diff --git a/constv b/constv new file mode 100755 index 000000000..bc0641662 Binary files /dev/null and b/constv differ diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 70b9f1b9a..c4a3d9c22 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1348,6 +1348,47 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { namespace_key_globals.push((gname, byte_len)); } } + let mut namespace_extern_prefixes = std::collections::BTreeSet::new(); + for entry in &cross_module.namespace_entries { + if let super::opts::NamespaceEntryKind::NestedNamespace { source_prefix } = &entry.kind { + if source_prefix != module_prefix { + namespace_extern_prefixes.insert(source_prefix.clone()); + } + } + } + for source_prefix in cross_module.namespace_import_prefixes.values() { + if source_prefix != module_prefix { + namespace_extern_prefixes.insert(source_prefix.clone()); + } + } + for source_prefix in cross_module.namespace_member_namespace_prefixes.values() { + if source_prefix != module_prefix { + namespace_extern_prefixes.insert(source_prefix.clone()); + } + } + let mut emitted_namespace_extern_prefixes = std::collections::BTreeSet::new(); + for source_prefix in namespace_extern_prefixes { + if emitted_namespace_extern_prefixes.insert(source_prefix.clone()) { + llmod.add_external_global(&format!("__perry_ns_{}", source_prefix), DOUBLE); + } + } + for export in &hir.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + let Some(source_prefix) = cross_module.namespace_import_prefixes.get(local) else { + continue; + }; + let getter_name = format!("perry_fn_{}__{}", module_prefix, sanitize(exported)); + if llmod.has_function(&getter_name) { + continue; + } + let getter = llmod.define_function(&getter_name, DOUBLE, vec![]); + let _ = getter.create_block("entry"); + let blk = getter.block_mut(0).unwrap(); + let value = blk.load(DOUBLE, &format!("@__perry_ns_{}", source_prefix)); + blk.ret(DOUBLE, &value); + } // For each `Expr::DynamicImport` target this module dispatches to, // declare the foreign module's `@__perry_ns_` as an // extern global so the dispatch site can load it. Deduped via @@ -1374,8 +1415,10 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { if prefix.starts_with("__native_mod__") || prefix.starts_with("__node_submod__") { continue; } - let ns_name = format!("__perry_ns_{}", prefix); - llmod.add_external_global(&ns_name, DOUBLE); + if emitted_namespace_extern_prefixes.insert(prefix.clone()) { + let ns_name = format!("__perry_ns_{}", prefix); + llmod.add_external_global(&ns_name, DOUBLE); + } // Issue #753: declare each dynamic-import target's `__init` // so the dispatch site in `Expr::DynamicImport` can call it // before loading the namespace. The wrapper-side init is diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index e85bc21a6..443434fd2 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -780,6 +780,9 @@ pub(super) fn compile_closure( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index ef04f52c3..85667f2c6 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -613,6 +613,9 @@ pub(super) fn compile_module_entry( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, @@ -1121,6 +1124,9 @@ pub(super) fn compile_module_entry( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 9ab0c840c..7315ab3f1 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -516,6 +516,9 @@ pub(super) fn compile_function( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 64c3fa82b..ee70cd564 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1145,7 +1145,7 @@ pub(super) fn emit_namespace_populator( let wrapper_name = format!( "__perry_wrap_perry_fn_{}__{}", source_prefix, - sanitize(source_local) + sanitize_member(source_local) ); let arity = (*param_count).min(16); let mut wrapper_params: Vec = vec![I64]; diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index d1406c286..b33b8fc91 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -235,6 +235,31 @@ fn node_stream_parent_kind( None } +fn map_set_default_super_kind<'a>( + classes: &HashMap, + mut parent: Option<&'a str>, +) -> Option { + let mut depth = 0usize; + while let Some(name) = parent { + match name { + "Map" => return Some(0), + "Set" => return Some(1), + _ => {} + } + let class = classes.get(name).copied()?; + if class.constructor.is_some() { + return None; + } + parent = class.extends_name.as_deref(); + depth += 1; + // Keep this bound in sync with the twin in lower_call/new_helpers.rs. + if depth > 64 { + break; + } + } + None +} + /// Compile a class instance method as a top-level LLVM function with the /// signature `perry_method__(this_box: double, args: double…) /// -> double`. The first parameter (`this`) is stored in a slot whose @@ -400,6 +425,9 @@ pub(super) fn compile_method( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, @@ -717,6 +745,31 @@ pub(super) fn compile_method( ctx.block() .call(DOUBLE, runtime_fn, &[(DOUBLE, &this_box), (DOUBLE, &opts)]); } + if let Some(kind) = map_set_default_super_kind(classes, class.extends_name.as_deref()) { + let undef_lit = + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let iterable = method + .params + .first() + .and_then(|param| ctx.locals.get(¶m.id).cloned()) + .map(|slot| ctx.block().load(DOUBLE, &slot)) + .unwrap_or_else(|| undef_lit.clone()); + let this_box = ctx + .this_stack + .last() + .cloned() + .map(|slot| ctx.block().load(DOUBLE, &slot)) + .unwrap_or_else(|| undef_lit.clone()); + ctx.block().call( + DOUBLE, + "js_map_set_subclass_init", + &[ + (DOUBLE, &this_box), + (I32, &kind.to_string()), + (DOUBLE, &iterable), + ], + ); + } // Wall 51: a no-own-ctor class with a DYNAMIC / cross-module parent // (`class X extends _mod.Parent {}`, captured as `extends_expr`) that @@ -1273,6 +1326,9 @@ pub(super) fn compile_static_method( namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, namespace_member_origin_names: &cross_module.namespace_member_origin_names, + namespace_member_vars: &cross_module.namespace_member_vars, + namespace_member_namespace_prefixes: &cross_module.namespace_member_namespace_prefixes, + namespace_import_prefixes: &cross_module.namespace_import_prefixes, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 77202c4f3..4ea7280d2 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1457,6 +1457,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> namespace_reexport_named_imports: opts.namespace_reexport_named_imports.clone(), namespace_member_prefixes: opts.namespace_member_prefixes, namespace_member_origin_names: opts.namespace_member_origin_names, + namespace_member_vars: opts.namespace_member_vars, + namespace_member_namespace_prefixes: opts.namespace_member_namespace_prefixes, + namespace_import_prefixes: opts.namespace_import_prefixes, imported_async_funcs: opts.imported_async_funcs, local_async_funcs, local_generator_funcs, @@ -1490,6 +1493,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> symbol: ctor_name, param_count: ic.constructor_param_count, has_own_constructor: ic.has_own_constructor, + uses_new_target: ic.constructor_uses_new_target, has_instance_fields: ic.has_instance_fields, has_rest: ic.constructor_has_rest, }, diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 580ea88b1..97c48796b 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -270,7 +270,13 @@ pub(crate) fn emit_module_globals( // semantically wrong (it'd return the closure value instead // of invoking it). let is_function_alias = hir.exported_functions.iter().any(|(exp, _)| exp == name); - if is_exported && !is_also_function && !is_function_alias { + let is_exported_alias_source = hir.exports.iter().any(|export| { + matches!(export, perry_hir::Export::Named { local, .. } if local == name) + }); + if (is_exported || is_exported_alias_source) + && !is_also_function + && !is_function_alias + { let fn_name = format!("perry_fn_{}__{}", module_prefix, sanitize(name),); let getter = llmod.define_function(&fn_name, DOUBLE, vec![]); let _ = getter.create_block("entry"); diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index bec9a8333..da3ecf4d9 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -175,16 +175,35 @@ pub struct CompileOptions { /// it dispatched `tracer.make(Math.random())` instead of /// `random.make(Math.random())`. pub namespace_member_prefixes: std::collections::HashMap<(String, String), String>, - /// Issue #5924 (companion to #680/#678): per-namespace origin-name - /// resolution. Keyed by `(namespace_local_name, member_name)` → - /// `origin_name`. `import_function_origin_names` is a flat map, so when - /// two namespaces imported into the same file both have a member with - /// the same name and only one of them is a re-export rename, the - /// rename's origin-name override clobbers the other namespace's - /// (correct, unrenamed) suffix — `import { Effect, Context } from - /// "effect"` broke `Context.Service` because `Effect`'s own re-exported - /// `Service` clobbered the flat map first. + /// Issue #680 / #5924 follow-up: per-namespace member origin-name overrides. + /// `namespace_member_prefixes` picks the producer module, but re-exported + /// members can be stored under a different origin export name; without this + /// `ns.alias` calls the right module with the wrong symbol name. Keyed by + /// `(namespace_local_name, exported_member_name)` → `origin_export_name`. + /// Being per-namespace also fixes #5924: `import_function_origin_names` is a + /// flat map, so two namespaces imported into the same file that both expose + /// a member with the same name (only one of them a re-export rename) would + /// clobber each other — `import { Effect, Context } from "effect"` broke + /// `Context.Service` because `Effect`'s own re-exported `Service` clobbered + /// the flat map first. pub namespace_member_origin_names: std::collections::HashMap<(String, String), String>, + /// Issue #680 follow-up: per-namespace exported-variable members. Namespace + /// member calls need to know when `ns.member` is a getter returning a + /// closure rather than a direct function symbol; otherwise codegen emits a + /// direct call to the zero-arg getter. Keyed by `(namespace_local_name, + /// exported_member_name)`. + pub namespace_member_vars: std::collections::HashSet<(String, String)>, + /// Issue #680 follow-up: per-namespace nested namespace re-exports. + /// `export * as child` members are namespace objects, not callable/value + /// exports from the parent prefix; this map preserves the nested target + /// prefix. Keyed by `(namespace_local_name, exported_member_name)` → + /// nested module prefix. + pub namespace_member_namespace_prefixes: std::collections::HashMap<(String, String), String>, + /// Issue #680 follow-up: namespace import local → target module prefix. + /// Member-specific maps are insufficient when the namespace binding itself + /// is read as a value; codegen must return the producer's real module + /// namespace object, including nested `export * as` members. + pub namespace_import_prefixes: std::collections::HashMap, /// When true, `compile_module` returns the textual LLVM IR (`.ll`) /// as bytes instead of invoking `clang -c` to produce an object file. /// Used by the bitcode-link path (`PERRY_LLVM_BITCODE_LINK=1`). @@ -475,6 +494,8 @@ pub struct ImportedClass { pub constructor_param_count: usize, /// Whether the source class declared its own constructor body. pub has_own_constructor: bool, + /// Whether the source constructor symbol may read `new.target`. + pub constructor_uses_new_target: bool, /// Whether the source class's constructor's last declared parameter is /// `...rest`. Symmetric to `method_has_rest` but for the constructor: the /// source module compiled `_constructor(this, arg0, …)` expecting @@ -549,6 +570,7 @@ pub(crate) struct ImportedCtor { pub symbol: String, pub param_count: usize, pub has_own_constructor: bool, + pub uses_new_target: bool, pub has_instance_fields: bool, /// True when the constructor's last declared param is `...rest`. Tells /// the cross-module `new` dispatch to pack the trailing args into an @@ -574,9 +596,14 @@ pub(crate) struct CrossModuleCtx { /// Issue #680: per-namespace member resolution. See doc on /// `CompileOptions::namespace_member_prefixes`. pub namespace_member_prefixes: std::collections::HashMap<(String, String), String>, - /// Issue #5924: per-namespace origin-name resolution. See doc on - /// `CompileOptions::namespace_member_origin_names`. + /// See `CompileOptions::namespace_member_origin_names` (#680 / #5924). pub namespace_member_origin_names: std::collections::HashMap<(String, String), String>, + /// See `CompileOptions::namespace_member_vars`. + pub namespace_member_vars: std::collections::HashSet<(String, String)>, + /// See `CompileOptions::namespace_member_namespace_prefixes`. + pub namespace_member_namespace_prefixes: std::collections::HashMap<(String, String), String>, + /// See `CompileOptions::namespace_import_prefixes`. + pub namespace_import_prefixes: std::collections::HashMap, pub imported_async_funcs: std::collections::HashSet, /// FuncIds of locally-defined async functions in this module. Populated /// from `hir.functions.is_async`. Used by `is_promise_expr` to refine diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index a9b28d688..8cb1a7890 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -568,9 +568,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `Object.prototype.hasOwnProperty.call(ImportedClass, sym)` // always returned false because the receiver was a closure-pointer // NaN-box (POINTER_TAG) rather than a class-ref (INT32_TAG). - if let Some(&cid) = ctx.class_ids.get(name) { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - return Ok(double_literal(f64::from_bits(bits))); + if !ctx.namespace_imports.contains(name) { + if let Some(&cid) = ctx.class_ids.get(name) { + let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); + return Ok(double_literal(f64::from_bits(bits))); + } } // Issue #841: named imports from Node submodules Perry recognizes // as runtime-backed values must win over the generic native-module @@ -600,6 +602,69 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ], )); } + if let Some(submod_key) = ctx.namespace_node_submodules.get(name) { + let submod_label = emit_string_literal_global(ctx, submod_key); + let submod_len = submod_key.len(); + let install_sym = crate::nm_install::nm_submod_install_symbol(submod_key); + let blk = ctx.block(); + if let Some(s) = install_sym { + blk.call_void(s, &[]); + } + return Ok(blk.call( + DOUBLE, + "js_node_submodule_namespace", + &[(PTR, &submod_label), (I32, &submod_len.to_string())], + )); + } + if ctx.namespace_imports.contains(name) { + if let Some(prefix) = ctx.namespace_import_prefixes.get(name) { + return Ok(ctx.block().load(DOUBLE, &format!("@__perry_ns_{}", prefix))); + } + let mut members: Vec = ctx + .namespace_member_prefixes + .keys() + .filter(|(ns, _)| ns == name) + .map(|(_, m)| m.clone()) + .collect(); + if !members.is_empty() { + members.sort(); + members.dedup(); + let n_str = (members.len() as u32).to_string(); + let zero_str = "0".to_string(); + let handle = ctx.block().call( + I64, + "js_object_alloc", + &[(I32, &zero_str), (I32, &n_str)], + ); + for member in &members { + let member_get = Expr::PropertyGet { + object: Box::new(Expr::ExternFuncRef { + name: name.clone(), + param_types: Vec::new(), + return_type: HirType::Any, + }), + property: member.clone(), + }; + let v_box = lower_expr(ctx, &member_get)?; + let key_idx = ctx.strings.intern(member); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &handle), (I64, &key_raw), (DOUBLE, &v_box)], + ); + } + let blk = ctx.block(); + return Ok(nanbox_pointer_inline(blk, &handle)); + } + return Ok(ctx + .block() + .call(DOUBLE, "js_unresolved_namespace_stub", &[])); + } if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { // Next.js lazy-require: a `_lazyreq_N` binding is the CJS require // shim's handle to a FUNCTION-LOCAL `require('S')`. S is @@ -694,96 +759,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { blk.call(I64, "js_closure_alloc_singleton", &[(PTR, &wrap_ptr)]); return Ok(nanbox_pointer_inline(blk, &closure_handle)); } - // Issue #841 companion: namespace imports for the same five - // submodules. The `collect_modules.rs` rejection skips - // these so the namespace binding flows through HIR and - // lands here. Emit a call to `js_node_submodule_namespace` - // which returns a per-submodule stub object whose properties - // are the function singletons named imports produce. - if let Some(submod_key) = ctx.namespace_node_submodules.get(name) { - let submod_label = emit_string_literal_global(ctx, submod_key); - let submod_len = submod_key.len(); - let install_sym = crate::nm_install::nm_submod_install_symbol(submod_key); - let blk = ctx.block(); - if let Some(s) = install_sym { - blk.call_void(s, &[]); - } - return Ok(blk.call( - DOUBLE, - "js_node_submodule_namespace", - &[(PTR, &submod_label), (I32, &submod_len.to_string())], - )); - } - // Issue #629: namespace imports for unresolved modules - // (`import * as fsp from "node:fs/promises"`) — when the - // module isn't backed by perry-stdlib bindings or compiled - // sources, the binding lands here. Pre-fix the catch-all - // returned TAG_TRUE so `typeof fsp === "boolean"` and every - // property access produced the confusing "(boolean).X is - // not a function" error. Route to the runtime stub which - // returns an empty-object pointer (typeof "object", every - // property reads undefined). Namespace bindings registered - // in `ctx.namespace_imports` already short-circuit via - // dedicated arms above; this catch-all only fires for - // names with no resolution at all. - if ctx.namespace_imports.contains(name) { - // A namespace import used as a whole VALUE (passed to a - // function, iterated by `Object.keys`/`for…in`/`Object.entries`, - // spread, …) must be a real object whose OWN ENUMERABLE - // properties are the source module's exports — not the empty - // `js_unresolved_namespace_stub`. Drizzle's - // `drizzle(pool, { schema })` (with `import * as schema`) and - // Stripe's `_prepResources` (`for (const name in resources)` - // over `import * as resources`) both enumerate the namespace and - // silently saw zero members otherwise. Materialize the object by - // resolving each exported member through the SAME per-member - // `ns.member` PropertyGet lowering (functions → closure - // singletons, consts → getters, classes → class refs). - let mut members: Vec = ctx - .namespace_member_prefixes - .keys() - .filter(|(ns, _)| ns == name) - .map(|(_, m)| m.clone()) - .collect(); - if !members.is_empty() { - members.sort(); - members.dedup(); - let n_str = (members.len() as u32).to_string(); - let zero_str = "0".to_string(); - let handle = ctx.block().call( - I64, - "js_object_alloc", - &[(I32, &zero_str), (I32, &n_str)], - ); - for member in &members { - let member_get = Expr::PropertyGet { - object: Box::new(Expr::ExternFuncRef { - name: name.clone(), - param_types: Vec::new(), - return_type: HirType::Any, - }), - property: member.clone(), - }; - let v_box = lower_expr(ctx, &member_get)?; - let key_idx = ctx.strings.intern(member); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &handle), (I64, &key_raw), (DOUBLE, &v_box)], - ); - } - let blk = ctx.block(); - return Ok(nanbox_pointer_inline(blk, &handle)); - } - return Ok(ctx - .block() - .call(DOUBLE, "js_unresolved_namespace_stub", &[])); - } // #4950: built-in globals that HIR lowers to `ExternFuncRef` even // in VALUE position (the `is_builtin_function` timer set — // `setTimeout`, `setImmediate`, …) used to fall through to the diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 13969ae36..43dbe3e59 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1407,7 +1407,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let s_box = lower_expr(ctx, object)?; let idx_d = lower_expr(ctx, index)?; let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); + let s_handle = unbox_str_handle(blk, &s_box); // #3987: route through the canonical-index runtime helper (it // takes the raw NaN-boxed key, not an `fptosi`'d i32) so a valid // array index returns its char and every non-canonical key diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 352696fcd..9777498b4 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -72,6 +72,7 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { "DataView" => 0xFFFF002B, "WeakMap" => 0xFFFF002C, "WeakSet" => 0xFFFF002D, + "URL" => 0xFFFF002F, "Promise" => 0xFFFF0027, "Number" => 0xFFFF00D0, "String" => 0xFFFF00D1, @@ -403,6 +404,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `Blob` — stream consumers allocate a scoped Blob-shaped // ObjectHeader tagged with this reserved class id. "Blob" => 0xFFFF0026u32, + // `File` — Blob-registry handles with File metadata. The + // runtime also treats File handles as Blob instances. + "File" => 0xFFFF002Eu32, + "URL" => 0xFFFF002Fu32, // `Promise` — runtime detects via GC_TYPE_PROMISE because // Promise values are raw promise allocations, not ObjectHeader // instances with a class_id field. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index d4b0ddaba..841927181 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -454,13 +454,20 @@ pub(crate) struct FnCtx<'a> { /// by namespace member access lowering to disambiguate when the same /// export name appears in multiple `import * as X / Y` sources. pub namespace_member_prefixes: &'a std::collections::HashMap<(String, String), String>, - /// Issue #5924: per-namespace origin-name resolution. Keyed by - /// `(namespace_local_name, member_name)` → `origin_name`. Consulted - /// before `import_function_origin_names` when computing the symbol + /// Per-namespace member origin-name overrides (#680 / #5924). Consulted + /// before the flat `import_function_origin_names` when computing the symbol /// suffix for a namespace-member access, so a re-export rename in one /// namespace can't clobber another namespace's unrenamed member of the /// same name. pub namespace_member_origin_names: &'a std::collections::HashMap<(String, String), String>, + /// Per-namespace exported-variable members. + pub namespace_member_vars: &'a std::collections::HashSet<(String, String)>, + /// Per-namespace nested namespace re-exports. + pub namespace_member_namespace_prefixes: + &'a std::collections::HashMap<(String, String), String>, + /// Namespace import local → target module prefix for whole-namespace value + /// reads. + pub namespace_import_prefixes: &'a std::collections::HashMap, /// Names of imported functions that are async. Used to wrap /// cross-module calls in promise machinery. // #854: cross-module async-import wrapping context; currently routed via diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index fcdf8bae1..bd4ba23f5 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -111,7 +111,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); let recv_handle = unbox_to_i64(blk, &recv_box); let arr_handle = blk.call(I64, "js_error_get_errors", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &arr_handle)) + let is_missing = blk.icmp_eq(I64, &arr_handle, "0"); + let tagged = nanbox_pointer_inline(blk, &arr_handle); + let tagged_bits = blk.bitcast_double_to_i64(&tagged); + let selected = blk.select( + I1, + &is_missing, + I64, + crate::nanbox::TAG_UNDEFINED_I64, + &tagged_bits, + ); + Ok(blk.bitcast_i64_to_double(&selected)) } Expr::PropertyGet { object, property } @@ -566,7 +576,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // discards the INT32 tag during the unbox and ends up returning // undefined. let is_class_ref_object = matches!(object.as_ref(), Expr::ClassRef(_)) - || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name)); + || matches!(object.as_ref(), Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name) + && !ctx.namespace_imports.contains(name)); if is_class_ref_object { let obj_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); @@ -836,6 +847,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // misses the class ref and falls back to the global // `Number`, dropping all inherited statics (effect's // `S.Number.ast`). + if let Some(nested_prefix) = ctx + .namespace_member_namespace_prefixes + .get(&(name.clone(), property.clone())) + { + return Ok(ctx + .block() + .load(DOUBLE, &format!("@__perry_ns_{}", nested_prefix))); + } let class_cid = ctx.class_ids.get(property).copied().or_else(|| { ctx.import_function_origin_names .get(property) @@ -848,22 +867,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Issue #680: prefer the per-namespace map so // `random.make` and `tracer.make` resolve to their // own sources even when both modules export `make`. - // Falls back to the flat `import_function_prefixes` - // for namespaces with no overlapping conflicts. - let _ns_lookup_name = if let Expr::ExternFuncRef { name, .. } = object.as_ref() - { + // Do not fall back to flat import maps here: another + // module can export a homonymous class/function with the + // same local name as this namespace. + let ns_lookup_name = if let Expr::ExternFuncRef { name, .. } = object.as_ref() { Some(name.clone()) } else { None }; - let source_prefix_opt = _ns_lookup_name - .as_ref() - .and_then(|ns| { - ctx.namespace_member_prefixes - .get(&(ns.clone(), property.clone())) - .cloned() - }) - .or_else(|| ctx.import_function_prefixes.get(property).cloned()); + let source_prefix_opt = ns_lookup_name.as_ref().and_then(|ns| { + ctx.namespace_member_prefixes + .get(&(ns.clone(), property.clone())) + .cloned() + }); if let Some(source_prefix) = source_prefix_opt { // Issue #678 followup: V8-fallback namespace member // read as a value (e.g. `let r = ns.render`) — there @@ -912,18 +928,50 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // body. The body only runs later when the consumer // actually calls `HashMap.keySet(self)`, by which time // both modules have finished `__init`. - // Issue #678/#5924: re-export renames mean the suffix - // in the origin module differs from the - // consumer-visible name. Namespace-scoped lookup - // first so a rename in a different namespace - // imported into this file can't clobber this - // namespace's unrenamed member of the same name. + // Issue #678 / #5924: re-export renames mean the suffix + // in the origin module differs from the consumer-visible + // name. Consult the per-namespace map first (so a rename + // in a different namespace imported into this file can't + // clobber this namespace's unrenamed member of the same + // name), then the flat `import_function_origin_names`. let origin_suffix = import_origin_suffix_ns( ctx.import_function_origin_names, ctx.namespace_member_origin_names, - _ns_lookup_name.as_deref().unwrap_or(""), + ns_lookup_name.as_deref().unwrap_or(""), property, ); + // Issue #680: a per-namespace exported VAR member is a + // getter returning a closure — read it off the actual + // namespace object rather than emitting a direct call to + // the zero-arg getter symbol. + let is_namespace_var = ns_lookup_name.as_ref().is_some_and(|ns| { + ctx.namespace_member_vars + .contains(&(ns.clone(), property.clone())) + }); + if is_namespace_var { + if let Some(namespace_prefix) = ns_lookup_name + .as_ref() + .and_then(|ns| ctx.namespace_import_prefixes.get(ns)) + { + let ns_value = ctx + .block() + .load(DOUBLE, &format!("@__perry_ns_{}", namespace_prefix)); + let key_idx = ctx.strings.intern(property); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let ns_bits = blk.bitcast_double_to_i64(&ns_value); + let ns_handle = blk.and(I64, &ns_bits, POINTER_MASK_I64); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); + return Ok(blk.call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, &ns_handle), (I64, &key_handle)], + )); + } + } if ctx.imported_vars.contains(property) { let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 649db26d5..12c741191 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -778,6 +778,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); } } + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; } return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index 0a7e08799..80b062ede 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -511,6 +511,14 @@ pub fn try_lower_console_call( Ok(None) } +fn property_get_chain_starts_at_namespace_import(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let mut current = expr; + while let Expr::PropertyGet { object, .. } = current { + current = object.as_ref(); + } + matches!(current, Expr::ExternFuncRef { name, .. } if ctx.namespace_imports.contains(name)) +} + pub fn try_lower_promise_static_call( ctx: &mut FnCtx<'_>, callee: &Expr, @@ -758,6 +766,7 @@ pub fn try_lower_native_method_str_dispatch( .is_some_and(|kind| is_collection_method_for_kind(kind, property.as_str())); let skip_native = matches!(object.as_ref(), Expr::GlobalGet(_)) || matches!(object.as_ref(), Expr::NativeModuleRef(_)) + || property_get_chain_starts_at_namespace_import(ctx, object) || (class_name_opt.is_some() && !is_buffer_class && !class_unknown_to_codegen diff --git a/crates/perry-codegen/src/lower_call/namespace_call.rs b/crates/perry-codegen/src/lower_call/namespace_call.rs index 2b895ce5d..d12ae7c9d 100644 --- a/crates/perry-codegen/src/lower_call/namespace_call.rs +++ b/crates/perry-codegen/src/lower_call/namespace_call.rs @@ -238,7 +238,6 @@ pub fn try_lower_namespace_member_call( .namespace_member_prefixes .get(&(ns_name.clone(), property.clone())) .cloned() - .or_else(|| ctx.import_function_prefixes.get(property).cloned()) else { return Ok(None); }; @@ -270,7 +269,10 @@ pub fn try_lower_namespace_member_call( property, ); let symbol = format!("perry_fn_{}__{}", source_prefix, origin_suffix); - if ctx.imported_vars.contains(property) { + if ctx + .namespace_member_vars + .contains(&(ns_name.clone(), property.clone())) + { // Var-shaped export: fetch closure via zero-arg // getter, then closure-call with the user args. ctx.pending_declares.push((symbol.clone(), DOUBLE, vec![])); @@ -297,10 +299,22 @@ pub fn try_lower_namespace_member_call( // Function-decl-shaped export: direct call with rest bundling. let declared_count = ctx .imported_func_param_counts - .get(property) + .get(origin_suffix) + .or_else(|| ctx.imported_func_param_counts.get(property)) .copied() - .unwrap_or(args.len()); - let has_rest = ctx.imported_func_has_rest.contains(property); + .unwrap_or_else(|| { + // No arity metadata for this re-exported function. When it's a + // renamed re-export (origin != property) called with no args, pad a + // single `undefined` so a 1-param callee still receives a slot; + // otherwise fall back to the actual arg count. + if args.is_empty() && origin_suffix != property { + 1 + } else { + args.len() + } + }); + let has_rest = ctx.imported_func_has_rest.contains(origin_suffix) + || ctx.imported_func_has_rest.contains(property); let mut lowered: Vec = Vec::with_capacity(declared_count); if has_rest { let fixed_count = declared_count.saturating_sub(1); diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 740a93fa8..554c079d6 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -13,8 +13,9 @@ use super::field_init::{apply_field_initializers_recursive, FieldInitMode}; use super::lower_builtin_new; use super::new_helpers::{ collect_decl_local_ids, ctor_body_calls_super, ctor_body_closure_calls_super, - ctor_body_has_value_return, ctor_body_uses_new_target, ctor_body_uses_this, - node_stream_parent_kind, + ctor_body_has_value_return, ctor_body_uses_this, ctor_chain_uses_new_target, + effective_constructor_param_count, local_constructor_symbol_exists, map_set_default_super_kind, + node_stream_parent_kind, restore_imported_ctor_new_target, set_imported_ctor_new_target, }; use crate::expr::{lower_expr, lower_js_args_array, nanbox_pointer_inline, FnCtx}; use crate::nanbox::{double_literal, POINTER_MASK_I64}; @@ -307,82 +308,6 @@ fn marshal_imported_ctor_args( } } -/// The effective constructor arity for `new (...)`: the class's own -/// ctor params, else — for a subclass with no own ctor — the closest -/// ancestor-with-a-ctor's param count (the synthesized default ctor forwards -/// `super(...args)`). Matches the standalone-ctor signature emitted in -/// `codegen/artifacts.rs`, so callers pass the right number of args. -fn effective_constructor_param_count(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> usize { - if let Some(ctor) = class.constructor.as_ref() { - return ctor.params.len(); - } - let mut parent = class.extends_name.as_deref(); - while let Some(pname) = parent { - if let Some(ctor) = ctx.imported_class_ctors.get(pname) { - if ctor.stops_constructor_walk() { - return ctor.param_count; - } - } - match ctx.classes.get(pname).copied() { - Some(pc) => { - if let Some(pctor) = pc.constructor.as_ref() { - return pctor.params.len(); - } - parent = pc.extends_name.as_deref(); - } - None => break, - } - } - 0 -} - -/// True when the standalone `_constructor` symbol exists (so the -/// recursion-guard / capture-collision redirect can call it instead of -/// inlining). Mirrors the lookup in `call_local_constructor_symbol`. -fn local_constructor_symbol_exists(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> bool { - let ctor_method_name = format!("{}_constructor", class.name); - ctx.methods - .contains_key(&(class.name.clone(), ctor_method_name)) -} - -/// #2768: true when the standalone `_constructor` symbol's body reads -/// `new.target` — either the class's OWN ctor body, or an ancestor ctor body -/// it reaches through `super(...)`. The symbol is a separately compiled -/// function whose only `new.target` source is the runtime cell, and a -/// `super(...)` call inlines the parent ctor body into that same symbol, so an -/// ancestor that reads `new.target` (e.g. an abstract-class guard in a base) -/// still observes the cell. Gating the cell write on the WHOLE chain keeps -/// `new Child()` correct when only the inherited body reads `new.target`, while -/// a chain with no reader anywhere stays on the zero-overhead fast path. The -/// walk follows `extends_name` through the codegen class map; an unresolved -/// parent name just stops the walk, and a depth cap guards a cyclic graph. -fn ctor_chain_uses_new_target(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> bool { - let reads = |c: &perry_hir::Class| { - c.constructor - .as_ref() - .is_some_and(|f| ctor_body_uses_new_target(&f.body)) - }; - if reads(class) { - return true; - } - let mut parent = class.extends_name.as_deref(); - let mut depth = 0; - while let Some(parent_name) = parent { - depth += 1; - if depth > 64 { - break; - } - let Some(pc) = ctx.classes.get(parent_name).copied() else { - break; - }; - if reads(pc) { - return true; - } - parent = pc.extends_name.as_deref(); - } - false -} - /// Emit a call to the shared standalone `_constructor` symbol and /// return the raw value it produced. The standalone ctor function returns /// `undefined` for an ordinary constructor (implicit `return this`) or the @@ -1388,6 +1313,11 @@ fn lower_new_impl( } else { None }; + let map_set_parent_kind = if !has_own_ctor && !has_imported_ctor { + map_set_default_super_kind(ctx.classes, class.extends_name.as_deref()) + } else { + None + }; let inherited_ctor_class: Option = if !has_own_ctor && has_extends { // Walk the inheritance chain to find the closest ancestor with // an explicit ctor — same logic as the body-inlining loop below. @@ -1799,7 +1729,9 @@ fn lower_new_impl( // to match the symbol's real signature (see codegen/mod.rs). ctx.pending_declares .push((ctor.symbol.clone(), DOUBLE, ctor_param_types)); + let saved_new_target = set_imported_ctor_new_target(ctx, class_name, &ctor); let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); + restore_imported_ctor_new_target(ctx, saved_new_target); } else if let Some(ctor) = ctx.imported_class_ctors.get(class_name).cloned() { // Pad missing optional args with TAG_UNDEFINED so the constructor // doesn't read garbage from stale registers, and pack the rest @@ -1825,7 +1757,9 @@ fn lower_new_impl( // ("value is not a function" on `new Chalk(...).red(...)`). ctx.pending_declares .push((ctor.symbol.clone(), DOUBLE, ctor_param_types)); + let saved_new_target = set_imported_ctor_new_target(ctx, class_name, &ctor); let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); + restore_imported_ctor_new_target(ctx, saved_new_target); ctx.block().store(DOUBLE, &ctor_ret, &ctor_result_slot); found_inherited_ctor = true; } @@ -1876,7 +1810,6 @@ fn lower_new_impl( .unwrap_or(false); if !found_inherited_ctor && class.extends_expr.is_some() && !parent_is_uncallable_builtin { if let Some(cid) = ctx.class_ids.get(class_name).copied().filter(|c| *c != 0) { - let undef_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let parent_val = ctx.block().call( DOUBLE, "js_get_dynamic_parent_value", @@ -1918,6 +1851,30 @@ fn lower_new_impl( ); } } + if !found_inherited_ctor { + if let Some(kind) = map_set_parent_kind { + let undef_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let iterable = lowered_args + .first() + .cloned() + .unwrap_or_else(|| undef_lit.clone()); + let this_box = ctx + .this_stack + .last() + .cloned() + .map(|slot| ctx.block().load(DOUBLE, &slot)) + .unwrap_or_else(|| undef_lit.clone()); + ctx.block().call( + DOUBLE, + "js_map_set_subclass_init", + &[ + (DOUBLE, &this_box), + (I32, &kind.to_string()), + (DOUBLE, &iterable), + ], + ); + } + } } // Now that the parent body chain has run (setting `this.config`, etc.), @@ -1961,8 +1918,20 @@ fn lower_new_impl( class_name, FieldInitMode::BetweenExclusiveTo(stop_at), )?; - } else { + } else if class + .extends_name + .as_deref() + .map(|p| ctx.classes.contains_key(p)) + .unwrap_or(false) + { apply_field_initializers_recursive(ctx, class_name, FieldInitMode::AfterRoot)?; + } else { + // Extends a builtin (Error/Array/Map/Set/…) with no user-class + // ancestor: the leaf IS the root of the user chain, so AfterRoot + // (chain[1..]) drops its own fields. Apply the leaf's initializers + // after the builtin super-init, mirroring the explicit-super() + // SelfOnly arm in this_super_call.rs. + apply_field_initializers_recursive(ctx, class_name, FieldInitMode::SelfOnly)?; } } emit_typed_shape_layout_init(ctx, class_name, &obj_handle); diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 05daaf95d..3acddfd5b 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -216,6 +216,146 @@ pub(crate) fn ctor_body_has_value_return(body: &[perry_hir::Stmt]) -> bool { ) } +/// The effective constructor arity for `new (...)`: the class's own +/// ctor params, else — for a subclass with no own ctor — the closest +/// ancestor-with-a-ctor's param count (the synthesized default ctor forwards +/// `super(...args)`). Matches the standalone-ctor signature emitted in +/// `codegen/artifacts.rs`, so callers pass the right number of args. +pub(super) fn effective_constructor_param_count( + ctx: &FnCtx<'_>, + class: &perry_hir::Class, +) -> usize { + if let Some(ctor) = class.constructor.as_ref() { + return ctor.params.len(); + } + let mut parent = class.extends_name.as_deref(); + while let Some(pname) = parent { + if let Some(ctor) = ctx.imported_class_ctors.get(pname) { + if ctor.stops_constructor_walk() { + return ctor.param_count; + } + } + match ctx.classes.get(pname).copied() { + Some(pc) => { + if let Some(pctor) = pc.constructor.as_ref() { + return pctor.params.len(); + } + parent = pc.extends_name.as_deref(); + } + None => break, + } + } + 0 +} + +/// True when the standalone `_constructor` symbol exists (so the +/// recursion-guard / capture-collision redirect can call it instead of +/// inlining). Mirrors the lookup in `call_local_constructor_symbol`. +pub(super) fn local_constructor_symbol_exists(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> bool { + let ctor_method_name = format!("{}_constructor", class.name); + ctx.methods + .contains_key(&(class.name.clone(), ctor_method_name)) +} + +/// #2768: true when the standalone `_constructor` symbol's body reads +/// `new.target` — either the class's OWN ctor body, or an ancestor ctor body +/// it reaches through `super(...)`. The symbol is a separately compiled +/// function whose only `new.target` source is the runtime cell, and a +/// `super(...)` call inlines the parent ctor body into that same symbol, so an +/// ancestor that reads `new.target` (e.g. an abstract-class guard in a base) +/// still observes the cell. Gating the cell write on the WHOLE chain keeps +/// `new Child()` correct when only the inherited body reads `new.target`, while +/// a chain with no reader anywhere stays on the zero-overhead fast path. The +/// walk follows `extends_name` through the codegen class map; an unresolved +/// parent name just stops the walk, and a depth cap guards a cyclic graph. +pub(super) fn ctor_chain_uses_new_target(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> bool { + let reads = |c: &perry_hir::Class| { + c.constructor + .as_ref() + .is_some_and(|f| ctor_body_uses_new_target(&f.body)) + }; + if reads(class) { + return true; + } + let mut parent = class.extends_name.as_deref(); + let mut depth = 0; + while let Some(parent_name) = parent { + depth += 1; + if depth > 64 { + break; + } + let Some(pc) = ctx.classes.get(parent_name).copied() else { + break; + }; + if reads(pc) { + return true; + } + parent = pc.extends_name.as_deref(); + } + false +} + +pub(super) fn map_set_default_super_kind<'a>( + classes: &std::collections::HashMap, + mut parent: Option<&'a str>, +) -> Option { + // Bound the walk like `ctor_chain_uses_new_target` above: a cyclic + // `extends` graph of constructorless classes would otherwise loop forever. + // Keep this bound in sync with the twin in codegen/method.rs. + let mut depth = 0usize; + while let Some(name) = parent { + depth += 1; + if depth > 64 { + return None; + } + match name { + "Map" => return Some(0), + "Set" => return Some(1), + _ => {} + } + let class = classes.get(name)?; + if class.constructor.is_some() { + return None; + } + parent = class.extends_name.as_deref(); + } + None +} + +pub(super) fn set_imported_ctor_new_target( + ctx: &mut FnCtx<'_>, + constructed_class_name: &str, + ctor: &crate::codegen::ImportedCtor, +) -> Option { + if !ctor.uses_new_target { + return None; + } + ctx.class_ids.get(constructed_class_name).map(|&cid| { + let prev = ctx + .block() + .call(crate::types::DOUBLE, "js_new_target_get", &[]); + let class_ref = crate::nanbox::double_literal(f64::from_bits( + crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF), + )); + ctx.block().call( + crate::types::DOUBLE, + "js_new_target_set", + &[(crate::types::DOUBLE, &class_ref)], + ); + prev + }) +} + +pub(super) fn restore_imported_ctor_new_target(ctx: &mut FnCtx<'_>, saved: Option) { + if let Some(prev) = saved { + ctx.block().call( + crate::types::DOUBLE, + "js_new_target_set", + &[(crate::types::DOUBLE, &prev)], + ); + } +} + pub(super) fn node_stream_parent_kind( ctx: &FnCtx<'_>, class: &perry_hir::Class, diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index f6723af32..f1840d094 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -95,10 +95,8 @@ pub fn try_lower_property_get_method_call( // array. startsWith/endsWith are string-only in JS so the // 2-arg form (searchString, position) is also unambiguous. let is_string_only_method = match property.as_str() { - "split" | "charCodeAt" | "charAt" | "trim" | "trimStart" | "trimEnd" | "substring" - | "substr" | "toLowerCase" | "toUpperCase" | "toLocaleLowerCase" - | "toLocaleUpperCase" | "replaceAll" | "padStart" | "padEnd" | "repeat" - | "codePointAt" | "localeCompare" => true, + "split" | "charCodeAt" | "charAt" | "substring" | "substr" | "replaceAll" + | "padStart" | "padEnd" | "repeat" | "codePointAt" | "localeCompare" => true, // Annex B §B.2.2 HTML wrappers (`bold`, `link`, `anchor`, …) are // string-only in the spec but collide with common user method // names — chalk's `chalk.bold(s)` is a styled-string builder @@ -119,6 +117,13 @@ pub fn try_lower_property_get_method_call( // js_native_call_method fallback handles it correctly via // js_string_replace_string. "replace" if args.len() == 2 && matches!(&args[1], Expr::Closure { .. }) => true, + // trim / case-conversion names are also commonly defined as their + // own methods on Any-typed library/builder objects. Let runtime + // dispatch choose by receiver shape so those objects keep their own + // methods while real strings still use String.prototype methods + // (same treatment as `slice`/`indexOf`/`normalize` above). + "trim" | "trimStart" | "trimEnd" | "toLowerCase" | "toUpperCase" + | "toLocaleLowerCase" | "toLocaleUpperCase" => false, // `slice` exists on strings, arrays, buffers, and Blob-like // objects. Let the runtime dispatcher choose by receiver shape; // forcing the string path here turns Blob slices into string diff --git a/crates/perry-codegen/src/lower_call/property_get/number_string.rs b/crates/perry-codegen/src/lower_call/property_get/number_string.rs index 26966e8bf..82920219d 100644 --- a/crates/perry-codegen/src/lower_call/property_get/number_string.rs +++ b/crates/perry-codegen/src/lower_call/property_get/number_string.rs @@ -14,10 +14,11 @@ use crate::type_analysis::{ is_array_expr, is_global_constructor_expr, is_map_expr, is_native_module_dynamic_index, is_promise_expr, is_set_expr, is_string_expr, is_url_search_params_expr, receiver_class_name, }; +use crate::type_analysis::{is_bigint_expr, is_numeric_expr}; use crate::types::{DOUBLE, I32, I64}; /// Number `.toFixed` / `.toPrecision` / `.toExponential`, Buffer/Number -/// `.toString(encoding|radix)`, and the universal `.toString()` arms. Returns +/// `.toString(encoding|radix)`, and numeric `.toString()` arms. Returns /// `Ok(Some(_))` when one of these claims the call, otherwise `Ok(None)` so the /// caller continues down the dispatch tower. pub(crate) fn try_lower_number_string_methods( @@ -152,6 +153,7 @@ pub(crate) fn try_lower_number_string_methods( // "ff" instead of "255". if property == "toString" && args.len() == 1 + && (is_numeric_expr(ctx, object) || is_bigint_expr(ctx, object)) && !is_string_expr(ctx, object) && !is_array_expr(ctx, object) && !is_date_receiver(ctx, object) @@ -188,15 +190,12 @@ pub(crate) fn try_lower_number_string_methods( return Ok(Some(nanbox_string_inline(blk, &handle))); } } - // Universal `.toString()` — works for any JS value via the - // runtime's js_jsvalue_to_string dispatch (numbers print as - // their decimal form, strings as themselves, objects as - // [object Object], etc.). Only intercepts if NO class - // method dispatch can win (i.e. the receiver isn't a known - // class with its own toString) — otherwise the user's - // override wouldn't run. + // Numeric `.toString()` without a radix. Do not claim arbitrary Any + // receivers here: plain objects may define an own `toString` method that + // must be called with the source arguments. if property == "toString" && args.len() <= 1 + && (is_numeric_expr(ctx, object) || is_bigint_expr(ctx, object)) && !is_string_expr(ctx, object) && !is_array_expr(ctx, object) && !is_date_receiver(ctx, object) diff --git a/crates/perry-codegen/tests/argless_builtin_extra_args.rs b/crates/perry-codegen/tests/argless_builtin_extra_args.rs index 278c077eb..27214a66a 100644 --- a/crates/perry-codegen/tests/argless_builtin_extra_args.rs +++ b/crates/perry-codegen/tests/argless_builtin_extra_args.rs @@ -22,6 +22,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index d300b854d..6ea648e60 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -41,6 +41,9 @@ fn entry_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index 2ec991984..12c304836 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -16,6 +16,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/destructure_call_location.rs b/crates/perry-codegen/tests/destructure_call_location.rs index 8dd2ed1ec..a86cf511f 100644 --- a/crates/perry-codegen/tests/destructure_call_location.rs +++ b/crates/perry-codegen/tests/destructure_call_location.rs @@ -33,6 +33,9 @@ fn base_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index 76a61cec1..ce8238d1c 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -16,6 +16,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index 874805536..2bf26ce1b 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -22,6 +22,9 @@ fn entry_opts(target: Option<&str>) -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index ae3bdd2bc..89bbcb64f 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -21,6 +21,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 37d763c4a..34595502c 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -22,6 +22,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -1347,7 +1350,8 @@ fn pod_field_read_after_dynamic_materialization_uses_number_coerce() { let ir = compile_ir("pod_dynamic_materialized_read_coerce.ts", body); assert!( - ir.contains("call double @js_number_coerce"), + ir.contains("call double @js_number_coerce") + || ir.contains("call double @js_object_get_field_by_name_f64"), "POD field reads after dynamic materialization must not feed boxed JSValue fallbacks into raw numeric arithmetic:\n{ir}" ); } @@ -7425,6 +7429,7 @@ fn typed_f64_clone_test_module(use_any_param: bool) -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, @@ -7596,6 +7601,7 @@ fn typed_i1_clone_test_module_named(name: &str) -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, @@ -7687,6 +7693,7 @@ fn typed_string_clone_test_module(case: &str) -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, @@ -7800,6 +7807,7 @@ fn typed_i1_numeric_predicate_module() -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, @@ -7876,6 +7884,7 @@ fn typed_i1_i32_predicate_module() -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, @@ -8001,6 +8010,7 @@ fn typed_i32_return_module(case: &str) -> Module { exported_functions: Vec::new(), script_global_functions: Vec::new(), references_global_this: false, + annexb_global_undefined_names: Vec::new(), widgets: Vec::new(), uses_fetch: false, uses_webassembly: false, diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 059f9e49b..94bad0424 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -16,6 +16,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 495306dd4..a68400bb0 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -16,6 +16,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index e6f8474e4..3c953b6a0 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -47,6 +47,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index 3aa85b3d1..77b7c42d5 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -16,6 +16,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index f9290a0ce..0e0ad4718 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -46,6 +46,9 @@ fn empty_opts() -> CompileOptions { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, diff --git a/crates/perry-hir/src/dynamic_import.rs b/crates/perry-hir/src/dynamic_import.rs index b66b7c9d3..f45856b17 100644 --- a/crates/perry-hir/src/dynamic_import.rs +++ b/crates/perry-hir/src/dynamic_import.rs @@ -156,18 +156,28 @@ fn flatten_into<'a, F>( imported, exported, } => { - // The value lives in `source`; if `source` re-exports it - // again, we want to follow that chain so codegen reaches - // the ULTIMATE owner. But for the MVP we surface one hop - // — the cross-module access pattern works regardless of - // how many hops away the value's defining module is, as - // long as we name the directly-importing source. - out.push(FlatExport { - name: exported.clone(), - source_module: source.clone(), - source_local: imported.clone(), - nested_namespace_of: None, - }); + let mut source_exports = Vec::new(); + let mut nested_visited = visited.clone(); + flatten_into(source, lookup, &mut source_exports, &mut nested_visited); + let resolved = source_exports + .into_iter() + .rev() + .find(|entry| entry.name == *imported); + if let Some(entry) = resolved { + out.push(FlatExport { + name: exported.clone(), + source_module: entry.source_module, + source_local: entry.source_local, + nested_namespace_of: entry.nested_namespace_of, + }); + } else { + out.push(FlatExport { + name: exported.clone(), + source_module: source.clone(), + source_local: imported.clone(), + nested_namespace_of: None, + }); + } } Export::ExportAll { source } => { // Recursively flatten the source's exports into ours. @@ -1353,12 +1363,9 @@ pub fn resolve_import_path_with_context>( // chunk set is what we want to ingest, and the runtime dispatch still // picks the right one by path string). // - // Guard rail: this only fires when *every* value resolves to a relative - // module specifier (`./…` / `../…`). That keeps it a strict - // deferrals-into-compiles change — a plain data object indexed for a - // non-module reason (`const cfg = { name: "app", port: "3000" }; - // import(cfg[k])`) has non-relative values, so it stays deferred exactly - // as before instead of trying to compile `"app"`/`"3000"` as modules. + // Guard rail: the driver validates these strings with the real module + // resolver before registering dynamic edges. Unresolvable values keep + // the site deferred instead of compiling arbitrary data-object strings. Expr::PropertyGet { object, .. } | Expr::IndexGet { object, .. } => { // Record every const-local id on the registry's indirection chain in // the *outer* `visiting` set so a value that member-accesses back @@ -1387,13 +1394,6 @@ pub fn resolve_import_path_with_context>( } } -/// True for a relative module specifier (`./x`, `../x`). Registry-object -/// dynamic-import resolution (#5207) only over-approximates to values that look -/// like relative chunk paths, so a non-module data object stays deferred. -fn is_relative_specifier(s: &str) -> bool { - s.starts_with("./") || s.starts_with("../") || s == "." || s == ".." -} - /// #5207: collect the candidate value expressions of a const object-literal /// "registry", following `const`-local indirection (`const R = { … }; /// import(R[k])`). Handles both object-literal HIR shapes: open-shape literals @@ -1431,9 +1431,9 @@ fn object_registry_values<'a, V: Borrow>( } /// #5207: resolve a registry's value expressions to the union of their module -/// specifiers, but only when *every* value resolves to a relative specifier. -/// Any non-relative or unresolvable value collapses the whole site to -/// `Unresolved` so it keeps deferring (no false compile of a non-module string). +/// specifier strings. Filesystem/package resolution is intentionally left to +/// the compile driver; if any string cannot be resolved there, the site stays +/// deferred so data-object strings are not compiled as modules. fn resolve_registry_value_union>( values: &[&Expr], consts: &std::collections::HashMap, @@ -1452,7 +1452,7 @@ fn resolve_registry_value_union>( ) { Resolution::Set(set) => { for s in set { - if !is_relative_specifier(&s) { + if !is_relative_module_specifier(&s) { return Resolution::Unresolved(NOT_STATICALLY_RESOLVABLE.to_string()); } if !out.contains(&s) { @@ -1470,6 +1470,10 @@ fn resolve_registry_value_union>( } } +fn is_relative_module_specifier(value: &str) -> bool { + value.starts_with("./") || value.starts_with("../") +} + /// Shared "couldn't statically resolve this dynamic import() specifier" /// message used by the resolver's fall-through arms. const NOT_STATICALLY_RESOLVABLE: &str = diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 47a7cc2c6..d434422df 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -941,6 +941,36 @@ fn flatten_export_all_cycle_safe() { assert!(names.contains(&"fromB".to_string())); } +#[test] +fn flatten_named_reexport_cycle_safe() { + let mut a = Module::new("a"); + a.exports.push(Export::ReExport { + source: "b".into(), + imported: "value".into(), + exported: "value".into(), + }); + let mut b = Module::new("b"); + b.exports.push(Export::ReExport { + source: "a".into(), + imported: "value".into(), + exported: "value".into(), + }); + b.exports.push(Export::Named { + local: "fallback".into(), + exported: "other".into(), + }); + let map = std::collections::HashMap::from([ + ("a".to_string(), a.clone()), + ("b".to_string(), b.clone()), + ]); + let lookup = |s: &str| map.get(s); + let flat = flatten_exports("a", &lookup); + assert_eq!(flat.len(), 1); + assert_eq!(flat[0].name, "value"); + assert_eq!(flat[0].source_module, "a"); + assert_eq!(flat[0].source_local, "value"); +} + #[test] fn flatten_namespace_re_export() { let mut m = Module::new("m"); diff --git a/crates/perry-hir/src/dynamic_import/visitors.rs b/crates/perry-hir/src/dynamic_import/visitors.rs index d7b1fc5a2..1ca542aca 100644 --- a/crates/perry-hir/src/dynamic_import/visitors.rs +++ b/crates/perry-hir/src/dynamic_import/visitors.rs @@ -437,6 +437,11 @@ fn visit_expr_for_dyn_imports_ref(expr: &Expr, f: &mut F) { if matches!(expr, Expr::DynamicImport { .. }) { f(expr); } + if let Expr::Closure { body, .. } = expr { + for s in body { + visit_stmt_for_dyn_imports_ref(s, f); + } + } walk_expr_children(expr, &mut |child| visit_expr_for_dyn_imports_ref(child, f)); } diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index 29279c1dc..0464abfd8 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -319,20 +319,21 @@ pub fn eval_override_enabled() -> bool { env_flag("PERRY_ALLOW_EVAL") } -/// Whether `PERRY_EVAL_CSP` is set — present runtime dynamic-code generation as -/// *unavailable* to capability probes. With it set, a trivial no-op -/// `new Function("")` / `Function("")` (the canonical -/// `try { new Function(""), true } catch { false }` feature-test — the same -/// shape a CSP `unsafe-eval` policy blocks) throws at construction instead of -/// const-folding to a no-op function. Libraries that probe this way (e.g. zod 4's -/// object-validator JIT) then take their non-codegen interpreter fallback, which -/// perry compiles normally. Default off (spec-compliant: Node returns an empty -/// function); opt-in for ahead-of-time binaries that hit such a JIT. Only the -/// trivial no-op shape is affected — real literal bodies (`new Function("return -/// 42")`, the `return this` globalThis polyfill) still fold, so those idioms are -/// unaffected. +/// Whether trivial dynamic-codegen capability probes should report unavailable. +/// A no-op `new Function("")` / `Function("")` is commonly used as an +/// `unsafe-eval` feature-test. Perry is ahead-of-time compiled, so the default +/// is to throw at construction for that probe shape and let callers select their +/// non-codegen fallback. Set `PERRY_EVAL_CSP=0` to opt back into const-folding +/// the empty function. Real literal bodies (`new Function("return 42")`, the +/// `return this` globalThis polyfill) still fold. pub fn eval_csp_probe_unavailable() -> bool { - env_flag("PERRY_EVAL_CSP") + match std::env::var("PERRY_EVAL_CSP") { + Ok(v) => { + let v = v.trim().to_ascii_lowercase(); + !matches!(v.as_str(), "" | "0" | "off" | "false" | "no") + } + Err(_) => true, + } } /// Whether `PERRY_ALLOW_UNIMPLEMENTED` is set — forces non-strict (defer) mode diff --git a/crates/perry-hir/src/lower/closure_analysis.rs b/crates/perry-hir/src/lower/closure_analysis.rs index e436d00d6..f3afaa75c 100644 --- a/crates/perry-hir/src/lower/closure_analysis.rs +++ b/crates/perry-hir/src/lower/closure_analysis.rs @@ -56,6 +56,105 @@ pub(crate) fn widen_mutable_captures_stmts(stmts: &mut [Stmt]) { } } +/// Insert preallocated boxes when a closure is created before a later lexical +/// binding that it captures. Later lowering may split/in-line expressions into +/// `let getter = () => value; let value = ...`; the closure must capture the +/// future binding's box, not its current undefined snapshot. +pub(crate) fn preallocate_forward_captured_lets_stmts(stmts: &mut Vec) { + let mut forward = collect_forward_captured_let_ids(stmts); + if !forward.is_empty() { + forward.sort(); + forward.dedup(); + match stmts.first_mut() { + Some(Stmt::PreallocateBoxes(existing)) => { + for id in forward { + if !existing.contains(&id) { + existing.push(id); + } + } + existing.sort(); + } + _ => stmts.insert(0, Stmt::PreallocateBoxes(forward)), + } + } + + for stmt in stmts.iter_mut() { + preallocate_forward_captured_lets_stmt(stmt); + } +} + +fn collect_forward_captured_let_ids(stmts: &[Stmt]) -> Vec { + let mut scope_lets = std::collections::HashSet::new(); + for stmt in stmts { + if let Stmt::Let { id, .. } = stmt { + scope_lets.insert(*id); + } + } + + let mut declared = std::collections::HashSet::new(); + let mut out = std::collections::HashSet::new(); + for stmt in stmts { + let mut captures = std::collections::HashSet::new(); + collect_closure_captures_stmt(stmt, &mut captures); + for id in captures { + if scope_lets.contains(&id) && !declared.contains(&id) { + out.insert(id); + } + } + if let Stmt::Let { id, .. } = stmt { + declared.insert(*id); + } + } + + out.into_iter().collect() +} + +fn preallocate_forward_captured_lets_stmt(stmt: &mut Stmt) { + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + preallocate_forward_captured_lets_stmts(then_branch); + if let Some(else_branch) = else_branch { + preallocate_forward_captured_lets_stmts(else_branch); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + preallocate_forward_captured_lets_stmts(body); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + preallocate_forward_captured_lets_stmt(init); + } + preallocate_forward_captured_lets_stmts(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + preallocate_forward_captured_lets_stmts(body); + if let Some(catch) = catch { + preallocate_forward_captured_lets_stmts(&mut catch.body); + } + if let Some(finally) = finally { + preallocate_forward_captured_lets_stmts(finally); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + preallocate_forward_captured_lets_stmts(&mut case.body); + } + } + Stmt::Labeled { body, .. } => { + preallocate_forward_captured_lets_stmt(body); + } + _ => {} + } +} + fn widen_mutable_captures_stmt( stmt: &mut Stmt, scope_mutable: &std::collections::HashSet, @@ -959,6 +1058,44 @@ fn collect_closure_captures_expr(expr: &Expr, out: &mut std::collections::HashSe collect_closure_captures_expr(e, out); } } + // A fresh class expression can hold closures in its static + // initializers and in the captured-args snapshot. This path is + // reachable via `class X extends Y {}` bindings, so a closure + // capturing a forward-declared `let` here must be seen or its box + // won't be preallocated. + Expr::ClassExprFresh { + named_statics, + symbol_statics, + captured_args, + .. + } => { + for (_, e) in named_statics { + collect_closure_captures_expr(e, out); + } + for (k, v) in symbol_statics { + collect_closure_captures_expr(k, out); + collect_closure_captures_expr(v, out); + } + for e in captured_args { + collect_closure_captures_expr(e, out); + } + } + Expr::InstanceOf { expr, ty_expr, .. } => { + collect_closure_captures_expr(expr, out); + if let Some(te) = ty_expr { + collect_closure_captures_expr(te, out); + } + } + Expr::In { property, object } => { + collect_closure_captures_expr(property, out); + collect_closure_captures_expr(object, out); + } + Expr::TaggedTemplateStrings { cooked, .. } => { + for e in cooked { + collect_closure_captures_expr(e, out); + } + } + Expr::TemplateRaw(inner) => collect_closure_captures_expr(inner, out), _ => {} } } diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index 45d5e8414..ab0139e86 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -310,17 +310,23 @@ pub(crate) fn try_const_fold_function_construct_kind( None => (String::new(), String::new()), }; - // CSP capability-probe handling (`PERRY_EVAL_CSP`). A trivial no-op - // `new Function("")` / `Function("")` is the canonical runtime-codegen - // feature-test (`try { new Function(""), true } catch { false }`). perry is - // ahead-of-time compiled and cannot generate code from a runtime string, so - // under CSP mode this probe must report "unavailable" — throw at - // *construction* (not when called), exactly as a CSP `unsafe-eval`-blocked - // environment does — so probing callers (e.g. zod 4's validator JIT) take - // their non-codegen interpreter fallback. Only the trivial empty-body no-op - // is refused; real literal bodies (`return 42`, the `return this` globalThis - // polyfill) still fold, preserving spec behavior by default. - if body_src.trim().is_empty() && crate::eval_classifier::eval_csp_probe_unavailable() { + // Capability-probe handling. A trivial no-op `new Function("")` / + // `Function("")` — an EXPLICIT empty-string body — is commonly used to decide + // whether runtime code generation is available. Perry is ahead-of-time + // compiled, so report "unavailable" by throwing at construction; callers can + // then take their non-codegen fallback. Only the trivial empty-body no-op is + // refused; real literal bodies (`return 42`, the `return this` globalThis + // polyfill) still fold. + // + // `new Function()` with NO arguments is NOT this probe — it is the plain + // "make an empty function" spelling (e.g. `Reflect.construct(Intl.X, args, + // new Function())` to pick a custom prototype). Node returns + // `function anonymous(){}` for it, so fold it rather than throw; the throw is + // gated on `!consts.is_empty()` so only an explicit empty-string body probes. + if !consts.is_empty() + && body_src.trim().is_empty() + && crate::eval_classifier::eval_csp_probe_unavailable() + { return synth_throwing_iife( ctx, "throw new TypeError(\"Function: runtime dynamic code generation is \ diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 6eba59ed6..0c0cab3e8 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -162,6 +162,24 @@ fn is_fs_dir_receiver(ctx: &LoweringContext, expr: &ast::Expr) -> bool { }) } +fn chain_roots_at_namespace_or_import(ctx: &LoweringContext, expr: &ast::Expr) -> bool { + let mut current = unwrap_transparent_expr(expr); + loop { + match current { + ast::Expr::Ident(ident) => { + let name = ident.sym.as_ref(); + return ctx.namespace_import_locals.contains(name) + || (ctx.lookup_local(name).is_none() + && ctx.lookup_imported_func(name).is_some()); + } + ast::Expr::Member(member) => { + current = unwrap_transparent_expr(member.obj.as_ref()); + } + _ => return false, + } + } +} + /// Does this expression's method chain originate from a node:stream /// source — `Readable.from(...)` / `Readable.of(...)`, a local already tagged /// as a readable stream, `new Transform()`, or a chain of lazy iterator helpers @@ -598,6 +616,7 @@ pub(super) fn try_array_only_methods( let recv_is_class = recv_is_class || chain_roots_at_stream(ctx, member_obj) || chain_roots_at_iterator_from(member_obj) + || chain_roots_at_namespace_or_import(ctx, member_obj) || is_util_mime_params_receiver(ctx, member_obj); // thisArg routing: the dense `Expr::Array` fast paths // carry only the callback and silently drop a 2nd positional diff --git a/crates/perry-hir/src/lower/expr_call/imported_array_methods.rs b/crates/perry-hir/src/lower/expr_call/imported_array_methods.rs index 38d07960a..d9d4d99ba 100644 --- a/crates/perry-hir/src/lower/expr_call/imported_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/imported_array_methods.rs @@ -49,6 +49,11 @@ pub(super) fn try_imported_array_methods( param_types, return_type, }; + let extern_is_array = matches!( + extern_ref, + Expr::ExternFuncRef { ref return_type, .. } + if matches!(return_type, Type::Array(_)) + ); match method_name { "join" => { // Issue #420 (drizzle): `sql.join(arr)` — `sql` @@ -75,7 +80,7 @@ pub(super) fn try_imported_array_methods( // Fall through. } "map" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let cb = args.into_iter().next().unwrap(); let cb = ctx.maybe_wrap_builtin_callback(cb, &call.args[0]); return Ok(Ok(Expr::ArrayMap { @@ -84,7 +89,7 @@ pub(super) fn try_imported_array_methods( })); } "filter" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let cb = args.into_iter().next().unwrap(); let cb = ctx.maybe_wrap_builtin_callback(cb, &call.args[0]); return Ok(Ok(Expr::ArrayFilter { @@ -93,7 +98,7 @@ pub(super) fn try_imported_array_methods( })); } "forEach" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let cb = args.into_iter().next().unwrap(); let cb = ctx.maybe_wrap_builtin_callback(cb, &call.args[0]); return Ok(Ok(Expr::ArrayForEach { @@ -102,7 +107,7 @@ pub(super) fn try_imported_array_methods( })); } "find" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let cb = args.into_iter().next().unwrap(); let cb = ctx.maybe_wrap_builtin_callback(cb, &call.args[0]); return Ok(Ok(Expr::ArrayFind { @@ -138,7 +143,7 @@ pub(super) fn try_imported_array_methods( } "indexOf" // #2804: carry the optional fromIndex (2nd arg). - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let mut it = args.into_iter(); let value = it.next().unwrap(); let from_index = it.next().map(Box::new); @@ -149,7 +154,7 @@ pub(super) fn try_imported_array_methods( })); } "includes" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let mut it = args.into_iter(); let value = it.next().unwrap(); let from_index = it.next().map(Box::new); @@ -160,7 +165,7 @@ pub(super) fn try_imported_array_methods( })); } "slice" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let mut args_iter = args.into_iter(); let start = args_iter.next().unwrap(); let end = args_iter.next(); @@ -171,7 +176,7 @@ pub(super) fn try_imported_array_methods( })); } "reduce" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let mut args_iter = args.into_iter(); let callback = args_iter.next().unwrap(); let initial = args_iter.next().map(Box::new); @@ -183,13 +188,13 @@ pub(super) fn try_imported_array_methods( } "flat" // depth-aware calls fall through. - if args.is_empty() => { + if args.is_empty() && extern_is_array => { return Ok(Ok(Expr::ArrayFlat { array: Box::new(extern_ref), })); } "reduceRight" - if !args.is_empty() => { + if !args.is_empty() && extern_is_array => { let mut args_iter = args.into_iter(); let callback = args_iter.next().unwrap(); let initial = args_iter.next().map(Box::new); @@ -199,19 +204,19 @@ pub(super) fn try_imported_array_methods( initial, })); } - "toReversed" => { + "toReversed" if extern_is_array => { return Ok(Ok(Expr::ArrayToReversed { array: Box::new(extern_ref), })); } - "toSorted" => { + "toSorted" if extern_is_array => { let comparator = args.into_iter().next().map(Box::new); return Ok(Ok(Expr::ArrayToSorted { array: Box::new(extern_ref), comparator, })); } - "toSpliced" => { + "toSpliced" if extern_is_array => { // #2794: handle omitted args. let arg_count = args.len(); let mut args_iter = args.into_iter(); @@ -230,7 +235,7 @@ pub(super) fn try_imported_array_methods( })); } "with" - if args.len() >= 2 => { + if args.len() >= 2 && extern_is_array => { let mut args_iter = args.into_iter(); let index = args_iter.next().unwrap(); let value = args_iter.next().unwrap(); @@ -240,13 +245,13 @@ pub(super) fn try_imported_array_methods( value: Box::new(value), })); } - "entries" => { + "entries" if extern_is_array => { return Ok(Ok(Expr::ArrayEntries(Box::new(extern_ref)))); } - "keys" => { + "keys" if extern_is_array => { return Ok(Ok(Expr::ArrayKeys(Box::new(extern_ref)))); } - "values" => { + "values" if extern_is_array => { return Ok(Ok(Expr::ArrayValues(Box::new(extern_ref)))); } _ => {} // Fall through for other methods diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index e69f4993c..bde50dce1 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1063,7 +1063,12 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul .entry(cname.clone()) .and_modify(|d| *d = (*d).min(fn_body_scope_depth)) .or_insert(fn_body_scope_depth); - ctx.forward_class_names.insert(cname); + ctx.forward_class_names.insert(cname.clone()); + if class_decl.class.super_class.is_some() + && ctx.lookup_local_in_current_scope(&cname).is_none() + { + ctx.define_local(cname, Type::Any); + } } } } diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 0a98ea01c..45e773a53 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -178,14 +178,19 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // param shadows it — and the `resolve_class_alias().is_none()` // guard on the reroute block below would then skip it. // - // Capturing the binding here (when no real class of this name is in - // scope) keeps the reroute stable against both. - let callee_local_at_entry: Option = if ctx.lookup_class(&class_name).is_none() - { - ctx.lookup_local(&class_name) - } else { - None - }; + // Capturing the binding here keeps the reroute stable against both, + // and also lets a lexical binding shadow a same-named class. + let callee_local_at_entry = ctx.lookup_local(ident.sym.as_ref()); + if ctx.lookup_class(&class_name).is_some() { + if let Some(local_id) = callee_local_at_entry { + let args = lower_optional_args(ctx, new_expr.args.as_deref())?; + return Ok(Expr::NewDynamic { + callee: Box::new(Expr::LocalGet(local_id)), + args, + byte_offset: new_byte_offset, + }); + } + } if matches!( ctx.lookup_native_module(&class_name), Some(("url", Some("Url"))) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index a3c7194ad..ad1460249 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -145,16 +145,12 @@ pub(crate) fn lower_class_expr( .lookup_class_captures(&synthetic_name) .map(|ids| ids.iter().map(|id| Expr::LocalGet(*id)).collect()) .unwrap_or_default(); - // Static block synthetic-method names (`__perry_static_init_N`), in - // source order — emitted as inline `StaticMethodCall`s on the - // shared-template path so blocks run at class-evaluation time (the - // same treatment the class-declaration path gives them). - let static_block_names: Vec = class - .static_methods - .iter() - .filter(|m| m.name.starts_with("__perry_static_init_")) - .map(|m| m.name.clone()) - .collect(); + let static_init_exprs = crate::lower_decl::build_interleaved_static_init_exprs( + &class_expr.class.body, + &synthetic_name, + &class.static_fields, + &class.static_methods, + ); ctx.pending_classes.push(class); // #1772: a class EXPRESSION that carries per-evaluation static // fields and is NOT a mixin (`class extends `) lowers to a @@ -243,37 +239,7 @@ pub(crate) fn lower_class_expr( }); } seq.extend(computed_member_registrations); - for (k, v) in static_symbol_registrations { - seq.push(Expr::RegisterClassStaticSymbol { - class_name: synthetic_name.clone(), - key_expr: Box::new(k), - value_expr: Box::new(v), - }); - } - // Inline the named static field/element initializers at the point - // the class expression evaluates (source order), mirroring the - // class-declaration path. Without this the shared-template path - // relied solely on the late `init_static_fields_late` pass, which - // runs AFTER the surrounding top-level statements — so a read like - // `C.x` immediately after `var C = class { static x = 1 }` saw the - // uninitialized (0.0) slot. (Private statics carry a `#`-prefixed - // name and flow through the same StaticFieldSet path.) - for (name, v) in named_statics { - seq.push(Expr::StaticFieldSet { - class_name: synthetic_name.clone(), - field_name: name, - value: Box::new(v), - }); - } - // Static blocks run right after the static-field initializers, in - // source order, with the class as `this`. - for block_name in static_block_names { - seq.push(Expr::StaticMethodCall { - class_name: synthetic_name.clone(), - method_name: block_name, - args: Vec::new(), - }); - } + seq.extend(static_init_exprs); if seq.is_empty() { Ok(Expr::ClassRef(synthetic_name)) } else { diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index ba0a5c4f2..fadcf1610 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -1046,24 +1046,31 @@ pub fn lower_module_full( // also go through the box so they observe each other's writes. Without // this pass, a `get: () => value` sibling of `inc: () => value++` captures // the raw initial value instead of the shared boxed binding. + preallocate_forward_captured_lets_stmts(&mut module.init); widen_mutable_captures_stmts(&mut module.init); for func in &mut module.functions { + preallocate_forward_captured_lets_stmts(&mut func.body); widen_mutable_captures_stmts(&mut func.body); } for class in &mut module.classes { for method in &mut class.methods { + preallocate_forward_captured_lets_stmts(&mut method.body); widen_mutable_captures_stmts(&mut method.body); } for (_, getter) in &mut class.getters { + preallocate_forward_captured_lets_stmts(&mut getter.body); widen_mutable_captures_stmts(&mut getter.body); } for (_, setter) in &mut class.setters { + preallocate_forward_captured_lets_stmts(&mut setter.body); widen_mutable_captures_stmts(&mut setter.body); } for static_method in &mut class.static_methods { + preallocate_forward_captured_lets_stmts(&mut static_method.body); widen_mutable_captures_stmts(&mut static_method.body); } if let Some(ref mut ctor) = class.constructor { + preallocate_forward_captured_lets_stmts(&mut ctor.body); widen_mutable_captures_stmts(&mut ctor.body); } } diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 1c67a6a1c..a62b596d8 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -17,6 +17,7 @@ use crate::ir::*; // Topical sub-modules extracted from this file (issue #1435 — pure code move). mod namespace; mod native_default_import; +mod reexports; // Re-export moved items so existing `crate::...` / `super::*` call paths keep // resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`. @@ -24,6 +25,7 @@ pub(crate) use namespace::lower_namespace_as_class; use native_default_import::{ is_cjs_style_native_default_import, node_submodule_default_export_key, }; +use reexports::imported_binding_reexport; pub(crate) fn lower_module_decl( ctx: &mut LoweringContext, @@ -1320,6 +1322,7 @@ pub(crate) fn lower_module_decl( ast::Decl::TsEnum(enum_decl) => { let en = lower_enum_decl(ctx, enum_decl, true)?; let enum_name = en.name.clone(); + module.init.push(crate::lower_decl::enum_runtime_let(ctx, &en)); module.enums.push(en); module.exported_objects.push(enum_name.clone()); module.exports.push(Export::Named { @@ -1472,6 +1475,17 @@ pub(crate) fn lower_module_decl( continue; } + if let Some((source, imported)) = + imported_binding_reexport(&module.imports, &local) + { + module.exports.push(Export::ReExport { + source, + imported, + exported: exported.clone(), + }); + continue; + } + module.exports.push(Export::Named { local: local.clone(), exported: exported.clone(), diff --git a/crates/perry-hir/src/lower/module_decl/reexports.rs b/crates/perry-hir/src/lower/module_decl/reexports.rs new file mode 100644 index 000000000..49c7e3fd1 --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/reexports.rs @@ -0,0 +1,28 @@ +use crate::ir::{Import, ImportSpecifier}; + +pub(crate) fn imported_binding_reexport( + imports: &[Import], + local: &str, +) -> Option<(String, String)> { + imports.iter().find_map(|import| { + if import.is_native { + return None; + } + import + .specifiers + .iter() + .find_map(|specifier| match specifier { + ImportSpecifier::Named { + imported, + local: imported_local, + } if imported_local == local => Some((import.source.clone(), imported.clone())), + ImportSpecifier::Default { + local: imported_local, + } if imported_local == local => { + Some((import.source.clone(), "default".to_string())) + } + ImportSpecifier::Namespace { .. } => None, + _ => None, + }) + }) +} diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index b7e655dea..f0ba3ddd6 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1147,6 +1147,7 @@ pub(crate) fn lower_stmt( } ast::Decl::TsEnum(enum_decl) => { let en = lower_enum_decl(ctx, enum_decl, false)?; + module.init.push(crate::lower_decl::enum_runtime_let(ctx, &en)); module.enums.push(en); } ast::Decl::TsInterface(iface_decl) => { diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 05178b391..21c74998d 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -976,7 +976,12 @@ pub fn lower_fn_body_block_stmt( .entry(cname.clone()) .and_modify(|d| *d = (*d).min(cur_scope_depth)) .or_insert(cur_scope_depth); - ctx.forward_class_names.insert(cname); + ctx.forward_class_names.insert(cname.clone()); + if class_decl.class.super_class.is_some() + && ctx.lookup_local_in_current_scope(&cname).is_none() + { + ctx.define_local(cname, Type::Any); + } } } diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 1583a25f2..55692514e 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -282,10 +282,26 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result = ctx + .lookup_class_captures(&class.name) + .map(|ids| ids.iter().map(|id| Expr::LocalGet(*id)).collect()) + .unwrap_or_default(); + let class_local = ctx + .lookup_local_in_current_scope(&class_name) + .unwrap_or_else(|| ctx.define_local(class_name.clone(), Type::Any)); + let (mut named_statics, symbol_statics, fresh_static_init_stmts) = + crate::lower_decl::build_fresh_class_static_init( + &class_decl.class.body, + &class.name, + &class.static_fields, + &class.static_methods, + class_local, + ); + if let Some((parent_id, _)) = parent_value_local { + named_statics.push(( + "__perry_parent_value".to_string(), + Expr::LocalGet(parent_id), + )); + } + result.push(Stmt::Let { + id: class_local, + name: class_name.clone(), + ty: Type::Any, + init: Some(Expr::ClassExprFresh { + template: class.name.clone(), + named_statics, + symbol_statics, + captured_args, + }), + mutable: false, + }); + result.extend(fresh_static_init_stmts); + } // Static field initializers + static blocks for a // function-nested class. The module-level path // (`lower/stmt.rs`) emits these into `module.init`; here they @@ -321,12 +374,14 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Stmt { + let id = ctx + .lookup_local(&en.name) + .unwrap_or_else(|| ctx.define_local(en.name.clone(), perry_types::Type::Any)); + let mut properties = Vec::new(); + for member in &en.members { + match &member.value { + EnumValue::Number(value) => { + properties.push((member.name.clone(), Expr::Number(*value as f64))); + properties.push((value.to_string(), Expr::String(member.name.clone()))); + } + EnumValue::String(value) => { + properties.push((member.name.clone(), Expr::String(value.clone()))); + } + } + } + Stmt::Let { + id, + name: en.name.clone(), + ty: perry_types::Type::Any, + mutable: true, + init: Some(Expr::Object(properties)), + } +} + /// Compute an enum's members (names + values) without touching the lowering /// context. Pure so it can run in the forward-reference pre-scan /// (`pre_register_module_enums`) and again at the real declaration site and diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index 7809b04f7..578085db1 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -48,7 +48,7 @@ pub(crate) use class_members::{ pub(crate) use class_validation::{ validate_class_element_early_errors, validate_legacy_decorator_surface, }; -pub(crate) use enum_decl::{compute_enum_members, lower_enum_decl}; +pub(crate) use enum_decl::{compute_enum_members, enum_runtime_let, lower_enum_decl}; pub(crate) use fn_decl::lower_fn_decl; pub(crate) use helpers::{ append_synthetic_arguments_param, body_has_use_strict, body_uses_arguments, @@ -61,5 +61,8 @@ pub(crate) use private_members::{ build_private_scope, lower_private_getter, lower_private_method, lower_private_prop, lower_private_setter, }; -pub(crate) use static_init::build_interleaved_static_init_stmts; +pub(crate) use static_init::{ + build_fresh_class_static_init, build_interleaved_static_init_exprs, + build_interleaved_static_init_stmts, +}; pub(crate) use type_alias::lower_type_alias_decl; diff --git a/crates/perry-hir/src/lower_decl/static_init.rs b/crates/perry-hir/src/lower_decl/static_init.rs index 1a2e9ede5..4a154db13 100644 --- a/crates/perry-hir/src/lower_decl/static_init.rs +++ b/crates/perry-hir/src/lower_decl/static_init.rs @@ -5,6 +5,7 @@ use swc_ecma_ast as ast; use crate::ir::*; +use perry_types::LocalId; /// Per ClassDefinitionEvaluation step 34, a class's static fields and /// static blocks evaluate in a single pass over source order — a static @@ -89,3 +90,154 @@ pub(crate) fn build_interleaved_static_init_stmts( } out } + +pub(crate) fn build_interleaved_static_init_exprs( + class_body: &[ast::ClassMember], + class_name: &str, + static_fields: &[ClassField], + static_methods: &[Function], +) -> Vec { + let emit_field = |out: &mut Vec, sf: &ClassField| { + let Some(init) = &sf.init else { return }; + let mut init_value = init.clone(); + crate::analysis::substitute_lexical_this_in_expr( + &mut init_value, + &Expr::ClassRef(class_name.to_string()), + ); + out.push(if let Some(key) = sf.key_expr.as_ref() { + Expr::RegisterClassStaticSymbol { + class_name: class_name.to_string(), + key_expr: Box::new(key.clone()), + value_expr: Box::new(init_value), + } + } else { + Expr::StaticFieldSet { + class_name: class_name.to_string(), + field_name: sf.name.clone(), + value: Box::new(init_value), + } + }); + }; + + let mut out = Vec::new(); + let mut field_idx = 0usize; + let mut block_idx = 0usize; + for member in class_body { + match member { + ast::ClassMember::ClassProp(prop) if !prop.declare && prop.is_static => { + if let Some(sf) = static_fields.get(field_idx) { + emit_field(&mut out, sf); + } + field_idx += 1; + } + ast::ClassMember::PrivateProp(prop) if prop.is_static => { + if let Some(sf) = static_fields.get(field_idx) { + emit_field(&mut out, sf); + } + field_idx += 1; + } + ast::ClassMember::StaticBlock(_) => { + let method_name = format!("__perry_static_init_{}", block_idx); + block_idx += 1; + if static_methods.iter().any(|m| m.name == method_name) { + out.push(Expr::StaticMethodCall { + class_name: class_name.to_string(), + method_name, + args: Vec::new(), + }); + } + } + _ => {} + } + } + out +} + +pub(crate) fn build_fresh_class_static_init( + class_body: &[ast::ClassMember], + class_name: &str, + static_fields: &[ClassField], + static_methods: &[Function], + class_local: LocalId, +) -> (Vec<(String, Expr)>, Vec<(Expr, Expr)>, Vec) { + let class_ref = Expr::LocalGet(class_local); + let mut initial_named = Vec::new(); + let mut initial_symbol = Vec::new(); + let mut post_init = Vec::new(); + let mut field_idx = 0usize; + let mut block_idx = 0usize; + let mut after_block = false; + + for member in class_body { + match member { + ast::ClassMember::ClassProp(prop) if !prop.declare && prop.is_static => { + if let Some(sf) = static_fields.get(field_idx) { + push_fresh_class_static_field( + sf, + after_block, + &class_ref, + &mut initial_named, + &mut initial_symbol, + &mut post_init, + ); + } + field_idx += 1; + } + ast::ClassMember::PrivateProp(prop) if prop.is_static => { + if let Some(sf) = static_fields.get(field_idx) { + push_fresh_class_static_field( + sf, + after_block, + &class_ref, + &mut initial_named, + &mut initial_symbol, + &mut post_init, + ); + } + field_idx += 1; + } + ast::ClassMember::StaticBlock(_) => { + after_block = true; + let method_name = format!("__perry_static_init_{}", block_idx); + block_idx += 1; + if static_methods.iter().any(|m| m.name == method_name) { + post_init.push(Stmt::Expr(Expr::StaticMethodCall { + class_name: class_name.to_string(), + method_name, + args: Vec::new(), + })); + } + } + _ => {} + } + } + + (initial_named, initial_symbol, post_init) +} + +fn push_fresh_class_static_field( + sf: &ClassField, + after_block: bool, + class_ref: &Expr, + initial_named: &mut Vec<(String, Expr)>, + initial_symbol: &mut Vec<(Expr, Expr)>, + post_init: &mut Vec, +) { + let Some(init) = &sf.init else { return }; + let mut init_value = init.clone(); + crate::analysis::substitute_lexical_this_in_expr(&mut init_value, class_ref); + match (after_block, sf.key_expr.as_ref()) { + (false, Some(key)) => initial_symbol.push((key.clone(), init_value)), + (false, None) => initial_named.push((sf.name.clone(), init_value)), + (true, Some(key)) => post_init.push(Stmt::Expr(Expr::IndexSet { + object: Box::new(class_ref.clone()), + index: Box::new(key.clone()), + value: Box::new(init_value), + })), + (true, None) => post_init.push(Stmt::Expr(Expr::PropertySet { + object: Box::new(class_ref.clone()), + property: sf.name.clone(), + value: Box::new(init_value), + })), + } +} diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index 2eb34a4a0..d1e77fe0b 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -159,10 +159,18 @@ pub(crate) fn lower_lit(lit: &ast::Lit) -> Result { ast::Lit::Bool(b) => Ok(Expr::Bool(b.value)), ast::Lit::Null(_) => Ok(Expr::Null), ast::Lit::BigInt(bi) => Ok(Expr::BigInt(bi.value.to_string())), - ast::Lit::Regex(re) => Ok(Expr::RegExp { - pattern: re.exp.to_string(), - flags: re.flags.to_string(), - }), + ast::Lit::Regex(re) => { + let pattern = re.exp.to_string(); + let pattern = if pattern.is_ascii() { + pattern + } else { + repair_latin1_decoded_utf8(&pattern) + }; + Ok(Expr::RegExp { + pattern, + flags: re.flags.to_string(), + }) + } _ => Err(anyhow!("Unsupported literal type")), } } @@ -174,6 +182,10 @@ fn normalize_swc_string_literal(lit: &ast::Str, decoded: &str) -> String { if raw.is_ascii() || decoded.is_ascii() { return decoded.to_string(); } + repair_latin1_decoded_utf8(decoded) +} + +fn repair_latin1_decoded_utf8(decoded: &str) -> String { let mut bytes = Vec::with_capacity(decoded.len()); for ch in decoded.chars() { let code = ch as u32; @@ -182,10 +194,7 @@ fn normalize_swc_string_literal(lit: &ast::Str, decoded: &str) -> String { } bytes.push(code as u8); } - match String::from_utf8(bytes) { - Ok(repaired) => repaired, - Err(_) => decoded.to_string(), - } + String::from_utf8(bytes).unwrap_or_else(|_| decoded.to_string()) } /// Convert an assignment target to an expression for reading its current value diff --git a/crates/perry-runtime/src/collection_iter.rs b/crates/perry-runtime/src/collection_iter.rs index d31cc8843..fa4305262 100644 --- a/crates/perry-runtime/src/collection_iter.rs +++ b/crates/perry-runtime/src/collection_iter.rs @@ -107,7 +107,13 @@ pub(crate) fn is_iterable(value: f64) -> bool { if crate::array::js_array_is_array(value).to_bits() == crate::value::TAG_TRUE { return true; } - let raw = js_nanbox_get_pointer(value); + let raw = if jsv.is_pointer() { + js_nanbox_get_pointer(value) + } else if value.to_bits() >> 48 == 0 && value.to_bits() > 0x10000 { + value.to_bits() as i64 + } else { + 0 + }; if raw == 0 { return false; } @@ -115,6 +121,9 @@ pub(crate) fn is_iterable(value: f64) -> bool { if crate::map::is_registered_map(addr) || crate::set::is_registered_set(addr) { return true; } + if crate::object::map_set_subclass::subclass_backing_for_default_iteration(value).is_some() { + return true; + } // A built-in iterator object — an array/map/set/string/buffer/iterator- // helper iterator — IS already an iterator and is therefore iterable // (`[Symbol.iterator]()` returns itself). Mirrors the equivalent diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 54a1bbac3..5dbb70136 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -307,8 +307,16 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let fields_ptr = (ptr as *const u8).add(std::mem::size_of::()) as *const f64; - // Use keys_len as the iteration count since field_count may include pre-allocated slots. - let actual_fields = std::cmp::min(num_fields, keys_len); + let alloc_limit = std::cmp::max(num_fields, 8); + let read_field_bits = |f: u32| -> u64 { + if f < alloc_limit { + (*fields_ptr.add(f as usize)).to_bits() + } else { + crate::object::js_object_get_field(obj, f).bits() + } + }; + let actual_fields = keys_len; + let key_order = crate::object::ecma_own_key_order(keys_arr); let use_pretty = !indent.is_empty(); let inner_depth = depth + 1; // A function replacer only sees own ENUMERABLE keys (EnumerableOwnProperty @@ -316,7 +324,14 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let filter_non_enum = crate::object::descriptors_in_use(); buf.push('{'); let mut first = true; - for f in 0..actual_fields { + let pos = |j: u32| -> u32 { + match &key_order { + Some(ord) => ord[j as usize], + None => j, + } + }; + for j in 0..actual_fields { + let f = pos(j); // Skip non-enumerable own keys before invoking the replacer. if filter_non_enum && f < keys_len @@ -350,7 +365,7 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( // Get the field value (invoking an own getter, as spec [[Get]] does), // resolve toJSON, then apply the replacer. - let mut field_val = *fields_ptr.add(f as usize); + let mut field_val = f64::from_bits(read_field_bits(f)); if filter_non_enum && f < keys_len { if let Some(gv) = crate::object::json_object_getter_value(obj, *keys_elements.add(f as usize)) diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 22a7b86a4..50dd860a1 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -580,6 +580,15 @@ fn jsvalue_eq(a: f64, b: f64) -> bool { return false; } + // BigInt keys compare by numeric value, not allocation identity: `1n` + // stored and `1n` looked up are distinct heap allocations. Mirrors the + // Set path (set.rs::jsvalue_eq); BigInt keys take the linear scan below. + let a_val = crate::JSValue::from_bits(a_bits); + let b_val = crate::JSValue::from_bits(b_bits); + if a_val.is_bigint() && b_val.is_bigint() { + return crate::bigint::js_bigint_eq(a_val.as_bigint_ptr(), b_val.as_bigint_ptr()) != 0; + } + if is_string_like(a_bits) && is_string_like(b_bits) { let mut a_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; let mut b_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; diff --git a/crates/perry-runtime/src/node_submodules/blob.rs b/crates/perry-runtime/src/node_submodules/blob.rs index feb8ca5e0..610410c21 100644 --- a/crates/perry-runtime/src/node_submodules/blob.rs +++ b/crates/perry-runtime/src/node_submodules/blob.rs @@ -25,7 +25,9 @@ use std::os::unix::fs::MetadataExt; #[cfg(not(unix))] use std::time::UNIX_EPOCH; -const CLASS_ID_BLOB: u32 = 0xFFFF0026; +pub(crate) const CLASS_ID_BLOB: u32 = 0xFFFF0026; +/// `File extends Blob`; shares this module as its canonical class-id home. +pub(crate) const CLASS_ID_FILE: u32 = 0xFFFF002E; #[derive(Clone)] struct FileBlobState { diff --git a/crates/perry-runtime/src/object/array_object_ops.rs b/crates/perry-runtime/src/object/array_object_ops.rs index c6304d23e..20401dbaa 100644 --- a/crates/perry-runtime/src/object/array_object_ops.rs +++ b/crates/perry-runtime/src/object/array_object_ops.rs @@ -13,6 +13,14 @@ unsafe fn is_array_object(obj: *const ObjectHeader) -> bool { (*gc_header).obj_type == crate::gc::GC_TYPE_ARRAY } +pub(crate) unsafe fn array_has_descriptors(obj: *const ObjectHeader) -> bool { + if !is_array_object(obj) { + return false; + } + let gc_header = (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + (*gc_header)._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 +} + /// Apply `Object.freeze` / `Object.seal` to an array's OWN index + named data /// properties. The generic `mark_all_keys` walks `(*obj).keys_array`, but an /// array's indices live in the dense element store and its named props in the diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 9b49becd1..c75c2a42a 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -58,7 +58,8 @@ pub(crate) use state::{ class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, class_prototype_object_root_store, global_object_prototype_bits, is_bound_native_method_closure_value, is_non_constructable_builtin_function_value, - parent_closure_in_chain, throw_non_constructable_builtin_function, + parent_closure_in_chain, refresh_class_decl_prototype_parent, + throw_non_constructable_builtin_function, }; pub use state::{ ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 7f193e781..62426db2f 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -846,6 +846,7 @@ pub unsafe extern "C" fn js_new_function_construct( let class_cid = js_object_get_class_id(obj); if class_cid != 0 { let inst = js_object_alloc(class_cid, 0); + let dynamic_parent = class_object_parent_value(obj, class_cid); // Replay the class's registered constructor (instance-field // initializers + body) on the fresh instance, filling the // capture params from the snapshotted `__perry_ctor_caps`. The @@ -854,6 +855,35 @@ pub unsafe extern "C" fn js_new_function_construct( super::super::class_constructors::replay_class_object_constructor( func_value, class_cid, inst, args_ptr, args_len, ); + let proto = + ensure_class_object_prototype(obj as *mut ObjectHeader, dynamic_parent, func_value); + if !proto.is_null() { + super::super::prototype_chain::object_set_static_prototype( + inst as usize, + crate::value::js_nanbox_pointer(proto as i64).to_bits(), + ); + } + let dynamic_parent_is_error = matches!( + identify_global_builtin_constructor(dynamic_parent), + Some( + "Error" + | "TypeError" + | "RangeError" + | "ReferenceError" + | "SyntaxError" + | "EvalError" + | "URIError" + ) + ); + if crate::object::class_meta_registry::extends_builtin_error(class_cid) + || dynamic_parent_is_error + { + let key = crate::string::js_string_from_bytes( + b"constructor".as_ptr(), + b"constructor".len() as u32, + ); + super::super::js_object_set_field_by_name_nonenum(inst, key, func_value); + } // `class X extends Request/Response {}` constructed via the dynamic // (class-expression value) path: the replayed ctor's `super()` // can't statically route an aliased parent, so attach the native @@ -904,35 +934,38 @@ pub unsafe extern "C" fn js_new_function_construct( if fp != 0 && crate::closure::is_closure_ptr(fp) { let dyn_proto = crate::closure::closure_get_dynamic_prop(fp, "prototype"); let dp = JSValue::from_bits(dyn_proto.to_bits()); - if dp.is_pointer() { - let raw = dp.as_pointer::() as usize; - // Function objects (closures) are identified by CLOSURE_MAGIC, - // not a GC type tag — check them first. - let is_fn = crate::closure::is_closure_ptr(raw); - let has_user_proto = is_fn - || (raw >= crate::gc::GC_HEADER_SIZE + 0x1000 && { - let hdr = unsafe { - &*((raw - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) - }; - // Arrays: test262 filter/15.4.4.20-6-*, some/15.4.4.17-8-*. - // Objects: Intl/Temporal constructors install .prototype via - // closure_set_dynamic_prop (bypassing js_set_function_prototype), - // so CLASS_PROTOTYPE_OBJECTS has no entry; ensure_function_prototype_object - // would otherwise create a fresh empty proto and overwrite .prototype. - matches!( - hdr.obj_type, - crate::gc::GC_TYPE_ARRAY - | crate::gc::GC_TYPE_LAZY_ARRAY - | crate::gc::GC_TYPE_OBJECT - ) - }); - if has_user_proto { - super::super::prototype_chain::object_set_static_prototype( - obj_ptr as usize, - dyn_proto.to_bits(), - ); - linked_user_proto = true; - } + let has_user_proto = super::super::class_ref_id(dyn_proto).is_some() + || if dp.is_pointer() { + let raw = dp.as_pointer::() as usize; + // Function objects (closures) are identified by CLOSURE_MAGIC, + // not a GC type tag — check them first. + let is_fn = crate::closure::is_closure_ptr(raw); + is_fn + || (raw >= crate::gc::GC_HEADER_SIZE + 0x1000 && { + let hdr = unsafe { + &*((raw - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader) + }; + // Arrays: test262 filter/15.4.4.20-6-*, some/15.4.4.17-8-*. + // Objects: Intl/Temporal constructors install .prototype via + // closure_set_dynamic_prop (bypassing js_set_function_prototype), + // so CLASS_PROTOTYPE_OBJECTS has no entry; ensure_function_prototype_object + // would otherwise create a fresh empty proto and overwrite .prototype. + matches!( + hdr.obj_type, + crate::gc::GC_TYPE_ARRAY + | crate::gc::GC_TYPE_LAZY_ARRAY + | crate::gc::GC_TYPE_OBJECT + ) + }) + } else { + false + }; + if has_user_proto { + super::super::prototype_chain::object_set_static_prototype( + obj_ptr as usize, + dyn_proto.to_bits(), + ); + linked_user_proto = true; } } } @@ -993,6 +1026,99 @@ pub unsafe extern "C" fn js_new_function_construct( nan_boxed } +unsafe fn ensure_class_object_prototype( + class_obj: *mut ObjectHeader, + parent_value: f64, + constructor_value: f64, +) -> *mut ObjectHeader { + let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), b"prototype".len() as u32); + let existing = js_object_get_field_by_name_f64(class_obj as *const ObjectHeader, key); + if super::super::value_is_object_like(existing) { + return crate::value::JSValue::from_bits(existing.to_bits()).as_pointer::() + as *mut ObjectHeader; + } + + let proto = js_object_alloc(0, 0); + if proto.is_null() { + return proto; + } + + let constructor_key = + crate::string::js_string_from_bytes(b"constructor".as_ptr(), b"constructor".len() as u32); + js_object_set_field_by_name(proto, constructor_key, constructor_value); + set_builtin_property_attrs( + proto as usize, + "constructor".to_string(), + PropertyAttrs::new(true, false, true), + ); + + if let Some(parent_proto_bits) = class_constructor_prototype_bits(parent_value) { + super::super::prototype_chain::object_set_static_prototype( + proto as usize, + parent_proto_bits, + ); + } else if let Some(object_proto_bits) = global_object_prototype_bits() { + super::super::prototype_chain::object_set_static_prototype( + proto as usize, + object_proto_bits, + ); + } + + js_object_set_field_by_name( + class_obj, + key, + crate::value::js_nanbox_pointer(proto as i64), + ); + set_builtin_property_attrs( + class_obj as usize, + "prototype".to_string(), + PropertyAttrs::new(false, false, false), + ); + proto +} + +unsafe fn class_object_parent_value(class_obj: *const ObjectHeader, class_id: u32) -> f64 { + let key = crate::string::js_string_from_bytes( + b"__perry_parent_value".as_ptr(), + b"__perry_parent_value".len() as u32, + ); + let parent = js_object_get_field_by_name_f64(class_obj, key); + if parent.to_bits() != crate::value::TAG_UNDEFINED { + parent + } else { + js_get_dynamic_parent_value(class_id) + } +} + +unsafe fn class_constructor_prototype_bits(constructor_value: f64) -> Option { + if let Some(parent_cid) = super::super::class_ref_id(constructor_value) { + if let Some(proto) = class_decl_prototype_value_for_instance_class(parent_cid) { + return Some(proto.to_bits()); + } + let proto = class_prototype_object(parent_cid); + if !proto.is_null() { + return Some(crate::value::js_nanbox_pointer(proto as i64).to_bits()); + } + return None; + } + + let value = crate::value::JSValue::from_bits(constructor_value.to_bits()); + if !value.is_pointer() { + return None; + } + let raw = value.as_pointer::(); + if raw.is_null() { + return None; + } + let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), b"prototype".len() as u32); + let proto = js_object_get_field_by_name_f64(raw, key); + if super::super::value_is_object_like(proto) { + Some(proto.to_bits()) + } else { + None + } +} + /// `new (...spread)` — spread-bearing construction. Codegen builds a /// single JS array containing every argument in evaluation order (regular args /// pushed, spread sources expanded via `js_array_like_to_array` + concat), then diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index e9916995e..df03e4007 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -8,11 +8,22 @@ use std::sync::RwLock; /// Register a class with its parent class ID in the global registry pub(crate) fn register_class(class_id: u32, parent_class_id: u32) { + const CLASS_ID_OBJECT: u32 = 0xFFFF0050; let mut registry = CLASS_REGISTRY.write().unwrap(); if registry.is_none() { *registry = Some(HashMap::new()); } - registry.as_mut().unwrap().insert(class_id, parent_class_id); + let map = registry.as_mut().unwrap(); + if parent_class_id == CLASS_ID_OBJECT + && map + .get(&class_id) + .is_some_and(|existing| *existing != 0 && *existing != CLASS_ID_OBJECT) + { + return; + } + map.insert(class_id, parent_class_id); + drop(registry); + refresh_class_decl_prototype_parent(class_id); } /// Public registration entry point used by codegen module init. diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index ab66c41da..ea3c2e995 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -100,7 +100,10 @@ pub(crate) fn class_own_enumerable_field_names(class_id: u32) -> Vec { .map(|props| { props .keys() - .filter(|k| !k.starts_with('#')) + .filter(|k| { + !k.starts_with('#') + && !matches!(k.as_str(), "length" | "name" | "prototype") + }) .cloned() .collect() }) @@ -424,6 +427,35 @@ fn install_class_decl_prototype_method_fields(proto: *mut ObjectHeader, class_id } } +fn builtin_prototype_value_for_class_id(class_id: u32) -> Option { + let name = match class_id { + crate::error::CLASS_ID_ERROR => "Error", + crate::error::CLASS_ID_TYPE_ERROR => "TypeError", + crate::error::CLASS_ID_RANGE_ERROR => "RangeError", + crate::error::CLASS_ID_REFERENCE_ERROR => "ReferenceError", + crate::error::CLASS_ID_SYNTAX_ERROR => "SyntaxError", + crate::error::CLASS_ID_EVAL_ERROR => "EvalError", + crate::error::CLASS_ID_URI_ERROR => "URIError", + crate::error::CLASS_ID_AGGREGATE_ERROR => "AggregateError", + 0xFFFF0020 => "Date", + 0xFFFF0021 => "RegExp", + 0xFFFF0022 => "Map", + 0xFFFF0023 => "Set", + 0xFFFF0024 => "Array", + 0xFFFF0025 => "ArrayBuffer", + 0xFFFF0027 => "Promise", + 0xFFFF0050 => "Object", + 0xFFFF00D0 => "Number", + 0xFFFF00D1 => "String", + 0xFFFF00D2 => "Boolean", + 0xFFFF00D3 => "BigInt", + 0xFFFF00D4 => "Symbol", + _ => return None, + }; + let proto = crate::object::builtin_prototype_value(name); + ((proto.to_bits() >> 48) == 0x7FFD).then_some(proto) +} + pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { if class_id == 0 || class_name_for_id(class_id).is_none() { return f64::from_bits(crate::value::TAG_UNDEFINED); @@ -475,14 +507,7 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { unsafe { mirror_prototype_method_on_object(proto, &name, value_bits, enumerable) }; } - let parent_proto_bits = get_parent_class_id(class_id) - .filter(|parent_id| *parent_id != 0 && *parent_id != class_id) - .and_then(|parent_id| { - let parent_proto = class_decl_prototype_value(parent_id); - let parent_bits = parent_proto.to_bits(); - ((parent_bits >> 48) == 0x7FFD).then_some(parent_bits) - }) - .or_else(global_object_prototype_bits); + let parent_proto_bits = resolve_class_parent_prototype_bits(class_id); if let Some(bits) = parent_proto_bits { super::super::prototype_chain::object_set_static_prototype(proto as usize, bits); } @@ -490,6 +515,33 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { crate::value::js_nanbox_pointer(proto as i64) } +pub(crate) fn refresh_class_decl_prototype_parent(class_id: u32) { + let proto = class_decl_prototype_object(class_id); + if proto.is_null() { + return; + } + let parent_proto_bits = resolve_class_parent_prototype_bits(class_id); + if let Some(bits) = parent_proto_bits { + super::super::prototype_chain::object_set_static_prototype(proto as usize, bits); + } +} + +fn resolve_class_parent_prototype_bits(class_id: u32) -> Option { + dynamic_parent_prototype_bits(class_id).or_else(|| { + get_parent_class_id(class_id) + .filter(|parent_id| *parent_id != 0 && *parent_id != class_id) + .and_then(|parent_id| { + builtin_prototype_value_for_class_id(parent_id) + .or_else(|| { + let parent_proto = class_decl_prototype_value(parent_id); + ((parent_proto.to_bits() >> 48) == 0x7FFD).then_some(parent_proto) + }) + .map(f64::to_bits) + }) + .or_else(global_object_prototype_bits) + }) +} + pub(crate) fn class_decl_prototype_value_for_instance_class(class_id: u32) -> Option { if class_id == 0 || class_name_for_id(class_id).is_none() { return None; @@ -498,6 +550,33 @@ pub(crate) fn class_decl_prototype_value_for_instance_class(class_id: u32) -> Op ((proto.to_bits() >> 48) == 0x7FFD).then_some(proto) } +fn dynamic_parent_prototype_bits(class_id: u32) -> Option { + let parent = js_get_dynamic_parent_value(class_id); + if let Some(parent_cid) = class_ref_id(parent) { + if parent_cid == 0 || parent_cid == class_id { + return None; + } + let parent_proto = class_decl_prototype_value(parent_cid); + return ((parent_proto.to_bits() >> 48) == 0x7FFD).then_some(parent_proto.to_bits()); + } + + let value = JSValue::from_bits(parent.to_bits()); + if !value.is_pointer() { + return None; + } + let ptr = value.as_pointer::(); + if ptr.is_null() { + return None; + } + let key = crate::string::js_string_from_bytes(b"prototype".as_ptr(), b"prototype".len() as u32); + let proto = js_object_get_field_by_name_f64(ptr, key); + if unsafe { value_is_object_like(proto) } { + Some(proto.to_bits()) + } else { + None + } +} + pub(crate) fn global_object_prototype_bits() -> Option { let object_ctor = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); let ctor_bits = object_ctor.to_bits(); diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index f30a88fa8..2834f143b 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -342,6 +342,18 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu && super::class_registry::lookup_static_method_in_chain(class_id, "name") .is_none() { + if let Some(v) = + super::class_registry::class_own_static_field_value(class_id, &method_name) + { + let attrs = super::get_property_attrs(class_id as usize, &method_name) + .unwrap_or_else(|| super::PropertyAttrs::new(false, false, true)); + return build_data_descriptor( + v, + attrs.writable(), + attrs.enumerable(), + attrs.configurable(), + ); + } if let Some(class_name) = super::class_registry::class_name_for_id(class_id) { let s = crate::string::js_string_from_bytes( class_name.as_ptr(), @@ -431,7 +443,14 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if let Some(v) = super::class_registry::class_own_static_field_value(class_id, &method_name) { - return build_data_descriptor(v, true, true, true); + let attrs = super::get_property_attrs(class_id as usize, &method_name) + .unwrap_or_else(|| super::PropertyAttrs::new(true, true, true)); + return build_data_descriptor( + v, + attrs.writable(), + attrs.enumerable(), + attrs.configurable(), + ); } } } @@ -608,6 +627,11 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if let Some(desc) = super::arguments_object_descriptor(obj, key_str) { return desc; } + if let Some(ref name) = key_rust { + if super::is_inherited_object_proto_shim_on_builtin_prototype(obj, name) { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + } if (obj as usize) >= crate::gc::GC_HEADER_SIZE + 0x1000 { let gc_header = (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; @@ -1140,6 +1164,12 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { crate::array::js_array_push(result, JSValue::string_ptr(lk)); let named = crate::array::array_named_property_names(ap, false); for name in &named { + if super::is_inherited_object_proto_shim_on_builtin_prototype( + ap as *const ObjectHeader, + name, + ) { + continue; + } let k = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); crate::array::js_array_push(result, JSValue::string_ptr(k)); @@ -1150,6 +1180,10 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { for name in super::accessor_descriptor_keys_for_obj(ap as usize) { if super::canonical_array_index(&name).is_some() || named.contains(&name) + || super::is_inherited_object_proto_shim_on_builtin_prototype( + ap as *const ObjectHeader, + &name, + ) { continue; } @@ -1266,6 +1300,13 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { let mut sso_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; for i in 0..len { let key_val = crate::array::js_array_get(keys, pos(i)); + if let Some(b) = crate::string::js_string_key_bytes(key_val, &mut sso_buf) { + if let Ok(name) = std::str::from_utf8(b) { + if super::is_inherited_object_proto_shim_on_builtin_prototype(obj, name) { + continue; + } + } + } if hide_private { if let Some(b) = crate::string::js_string_key_bytes(key_val, &mut sso_buf) { if b.first() == Some(&b'#') diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 0dcc8a5fa..1d4a9fd9e 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -27,6 +27,34 @@ pub extern "C" fn js_object_get_field_by_name( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> JSValue { + if !key.is_null() { + let obj_bits = obj as u64; + let tagged = f64::from_bits(obj_bits); + let tagged_value = crate::value::JSValue::from_bits(obj_bits); + let string_ptr = if tagged_value.is_any_string() { + crate::value::js_get_string_pointer_unified(tagged) as *const crate::StringHeader + } else if obj_bits >= crate::gc::GC_HEADER_SIZE as u64 + && crate::value::addr_class::is_valid_obj_ptr(obj as *const u8) + { + let gc_hdr = unsafe { + (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader + }; + if unsafe { (*gc_hdr).obj_type } == crate::gc::GC_TYPE_STRING { + obj as *const crate::StringHeader + } else { + std::ptr::null() + } + } else { + std::ptr::null() + }; + if crate::string::is_valid_string_ptr(string_ptr) { + let key_value = crate::value::js_nanbox_string(key as i64); + let indexed = crate::string::js_string_index_get(string_ptr, key_value); + if indexed.to_bits() != crate::value::TAG_UNDEFINED { + return JSValue::from_bits(indexed.to_bits()); + } + } + } // #2846: the receiver may be a Proxy value that arrived through a generic // property read (e.g. `rec.proxy.a` where `rec = Proxy.revocable(...)`). // Proxies are encoded as small fake pointers; deref-ing one as an @@ -873,31 +901,14 @@ pub extern "C" fn js_object_get_field_by_name( } } } - // SSO property access (v0.5.213 Step 1 gate). The codegen inline - // `.length` path routes SHORT_STRING_TAG receivers here because - // it doesn't yet know about the SSO tag. Handle `.length` by - // reading the length byte directly from the NaN-box payload. - // Other property accesses on an SSO string (e.g. `.charAt` via - // `[0]`, `.slice`) aren't yet routed here — handled by the - // string method dispatch in a future migration step; today they - // fall through to "undefined" which matches the behavior for - // string-valued property access on untyped locals in general. + // SSO property access for keys not handled by the unified string fast path + // above. `.length` and canonical indices are centralized there; remaining + // unknown properties on untyped string locals resolve to undefined here + // rather than falling through to the object-deref path with an inline-string + // payload. { let obj_bits = obj as u64; if (obj_bits & crate::value::TAG_MASK) == crate::value::SHORT_STRING_TAG { - if !key.is_null() { - unsafe { - let key_ptr = - (key as *const u8).add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); - if key_bytes == b"length" { - let len = (obj_bits & crate::value::SHORT_STRING_LEN_MASK) - >> crate::value::SHORT_STRING_LEN_SHIFT; - return JSValue::number(len as f64); - } - } - } return JSValue::undefined(); } } diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 644b0cc69..bc7e9c10e 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1351,6 +1351,17 @@ pub(crate) fn get_field_by_name_object_tail( let v = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); return JSValue::from_bits(v.to_bits()); } + if class_id != 0 { + let receiver = + f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); + if let Some(v) = + super::super::class_registry::resolve_proto_chain_field_with_receiver( + class_id, key, receiver, + ) + { + return v; + } + } if let Some(func_value) = super::super::class_registry::function_value_for_class_id(class_id) { @@ -1360,6 +1371,10 @@ pub(crate) fn get_field_by_name_object_tail( let bits = 0x7FFE_0000_0000_0000u64 | (class_id as u64); return JSValue::from_bits(bits); } + if crate::url::url_class::is_url_object_shape(obj as *mut crate::ObjectHeader) { + let v = js_get_global_this_builtin_value(b"URL".as_ptr(), 3); + return JSValue::from_bits(v.to_bits()); + } // class_id == 0 fallback: plain ObjectHeader allocated // without an HIR shape (Object.create(null) hybrids, raw // empty `{}` produced by JSON.parse, etc.). Report diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 49eea6feb..7222b1d30 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -11,6 +11,27 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { let nanbox_false = f64::from_bits(0x7FFC_0000_0000_0003u64); // TAG_FALSE let nanbox_true = f64::from_bits(0x7FFC_0000_0000_0004u64); // TAG_TRUE + // The [[Prototype]] walk at the tail recurses through this entry point (a + // loop bounded at 1024 in earlier PRs became a recursion). `setPrototypeOf` + // rejects cycles, but a pathologically deep chain — or a cycle that slips + // past cycle-detection (see #b201538f3) — would otherwise overflow the + // stack. Bound total recursion depth to the same 1024 the manual walk below + // already caps at, so this introduces no new false-negative under that limit. + thread_local! { + static HP_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + struct DepthGuard; + impl Drop for DepthGuard { + fn drop(&mut self) { + HP_DEPTH.with(|d| d.set(d.get() - 1)); + } + } + if HP_DEPTH.with(|d| d.get()) > 1024 { + return nanbox_false; + } + HP_DEPTH.with(|d| d.set(d.get() + 1)); + let _depth_guard = DepthGuard; + let obj_val = JSValue::from_bits(obj.to_bits()); let key_val = JSValue::from_bits(key.to_bits()); @@ -41,6 +62,22 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { if addr >= crate::value::addr_class::COMMON_HANDLE_BAND_END && crate::value::addr_class::is_handle_band(addr) { + if key_val.is_any_string() { + unsafe { + if let Some(dispatch) = super::super::class_registry::handle_property_dispatch() + { + let key_ptr = crate::value::js_get_string_pointer_unified(key) + as *const crate::StringHeader; + let name_ptr = + (key_ptr as *const u8).add(std::mem::size_of::()); + let name_len = (*key_ptr).byte_len as usize; + let result = dispatch(addr as i64, name_ptr, name_len); + if result.to_bits() != crate::value::TAG_UNDEFINED { + return nanbox_true; + } + } + } + } return nanbox_false; } } @@ -585,6 +622,19 @@ unsafe fn ordinary_has_property( } } } + let receiver = f64::from_bits(crate::value::js_nanbox_pointer(obj_ptr as i64).to_bits()); + let proto = super::super::js_object_get_prototype_of(receiver); + let proto_bits = proto.to_bits(); + if proto_bits != crate::value::TAG_NULL + && proto_bits != crate::value::TAG_UNDEFINED + && proto_bits != receiver.to_bits() + { + let key_value = crate::value::js_nanbox_string(key as i64); + if crate::value::js_is_truthy(super::super::js_object_has_property(proto, key_value)) != 0 { + return true; + } + } + // Inherited `Object.prototype` properties (`toString`, `hasOwnProperty`, …, // plus any user-assigned `Object.prototype` members). ordinary_object_prototype_property_value(last_valid, key).is_some() diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 86a71741a..0a9092505 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -121,6 +121,7 @@ pub(crate) use math_temporal::{install_math_namespace, temporal_ctor_kind}; pub(crate) use populate::{ default_prepare_stack_trace_func_ptr, populate_global_this_builtins, ERROR_CONSTRUCTOR_PTR, }; +pub(crate) use proto_methods::is_inherited_object_proto_shim_on_builtin_prototype; pub(crate) use proto_methods::{ install_error_prototype_data_properties, populate_builtin_prototype_methods, }; diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 3749eac34..9b68dc836 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -388,11 +388,11 @@ pub(crate) extern "C" fn global_this_error_is_error_thunk( /// AOT-compiled in HIR; only dynamic ones reach here. Perry has no JS /// interpreter, but it CAN recognize the fixed templates a few popular codegen /// libraries emit and return a real native function. Currently: `depd`'s -/// deprecation wrapper (used eagerly by `send` → Next.js). depd's wrapper just -/// logs a deprecation then forwards to the wrapped fn, so the "wrapper" can -/// simply BE that fn — `new Function(...)(fn,log,deprecate,msg,site)` returns -/// `fn`. Unrecognized templates fall back to a non-callable placeholder object -/// (prior behavior); there is no general eval. +/// deprecation wrapper. depd's wrapper just logs a deprecation then forwards to +/// the wrapped fn, so the "wrapper" can simply BE that fn — +/// `new Function(...)(fn,log,deprecate,msg,site)` returns `fn`. Unrecognized +/// templates throw so feature-detecting libraries can fall back; there is no +/// general eval. #[no_mangle] pub extern "C" fn js_function_ctor_from_strings(args_ptr: *const f64, args_len: usize) -> f64 { let arg_str = |i: usize| -> String { @@ -437,24 +437,9 @@ pub extern "C" fn js_function_ctor_from_strings(args_ptr: *const f64, args_len: } } // Unrecognized dynamic-Function shape. Perry is ahead-of-time compiled and - // cannot compile a code string built from runtime data. THROW (rather than - // return a placeholder object) — this is the honest signal, and it lets - // feature-detecting libraries take their non-`Function` fallback. zod 4's - // JIT does `try { new Function(""), true } catch { false }` and switches to - // its interpreter when this throws, clearing the entire zod-object-validation - // wall with no native validator needed. (A placeholder made the feature-test - // *succeed*, forcing the JIT path that then crashes.) The eprintln names the - // offending library for diagnostics; it fires once per distinct feature-test. - let body = if args_len > 0 { - arg_str(args_len - 1) - } else { - String::new() - }; - let preview: String = body.chars().take(160).collect(); - eprintln!( - "[perry] dynamic Function refused (AOT) — {} arg(s); body[..160]={:?}", - args_len, preview - ); + // cannot compile a code string built from runtime data. Throwing lets + // feature-detecting libraries take their non-`Function` fallback without + // polluting stderr for handled probes. super::super::object_ops::throw_object_type_error( b"Function: dynamic code generation from a runtime string is not supported \ in an ahead-of-time compiled binary", diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index d4bd87b18..a80030445 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -25,6 +25,35 @@ const OBJECT_PROTO_METHODS: &[(&str, u32)] = &[ // dedicated thunks; do not include it here to avoid clobbering those. ]; +const INHERITED_OBJECT_PROTO_SHIMS: &[&str] = &[ + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", +]; + +pub(crate) fn is_inherited_object_proto_shim_on_builtin_prototype( + proto_obj: *const ObjectHeader, + key: &str, +) -> bool { + if proto_obj.is_null() || !INHERITED_OBJECT_PROTO_SHIMS.contains(&key) { + return false; + } + let proto_bits = crate::value::js_nanbox_pointer(proto_obj as i64).to_bits(); + for builtin in GLOBAL_THIS_BUILTIN_CONSTRUCTORS.iter().copied() { + if builtin == "Object" { + continue; + } + if builtin_prototype_value(builtin).to_bits() == proto_bits { + return true; + } + } + false +} + /// Populate well-known method properties on a built-in constructor's /// prototype object. Each registered method is a closure carrying a /// proper `name` property so feature-detection idioms like @@ -676,6 +705,7 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ("text", 0), ], ); + set_intrinsic_to_string_tag(proto_obj, builtin_name); install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); } "FormData" => { diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 8f400e23a..03ce545f6 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -33,6 +33,10 @@ pub(crate) fn value_is_callable(value: f64) -> bool { } let jv = crate::JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { + let bits = value.to_bits(); + if bits > 0x10000 && bits <= crate::value::POINTER_MASK { + return crate::closure::is_closure_ptr(bits as usize); + } return false; } crate::closure::is_closure_ptr((jv.bits() & crate::value::POINTER_MASK) as usize) @@ -71,6 +75,87 @@ fn value_addr(value: f64) -> usize { } } +fn prototype_identity_key(value: f64) -> Option { + let bits = value.to_bits(); + if let Some(class_id) = super::class_prototype_ref_id(value) { + return Some(0xFFFF_0000_0000_0000 | class_id as u64); + } + match bits >> 48 { + 0x7FFD => { + let ptr = (bits & crate::value::POINTER_MASK) as usize; + super::class_registry::class_id_for_decl_prototype_object(ptr) + .map(|class_id| 0xFFFF_0000_0000_0000 | class_id as u64) + .or(Some(ptr as u64)) + } + 0x7FFE if super::class_ref_id(value).is_some() => Some(bits), + 0 if bits > 0x10000 => { + super::class_registry::class_id_for_decl_prototype_object(bits as usize) + .map(|class_id| 0xFFFF_0000_0000_0000 | class_id as u64) + .or(Some(bits)) + } + _ => None, + } +} + +fn prototype_identity_chain_contains(value: f64, target_proto: f64) -> bool { + if !unsafe { crate::object::value_is_object_like(value) } { + return false; + } + if !unsafe { crate::object::value_is_object_like(target_proto) } + && super::class_ref_id(target_proto).is_none() + && super::class_prototype_ref_id(target_proto).is_none() + { + return false; + } + + let Some(target_key) = prototype_identity_key(target_proto) else { + return false; + }; + let mut current = value; + for _ in 0..128 { + current = super::object_ops::js_object_get_prototype_of(current); + let bits = current.to_bits(); + if bits == crate::value::TAG_NULL || bits == crate::value::TAG_UNDEFINED { + return false; + } + if prototype_identity_key(current) == Some(target_key) { + return true; + } + } + false +} + +fn ordinary_function_has_instance(value: f64, type_ref: f64) -> Option { + if !value_is_callable(type_ref) { + return None; + } + let bits = type_ref.to_bits(); + let function_value = if (bits >> 48) == 0x7FFD { + type_ref + } else if bits > 0x10000 && bits <= crate::value::POINTER_MASK { + if !crate::closure::is_closure_ptr(bits as usize) { + return None; + } + crate::value::js_nanbox_pointer(bits as i64) + } else { + return None; + }; + let proto = super::class_registry::js_function_prototype_value_for_read(function_value); + if unsafe { crate::object::value_is_object_like(proto) } + || super::class_ref_id(proto).is_some() + || super::class_prototype_ref_id(proto).is_some() + { + return Some(f64::from_bits( + if prototype_identity_chain_contains(value, proto) { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }, + )); + } + None +} + fn is_native_module_namespace_value(value: f64, expected: &str) -> bool { let jv = crate::JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { @@ -353,6 +438,9 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { f64::from_bits(TAG_FALSE) }; } + if let Some(result) = ordinary_function_has_instance(value, type_ref) { + return result; + } // `inst instanceof Intl.`: Intl instances are plain heap objects whose // `[[Prototype]]` is `Intl..prototype` but carry no class-id, so the // arms above can't match them. Walk their static-prototype chain. @@ -410,6 +498,9 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "URIError" => crate::error::CLASS_ID_URI_ERROR, "AggregateError" => crate::error::CLASS_ID_AGGREGATE_ERROR, "Promise" => CLASS_ID_PROMISE, + "Blob" => crate::node_submodules::blob::CLASS_ID_BLOB, + "File" => crate::node_submodules::blob::CLASS_ID_FILE, + "URL" => crate::url::CLASS_ID_URL, "Navigator" => crate::navigator::NAVIGATOR_CLASS_ID, "TextEncoderStream" => crate::object::CLASS_ID_TEXT_ENCODER_STREAM, "TextDecoderStream" => crate::object::CLASS_ID_TEXT_DECODER_STREAM, @@ -954,7 +1045,7 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { return false_val; } - // WHATWG fetch: `instanceof Response` / `Request` / `Headers` / `Blob`. + // WHATWG fetch: `instanceof Response` / `Request` / `Headers` / `Blob` / `File`. // These are pointer-tagged small-integer handles (stdlib fetch registries), // not heap objects, so consult the stdlib fetch kind-probe rather than the // class chain. Without this, Hono's `res instanceof Response` route-fallback @@ -962,21 +1053,25 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { const CLASS_ID_RESPONSE: u32 = 0xFFFF0028; const CLASS_ID_REQUEST: u32 = 0xFFFF0029; const CLASS_ID_HEADERS: u32 = 0xFFFF002A; - const CLASS_ID_BLOB: u32 = 0xFFFF0026; + const CLASS_ID_BLOB: u32 = crate::node_submodules::blob::CLASS_ID_BLOB; + const CLASS_ID_FILE: u32 = crate::node_submodules::blob::CLASS_ID_FILE; if class_id == CLASS_ID_RESPONSE || class_id == CLASS_ID_REQUEST || class_id == CLASS_ID_HEADERS || class_id == CLASS_ID_BLOB + || class_id == CLASS_ID_FILE { let want = match class_id { CLASS_ID_RESPONSE => 1u8, CLASS_ID_REQUEST => 2, CLASS_ID_HEADERS => 3, - _ => 4, // CLASS_ID_BLOB + CLASS_ID_BLOB => 4, + _ => 5, // CLASS_ID_FILE }; if let Some(handle) = small_native_handle_id(value) { if let Some(probe) = crate::object::fetch_handle_kind_probe() { - if unsafe { probe(handle as usize) } == want { + let kind = unsafe { probe(handle as usize) }; + if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { return true_val; } } @@ -989,7 +1084,8 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { let raw = jsval.as_pointer::() as usize; if let Some(id) = unsafe { crate::object::fetch_subclass_handle_id(raw) } { if let Some(probe) = crate::object::fetch_handle_kind_probe() { - if unsafe { probe(id as usize) } == want { + let kind = unsafe { probe(id as usize) }; + if kind == want || (class_id == CLASS_ID_BLOB && kind == 5) { return true_val; } } @@ -1089,6 +1185,16 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { false_val }; } + const CLASS_ID_URL: u32 = crate::url::CLASS_ID_URL; + if class_id == CLASS_ID_URL { + return if crate::url::object_from_f64(value) + .is_some_and(crate::url::url_class::is_url_object_shape) + { + true_val + } else { + false_val + }; + } // `Object` — ECMAScript spec: `x instanceof Object` is true for any // non-primitive (every object/array/function/Map/Set/Buffer/RegExp/ @@ -1276,6 +1382,10 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { } } + if gc_type != crate::gc::GC_TYPE_OBJECT { + return false_val; + } + // For user-defined classes that extend Error: `myErr instanceof Error` should be true. if class_id == crate::error::CLASS_ID_ERROR { let obj_class_id = (*obj_ptr).class_id; @@ -1310,3 +1420,27 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { false_val } } + +#[cfg(test)] +mod tests { + use super::*; + + extern "C" fn test_ctor(_closure: *const crate::closure::ClosureHeader) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) + } + + #[test] + fn ordinary_function_has_instance_reads_raw_closure_prototype_safely() { + let closure = crate::closure::js_closure_alloc(test_ctor as *const u8, 0); + let function_value = crate::value::js_nanbox_pointer(closure as i64); + let prototype = super::class_registry::js_function_prototype_value_for_read(function_value); + + let instance = js_object_alloc(0, 0); + let instance_value = crate::value::js_nanbox_pointer(instance as i64); + js_object_set_prototype_of(instance_value, prototype); + + let raw_function_value = f64::from_bits(closure as u64); + let result = ordinary_function_has_instance(instance_value, raw_function_value); + assert_eq!(result.map(f64::to_bits), Some(crate::value::TAG_TRUE)); + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 037fee789..09933f299 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -171,7 +171,9 @@ pub(crate) use this_binding::{ IMPLICIT_THIS, NEW_TARGET, }; pub use to_string_tag::js_object_to_string; -pub(crate) use to_string_tag::{typed_array_to_string_tag_name, web_stream_to_string_tag}; +pub(crate) use to_string_tag::{ + fetch_handle_to_string_tag, typed_array_to_string_tag_name, web_stream_to_string_tag, +}; static HTTP_METHODS_CACHE: AtomicU64 = AtomicU64::new(0); static FS_CONSTANTS_CACHE: AtomicU64 = AtomicU64::new(0); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index ecf3f06da..13f09dd31 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -30,6 +30,27 @@ pub(crate) use proto_dispatch::{ }; pub(super) use typed_array::dispatch_typed_array_method; +fn interned_default_method_key(method_name: &str) -> *mut crate::string::StringHeader { + use std::sync::OnceLock; + + static TO_STRING: OnceLock = OnceLock::new(); + static TO_LOCALE_STRING: OnceLock = OnceLock::new(); + static VALUE_OF: OnceLock = OnceLock::new(); + + let slot = match method_name { + "toString" => &TO_STRING, + "toLocaleString" => &TO_LOCALE_STRING, + "valueOf" => &VALUE_OF, + _ => return std::ptr::null_mut(), + }; + *slot.get_or_init(|| { + crate::string::js_string_from_bytes_longlived( + method_name.as_ptr(), + method_name.len() as u32, + ) as usize + }) as *mut crate::string::StringHeader +} + unsafe fn call_primitive_closure_value( receiver: f64, value: JSValue, @@ -1286,6 +1307,27 @@ pub unsafe extern "C" fn js_native_call_method( } } } + + if matches!(method_name, "toString" | "toLocaleString" | "valueOf") { + let method_key = interned_default_method_key(method_name); + if !method_key.is_null() { + let method = super::js_object_get_field_by_name(obj, method_key); + if !method.is_undefined() && !method.is_null() { + let bound = crate::closure::clone_closure_rebind_this( + method.bits(), + f64::from_bits(jsval.bits()), + ); + let prev_this = IMPLICIT_THIS.with(|c| c.replace(jsval.bits())); + let result = crate::closure::js_native_call_value( + f64::from_bits(bound), + args_ptr, + args_len, + ); + IMPLICIT_THIS.with(|c| c.set(prev_this)); + return result; + } + } + } } // Issue #510: throw `TypeError: is not a function` when diff --git a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs index 4fc76c8eb..1fdce6766 100644 --- a/crates/perry-runtime/src/object/native_call_method/collection_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/collection_methods.rs @@ -49,6 +49,15 @@ fn is_backed_collection_method( } } +unsafe fn dispatch_inherited_object_method(object: f64, method_name: &str) -> Option { + match method_name { + "toString" => Some(crate::object::js_object_to_string(object)), + "toLocaleString" => Some(js_object_default_to_locale_string(object)), + "valueOf" => Some(js_object_default_value_of(object)), + _ => None, + } +} + pub(super) unsafe fn dispatch_map_set( root_scope: &crate::gc::RuntimeHandleScope, object_handle: &crate::gc::RuntimeHandle, @@ -217,6 +226,14 @@ pub(super) unsafe fn dispatch_map_set( crate::map::js_map_foreach(map, args[0], this_arg); f64::from_bits(crate::value::TAG_UNDEFINED) } + _ if args.is_empty() => { + if let Some(result) = dispatch_inherited_object_method(object, method_name) + { + result + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + } + } _ => f64::from_bits(crate::value::TAG_UNDEFINED), }); } @@ -271,6 +288,14 @@ pub(super) unsafe fn dispatch_map_set( crate::set::js_set_foreach(set, args[0], this_arg); f64::from_bits(crate::value::TAG_UNDEFINED) } + _ if args.is_empty() => { + if let Some(result) = dispatch_inherited_object_method(object, method_name) + { + result + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + } + } // #2872: ES2024 Set composition methods. union/intersection/ // difference/symmetricDifference return a new Set; the // is* predicates return a boolean. diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 494544e9a..9ee80aea9 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -696,13 +696,7 @@ pub(super) unsafe fn dispatch_common( } else { payload }; - let s = if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) { - (n as i64).to_string() - } else { - n.to_string() - }; - let str_ptr = - crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } Some("Boolean") => { @@ -753,12 +747,7 @@ pub(super) unsafe fn dispatch_common( crate::value::js_jsvalue_to_string_radix(object, radix_arg.unwrap()); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } - let s = if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) { - (n as i64).to_string() - } else { - n.to_string() - }; - let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } else if jsval.is_bool() { let s = if jsval.as_bool() { "true" } else { "false" }; diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index db3ed199b..2279f1c99 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -189,13 +189,7 @@ pub(super) unsafe fn dispatch_primitive( } else { payload }; - let s = if n.fract() == 0.0 && n.abs() < (i64::MAX as f64) { - (n as i64).to_string() - } else { - n.to_string() - }; - let str_ptr = - crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); + let str_ptr = crate::string::js_number_to_string(n); return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits())); } Some("Boolean") => { diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index 0cb55ca9e..809a4099b 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -216,6 +216,10 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 const TAG_NULL_U64: u64 = 0x7FFC_0000_0000_0002; const TAG_UNDEFINED_U64: u64 = 0x7FFC_0000_0000_0001; let advance = |bits: u64| -> u64 { + let current_js = crate::value::JSValue::from_bits(bits); + if current_js.is_null() || current_js.is_undefined() { + return TAG_NULL_U64; + } let val = f64::from_bits(bits); // OrdinarySetPrototypeOf step 7.b.ii.1: if `p`'s [[GetPrototypeOf]] // is not the ordinary internal method (a Proxy's is exotic — it may diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index f2a91dfe6..fa6f0a666 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -311,6 +311,58 @@ pub extern "C" fn js_object_define_property( if let Some(name) = super::super::metadata_key_to_string(key_value) { let desc_ptr = extract_obj_ptr(descriptor_value); if !desc_ptr.is_null() { + let desc_has_value = desc_has_field(descriptor_value, b"value"); + let desc_has_writable = desc_has_field(descriptor_value, b"writable"); + if desc_has_value || desc_has_writable { + let existing_attrs = + super::super::get_property_attrs(target_cid as usize, &name).or_else( + || { + if matches!(name.as_str(), "name" | "length" | "prototype") { + Some(PropertyAttrs::new(false, false, true)) + } else if super::super::class_registry::class_own_static_field_value( + target_cid, &name, + ) + .is_some() + { + Some(PropertyAttrs::new(true, true, true)) + } else { + None + } + }, + ); + let value = if desc_has_value { + f64::from_bits(desc_read_field(descriptor_value, b"value").bits()) + } else { + super::super::class_registry::class_own_static_field_value( + target_cid, &name, + ) + .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)) + }; + super::super::class_registry::class_dynamic_prop_root_store( + target_cid, + name.clone(), + value, + ); + let read_bool = |field: &[u8]| -> Option { + if !desc_has_field(descriptor_value, field) { + return None; + } + let v = desc_read_field(descriptor_value, field); + Some(crate::value::js_is_truthy(f64::from_bits(v.bits())) != 0) + }; + let attrs = PropertyAttrs::new( + read_bool(b"writable").unwrap_or_else(|| { + existing_attrs.map(|a| a.writable()).unwrap_or(false) + }), + read_bool(b"enumerable").unwrap_or_else(|| { + existing_attrs.map(|a| a.enumerable()).unwrap_or(false) + }), + read_bool(b"configurable").unwrap_or_else(|| { + existing_attrs.map(|a| a.configurable()).unwrap_or(false) + }), + ); + super::super::set_property_attrs(target_cid as usize, name.clone(), attrs); + } let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); let value_field = js_object_get_field_by_name(desc_ptr as *const ObjectHeader, value_key); diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index ca8747175..72a349035 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -54,6 +54,13 @@ pub extern "C" fn js_object_is(a: f64, b: f64) -> f64 { return f64::from_bits(TAG_FALSE); } + if a_jsval.is_bigint() && b_jsval.is_bigint() { + if crate::bigint::js_bigint_eq(a_jsval.as_bigint_ptr(), b_jsval.as_bigint_ptr()) != 0 { + return f64::from_bits(TAG_TRUE); + } + return f64::from_bits(TAG_FALSE); + } + // For everything else, bit-pattern equality if a_bits == b_bits { f64::from_bits(TAG_TRUE) @@ -321,6 +328,11 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { let gc_header = (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; if (*gc_header).obj_type == crate::gc::GC_TYPE_ARRAY { + if let Some(key) = super::super::has_own_helpers::str_from_string_header(key_str) { + if super::super::is_inherited_object_proto_shim_on_builtin_prototype(obj, key) { + return f64::from_bits(TAG_FALSE); + } + } let present = super::super::has_own_helpers::array_own_key_present( obj as *const crate::array::ArrayHeader, key_str, @@ -353,6 +365,17 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { { return f64::from_bits(TAG_FALSE); } + if super::super::is_inherited_object_proto_shim_on_builtin_prototype(obj, key) { + return f64::from_bits(TAG_FALSE); + } + } + } + + if (*obj).class_id == 0 { + if let Some(key) = super::super::has_own_helpers::str_from_string_header(key_str) { + if super::super::is_inherited_object_proto_shim_on_builtin_prototype(obj, key) { + return f64::from_bits(TAG_FALSE); + } } } @@ -565,6 +588,9 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 if !is_valid_obj_ptr(obj as *const u8) { return f64::from_bits(TAG_FALSE); } + if super::super::is_inherited_object_proto_shim_on_builtin_prototype(obj, key_name) { + return f64::from_bits(TAG_FALSE); + } if (*obj).class_id == NATIVE_MODULE_CLASS_ID { if let Some(module_name) = read_native_module_name(obj) { return f64::from_bits( diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index d5782fd8c..1202865e2 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -426,6 +426,11 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { } return function_prototype_or_null(); } + if let Some(proto_bits) = + super::super::prototype_chain::object_static_prototype(raw_addr as usize) + { + return f64::from_bits(proto_bits); + } // Fast [[Prototype]] for a DECLARED-class instance: resolve // directly from the class id instead of the generic // `constructor_dynamic_prototype` probe, which reads the @@ -454,6 +459,20 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { return proto; } } + // #3986: `Object.create(proto)` / `new F()` (plain function + // ctor) instances carry a synthetic class id whose prototype + // object is stored keyed by that id. Return the exact stored + // object so prototype identity is preserved. Gated on + // GC_TYPE_OBJECT so non-object heap values don't misresolve. + if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT { + let synth_proto = + super::super::class_registry::class_prototype_object((*obj).class_id); + if !synth_proto.is_null() { + return f64::from_bits( + crate::value::js_nanbox_pointer(synth_proto as i64).to_bits(), + ); + } + } if let Some(proto) = constructor_dynamic_prototype(obj) { return proto; } @@ -487,27 +506,6 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { { return proto; } - // #3986: `Object.create(proto)` and `new F()` (a plain - // function ctor, whose instances carry a synthetic - // function-prototype class id) record the actual - // [[Prototype]] object pointer in CLASS_PROTOTYPE_OBJECTS - // keyed by that synthetic class id. Return the exact stored - // pointer so `Object.getPrototypeOf(o) === proto` holds by - // identity (test262 built-ins/Object/create/15.2.3.5-*, - // S9.9 ToObject identity). Declared ES classes use the - // separate CLASS_DECL_PROTOTYPE_OBJECTS table handled just - // above, so this does not perturb the - // `getPrototypeOf(instance) === instance` model their - // `.constructor` resolution relies on. Without this the - // synthetic-class instance fell through to the - // `return obj_value` self-prototype fallback below. - let synth_proto = - super::super::class_registry::class_prototype_object((*obj).class_id); - if !synth_proto.is_null() { - return f64::from_bits( - crate::value::js_nanbox_pointer(synth_proto as i64).to_bits(), - ); - } } // A native-module namespace object (`require("path")` etc., // class_id NATIVE_MODULE_CLASS_ID, the `__module__`-tagged @@ -587,6 +585,17 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { } return function_prototype_or_null(); } + // #3986: synthetic-class instance (see the sibling site above) — + // return its stored prototype object to preserve identity. + if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT { + let synth_proto = + super::super::class_registry::class_prototype_object((*obj).class_id); + if !synth_proto.is_null() { + return f64::from_bits( + crate::value::js_nanbox_pointer(synth_proto as i64).to_bits(), + ); + } + } if let Some(proto) = constructor_dynamic_prototype(obj) { return proto; } @@ -612,27 +621,6 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 { { return proto; } - // #3986: `Object.create(proto)` and `new F()` (a plain - // function ctor, whose instances carry a synthetic - // function-prototype class id) record the actual - // [[Prototype]] object pointer in CLASS_PROTOTYPE_OBJECTS - // keyed by that synthetic class id. Return the exact stored - // pointer so `Object.getPrototypeOf(o) === proto` holds by - // identity (test262 built-ins/Object/create/15.2.3.5-*, - // S9.9 ToObject identity). Declared ES classes use the - // separate CLASS_DECL_PROTOTYPE_OBJECTS table handled just - // above, so this does not perturb the - // `getPrototypeOf(instance) === instance` model their - // `.constructor` resolution relies on. Without this the - // synthetic-class instance fell through to the - // `return obj_value` self-prototype fallback below. - let synth_proto = - super::super::class_registry::class_prototype_object((*obj).class_id); - if !synth_proto.is_null() { - return f64::from_bits( - crate::value::js_nanbox_pointer(synth_proto as i64).to_bits(), - ); - } // A native-module namespace object (`require("path")` etc., // class_id NATIVE_MODULE_CLASS_ID, the `__module__`-tagged // object) is an ordinary object whose [[Prototype]] is diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index b2e8d4861..ead63d26c 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -25,6 +25,26 @@ pub(crate) fn web_stream_to_string_tag(value: f64) -> Option<&'static str> { } } +pub(crate) fn fetch_handle_to_string_tag(value: f64) -> Option<&'static str> { + let bits = value.to_bits(); + if (bits >> 48) != 0x7FFD { + return None; + } + let id = (bits & crate::value::POINTER_MASK) as usize; + if !crate::value::addr_class::is_handle_band(id) { + return None; + } + let kind_probe = crate::object::fetch_handle_kind_probe()?; + match unsafe { kind_probe(id) } { + 1 => Some("Response"), + 2 => Some("Request"), + 3 => Some("Headers"), + 4 => Some("Blob"), + 5 => Some("File"), + _ => None, + } +} + unsafe fn string_value_to_owned(value: f64) -> Option { let jv = crate::value::JSValue::from_bits(value.to_bits()); if !jv.is_any_string() { @@ -261,6 +281,18 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { 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)); } + if let Some(tag) = fetch_handle_to_string_tag(value) { + let formatted = format!("[object {}]", tag); + let bytes = formatted.as_bytes(); + 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)); + } + if let Some(obj) = crate::url::object_from_f64(value) { + if crate::url::url_class::is_url_object_shape(obj) { + let str_ptr = crate::string::js_string_from_bytes(b"[object URL]".as_ptr(), 12); + return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK)); + } + } if let Some(tag) = crate::builtins::boxed_primitive_to_string_tag(value) { let formatted = format!("[object {}]", tag); let bytes = formatted.as_bytes(); diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 184d77be8..5b77d882a 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -961,7 +961,12 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { // allocation. Closures don't carry the flag, so keep consulting the side // tables for them (their `name`/`length` + user `defineProperty` descriptors // live there). - if crate::object::object_has_descriptors(obj_ptr) || crate::closure::is_closure_ptr(obj_ptr) { + let array_has_descriptors = + unsafe { crate::object::array_has_descriptors(obj_ptr as *const crate::ObjectHeader) }; + if crate::object::object_has_descriptors(obj_ptr) + || array_has_descriptors + || crate::closure::is_closure_ptr(obj_ptr) + { if let Some(acc) = crate::object::get_accessor_descriptor(obj_ptr, &key_name) { return Some(OwnSetDescriptor::Accessor { setter_bits: acc.set, diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 25780b55a..e9b5b092a 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -62,6 +62,19 @@ impl Hash for JSValueKey { fn hash(&self, state: &mut H) { let bits = self.0.to_bits(); let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let value = crate::JSValue::from_bits(bits); + if value.is_bigint() { + 0xFFFF_FFFEu32.hash(state); + let ptr = crate::bigint::clean_bigint_ptr(value.as_bigint_ptr()); + if ptr.is_null() { + 0u8.hash(state); + } else { + unsafe { + (*ptr).limbs.hash(state); + } + } + return; + } if is_string_like(bits) { if let Some((data, len)) = string_view_from_bits(bits, &mut scratch) { // String value: hash by content so identical strings with @@ -506,6 +519,12 @@ fn jsvalue_eq(a: f64, b: f64) -> bool { return false; } + let a_val = crate::JSValue::from_bits(a_bits); + let b_val = crate::JSValue::from_bits(b_bits); + if a_val.is_bigint() && b_val.is_bigint() { + return crate::bigint::js_bigint_eq(a_val.as_bigint_ptr(), b_val.as_bigint_ptr()) != 0; + } + if is_string_like(a_bits) && is_string_like(b_bits) { let mut a_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; let mut b_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; diff --git a/crates/perry-runtime/src/string/char_ops.rs b/crates/perry-runtime/src/string/char_ops.rs index 226076b19..b40dad746 100644 --- a/crates/perry-runtime/src/string/char_ops.rs +++ b/crates/perry-runtime/src/string/char_ops.rs @@ -98,13 +98,14 @@ pub extern "C" fn js_string_char_code_at(s: *const StringHeader, index: i32) -> /// `s[key]` indexed read with ECMAScript CanonicalNumericIndexString semantics /// (#3987): returns the single-UTF-16-code-unit string at `key` only when `key` -/// is a canonical array index — a non-negative integer (or a numeric string -/// that round-trips, e.g. `"1"`) within `[0, length)`. Every other key returns -/// `undefined`: `NaN`, `Infinity`, negatives, fractions like `1.5`, -/// out-of-range indices, non-canonical strings like `"01"` / `" 1"` / `"1.0"`, -/// and non-numeric keys. Previously codegen `fptosi`'d the key and called -/// `js_string_char_at`, which truncated `1.5`→`1`, mapped `NaN`→`0`, returned -/// `""` (not `undefined`) for OOB/negatives, and mis-resolved string keys. +/// is `"length"` or a canonical array index — a non-negative integer (or a +/// numeric string that round-trips, e.g. `"1"`) within `[0, length)`. Other +/// keys return `undefined`: `NaN`, `Infinity`, negatives, fractions like +/// `1.5`, out-of-range indices, non-canonical strings like `"01"` / `" 1"` / +/// `"1.0"`, and non-index property names. Previously codegen `fptosi`'d the +/// key and called `js_string_char_at`, which truncated `1.5`→`1`, mapped +/// `NaN`→`0`, returned `""` (not `undefined`) for OOB/negatives, and +/// mis-resolved string keys. #[no_mangle] pub extern "C" fn js_string_index_get(s: *const StringHeader, key: f64) -> f64 { const UNDEFINED: f64 = f64::from_bits(crate::value::TAG_UNDEFINED); @@ -113,6 +114,8 @@ pub extern "C" fn js_string_index_get(s: *const StringHeader, key: f64) -> f64 { } let len = unsafe { (*s).utf16_len } as u64; let jsval = crate::value::JSValue::from_bits(key.to_bits()); + let raw_key_bits = key.to_bits(); + let raw_key_ptr = raw_key_bits as *const StringHeader; let idx: u64 = if jsval.is_int32() { let i = jsval.as_int32(); @@ -126,14 +129,44 @@ pub extern "C" fn js_string_index_get(s: *const StringHeader, key: f64) -> f64 { return UNDEFINED; } key as u64 // saturating; an out-of-range magnitude fails the bound below - } else if jsval.is_any_string() { - match crate::builtins::jsvalue_string_content(key).and_then(|k| canonical_string_index(&k)) - { + } else if (raw_key_bits >> 48) == 0 && is_valid_string_ptr(raw_key_ptr) { + let key_content = string_as_str(raw_key_ptr).to_string(); + if key_content == "length" { + return len as f64; + } + match canonical_string_index(&key_content) { Some(i) => i, None => return UNDEFINED, } } else { - return UNDEFINED; + let key_content = if jsval.is_any_string() { + let Some(content) = crate::builtins::jsvalue_string_content(key) else { + return UNDEFINED; + }; + content + } else { + let direct_key_ptr = crate::value::js_jsvalue_to_string(key); + if is_valid_string_ptr(direct_key_ptr) { + string_as_str(direct_key_ptr).to_string() + } else { + let property_key = unsafe { crate::object::js_to_property_key(key) }; + if unsafe { crate::symbol::js_is_symbol(property_key) } != 0 { + return UNDEFINED; + } + let key_ptr = crate::value::js_jsvalue_to_string(property_key); + if !is_valid_string_ptr(key_ptr) { + return UNDEFINED; + } + string_as_str(key_ptr).to_string() + } + }; + if key_content == "length" { + return len as f64; + } + match canonical_string_index(&key_content) { + Some(i) => i, + None => return UNDEFINED, + } }; if idx >= len { diff --git a/crates/perry-runtime/src/string/format.rs b/crates/perry-runtime/src/string/format.rs index c8e00d06a..4a316c12e 100644 --- a/crates/perry-runtime/src/string/format.rs +++ b/crates/perry-runtime/src/string/format.rs @@ -135,13 +135,18 @@ pub(crate) fn js_format_f64(value: f64) -> String { // matches Node's output). let abs = value.abs(); if !(1e-6..1e21).contains(&abs) { - fix_exponent_format(&format!("{:e}", value)) + format_short_exponential(value) } else { format!("{}", value) } } } +fn format_short_exponential(value: f64) -> String { + let mut buf = ryu::Buffer::new(); + fix_exponent_format(buf.format(value)) +} + /// GC root scanner for the small-integer string cache. /// /// The cache stores raw `StringHeader*` values, not NaN-boxed JSValues. The @@ -589,9 +594,24 @@ fn format_number_for_js(value: f64) -> String { // ECMAScript NumberToString — see js_number_to_string for rationale. let abs = value.abs(); if !(1e-6..1e21).contains(&abs) { - fix_exponent_format(&format!("{:e}", value)) + format_short_exponential(value) } else { format!("{}", value) } } } + +#[cfg(test)] +mod tests { + use super::js_format_f64; + + #[test] + fn formats_large_numbers_with_short_js_exponent() { + assert_eq!( + js_format_f64(3.4028234663852886e38), + "3.4028234663852886e+38" + ); + assert_eq!(js_format_f64(1e21), "1e+21"); + assert_eq!(js_format_f64(1e-7), "1e-7"); + } +} diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 36bfb0c5e..4db60e7b5 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -303,6 +303,30 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(v) = web_stream_symbol_property(obj_f64, sym_f64) { return v; } + if let Some(tag) = crate::object::fetch_handle_to_string_tag(obj_f64) { + let tag_wk = well_known_symbol("toStringTag"); + if !tag_wk.is_null() { + let tag_f64 = + f64::from_bits(crate::value::JSValue::pointer(tag_wk as *const u8).bits()); + if sym_key_from_f64(sym_f64) == sym_key_from_f64(tag_f64) { + let s = crate::string::js_string_from_bytes(tag.as_ptr(), tag.len() as u32); + return f64::from_bits(crate::js_nanbox_string(s as i64).to_bits()); + } + } + } + if let Some(obj) = crate::url::object_from_f64(obj_f64) { + if crate::url::url_class::is_url_object_shape(obj) { + let tag_wk = well_known_symbol("toStringTag"); + if !tag_wk.is_null() { + let tag_f64 = + f64::from_bits(crate::value::JSValue::pointer(tag_wk as *const u8).bits()); + if sym_key_from_f64(sym_f64) == sym_key_from_f64(tag_f64) { + let s = crate::string::js_string_from_bytes(b"URL".as_ptr(), 3); + return f64::from_bits(crate::js_nanbox_string(s as i64).to_bits()); + } + } + } + } // #1213: Timeout/Immediate handles expose `Symbol.dispose` so // `using t = setTimeout(...)` and `t[Symbol.dispose]()` clear the timer. // The handle is a small id NaN-boxed as POINTER; the symbol-keyed read diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index dc266dd77..e611b4c7b 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -44,6 +44,8 @@ pub use self::search_params::{ // a URLSearchParams (a plain class_id-0 ObjectHeader) and pull its entries. pub(crate) use self::search_params::try_read_as_search_params; pub(crate) use self::url_class::is_url_object_shape; +/// Canonical class-id for `URL` instances (see instanceof / branding paths). +pub(crate) const CLASS_ID_URL: u32 = 0xFFFF002F; pub use self::url_class::{ js_url_can_parse, js_url_can_parse_with_base, js_url_get_hash, js_url_get_host, js_url_get_hostname, js_url_get_href, js_url_get_origin, js_url_get_pathname, js_url_get_port, @@ -127,9 +129,9 @@ pub extern "C" fn js_url_coerce_string(value: f64) -> *mut StringHeader { pub(crate) fn object_from_f64(value: f64) -> Option<*mut ObjectHeader> { let bits = value.to_bits(); if (bits & 0xFFFF_0000_0000_0000) == 0x7FFD_0000_0000_0000 { - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; - if !ptr.is_null() { - return Some(ptr); + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::value::addr_class::is_plausible_heap_addr(addr) { + return Some(addr as *mut ObjectHeader); } } None diff --git a/crates/perry-runtime/src/url/parse.rs b/crates/perry-runtime/src/url/parse.rs index ab520599d..a0299fda1 100644 --- a/crates/perry-runtime/src/url/parse.rs +++ b/crates/perry-runtime/src/url/parse.rs @@ -14,7 +14,7 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin // future code path that fails to assign before use. let mut host: String; let hostname: String; - let pathname: String; + let mut pathname: String; let mut port = String::new(); let mut search = String::new(); let mut hash = String::new(); @@ -174,6 +174,24 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin } } + #[cfg(feature = "url-engine")] + if matches!( + protocol.as_str(), + "http:" | "https:" | "ws:" | "wss:" | "ftp:" + ) { + if let Ok(parsed) = url::Url::parse(url_str) { + pathname = parsed.path().to_string(); + search = parsed + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(); + hash = parsed + .fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default(); + } + } + (protocol, host, hostname, port, pathname, search, hash) } @@ -391,6 +409,10 @@ pub(crate) fn create_url_object(url_string: &str) -> *mut ObjectHeader { // Allocate object with URL_FIELD_COUNT fields // Using class_id 0 for now (generic object) let obj = js_object_alloc(0, URL_FIELD_COUNT); + let proto = crate::object::builtin_prototype_value("URL"); + if proto.to_bits() != crate::value::TAG_UNDEFINED { + crate::object::prototype_chain::object_set_static_prototype(obj as usize, proto.to_bits()); + } // Create the keys array with property names (order must match field indices) let mut keys = js_array_alloc(URL_FIELD_COUNT); diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 5471895a6..7f235d79f 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -29,10 +29,10 @@ fn finite_nonnegative_u32_index(index: f64) -> Option { } /// Tag-aware dynamic index dispatch for `obj[key]` where `obj` has unknown -/// static type. Issue #514. Strings → js_string_char_at; objects stringify -/// numeric keys (`obj[0]` is `obj["0"]`), while arrays/buffers keep numeric -/// element reads. LAZY_ARRAY / FORWARDED arrays route through -/// `js_array_get_f64` to chase the materialized chain. +/// static type. Issue #514. Strings use canonical string-index/property +/// handling; objects stringify numeric keys (`obj[0]` is `obj["0"]`), while +/// arrays/buffers keep numeric element reads. LAZY_ARRAY / FORWARDED arrays +/// route through `js_array_get_f64` to chase the materialized chain. #[no_mangle] pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { let bits = value.to_bits(); @@ -43,6 +43,24 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { if bits == TAG_UNDEFINED || bits == TAG_NULL { crate::object::has_own_helpers::throw_to_object_nullish_type_error(); } + // A raw string pointer (a module-level string stored as raw I64, top16 == 0) + // routes through string indexing. Validate the GcHeader (obj_type == STRING) + // rather than a bare `>= 0x1000` range check: a plain `number` whose f64 bits + // look like a low pointer — e.g. the denormal ~1.7e-314 (bits 0x8_0000_0000) + // effect's fiber loop produces — also has top16 == 0, and dereferencing it as + // a StringHeader SIGBUSes. `try_read_gc_header` rejects non-heap addresses + // WITHOUT touching memory, so a denormal-bits number falls through to the + // number path below and correctly yields `undefined`. #63 / #321. + if (bits >> 48) == 0 { + if let Some(hdr) = unsafe { crate::value::addr_class::try_read_gc_header(bits as usize) } { + if hdr.obj_type == crate::gc::GC_TYPE_STRING { + return crate::string::js_string_index_get( + bits as *const crate::StringHeader, + index, + ); + } + } + } let jsval = JSValue::from_bits(bits); // #5525: a Symbol *index* (`obj[Symbol.iterator]`) must resolve through the // symbol side-table, never the integer-index / stringify paths below (which @@ -72,16 +90,7 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { if s_ptr.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let idx_i32 = if index.is_nan() || index.is_infinite() { - 0 - } else { - index as i32 - }; - let result = crate::string::js_string_char_at(s_ptr, idx_i32); - if result.is_null() { - return f64::from_bits(TAG_UNDEFINED); - } - return f64::from_bits(JSValue::string_ptr(result).bits()); + return crate::string::js_string_index_get(s_ptr, index); } // Class-ref value (INT32-tagged, top16 == 0x7FFE): `C[key]` where `C` is a // runtime class-ref value (e.g. a function parameter). Member-expression diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index 8065241c3..84cb2a033 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -93,6 +93,16 @@ unsafe fn both_bigint_or_throw(a: f64, b: f64) -> bool { } } +#[inline] +unsafe fn dynamic_number_operand(value: f64) -> f64 { + let jsval = JSValue::from_bits(value.to_bits()); + if jsval.is_number() || jsval.is_int32() { + value + } else { + crate::builtins::js_number_coerce(value) + } +} + #[cold] unsafe fn throw_add_type_error(message: &[u8]) -> ! { let s = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -277,7 +287,8 @@ pub unsafe extern "C" fn js_dynamic_mul(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_mul); } - numify_arith_operand(a) * numify_arith_operand(b) + numify_arith_operand(dynamic_number_operand(a)) + * numify_arith_operand(dynamic_number_operand(b)) } /// Dynamic add: BigInt + BigInt if either operand is BigInt, else f64 + f64. @@ -286,6 +297,10 @@ pub unsafe extern "C" fn js_dynamic_add(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_add); } + // Unlike `- * / %`, `+` intentionally does NOT `dynamic_number_operand` + // (ToNumber) its operands: string concatenation is handled by the `+` + // lowering before reaching here, so any non-number that survives to this + // point is already meant to add numerically. numify_arith_operand(a) + numify_arith_operand(b) } @@ -457,7 +472,8 @@ pub unsafe extern "C" fn js_dynamic_sub(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_sub); } - numify_arith_operand(a) - numify_arith_operand(b) + numify_arith_operand(dynamic_number_operand(a)) + - numify_arith_operand(dynamic_number_operand(b)) } /// Dynamic divide: BigInt / BigInt if either operand is BigInt, else f64 / f64. @@ -468,7 +484,8 @@ pub unsafe extern "C" fn js_dynamic_div(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_div); } - numify_arith_operand(a) / numify_arith_operand(b) + numify_arith_operand(dynamic_number_operand(a)) + / numify_arith_operand(dynamic_number_operand(b)) } /// Dynamic modulo: BigInt % BigInt if either operand is BigInt, else f64 % f64. @@ -479,6 +496,8 @@ pub unsafe extern "C" fn js_dynamic_mod(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_mod); } + let a = dynamic_number_operand(a); + let b = dynamic_number_operand(b); // Float modulo: a - trunc(a / b) * b let a = numify_arith_operand(a); let b = numify_arith_operand(b); @@ -605,7 +624,7 @@ pub unsafe extern "C" fn js_dynamic_pow(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_pow); } - crate::math::js_math_pow(a, b) + crate::math::js_math_pow(dynamic_number_operand(a), dynamic_number_operand(b)) } /// Dynamic unsigned right shift. BigInts have no `>>>` operator in diff --git a/crates/perry-runtime/src/value/nanbox.rs b/crates/perry-runtime/src/value/nanbox.rs index 0568ad579..e4454ef59 100644 --- a/crates/perry-runtime/src/value/nanbox.rs +++ b/crates/perry-runtime/src/value/nanbox.rs @@ -308,6 +308,7 @@ pub extern "C" fn js_get_string_pointer_unified(value: f64) -> i64 { /// - string vs non-string → false /// - number vs number → IEEE `==` after int32 unboxing (NaN ≠ NaN, -0 == +0, /// int32-boxed 1 == raw 1.0) +/// - bigint vs bigint → value compare (`js_bigint_eq`); bigint vs other → false /// - everything else (undefined/null/bool/pointers) → bit identity #[no_mangle] pub extern "C" fn js_switch_strict_equals(a: f64, b: f64) -> i32 { @@ -346,6 +347,17 @@ pub extern "C" fn js_switch_strict_equals(a: f64, b: f64) -> i32 { }; return (an == bn) as i32; } + // BigInt cases compare by value, not allocation identity, so + // `switch (1n + 0n) { case 1n: }` matches — mirrors the Set/Map key and + // general `===` paths. A BigInt is never `===` a non-BigInt. + let a_big = av.is_bigint(); + let b_big = bv.is_bigint(); + if a_big != b_big { + return 0; + } + if a_big { + return (crate::bigint::js_bigint_eq(av.as_bigint_ptr(), bv.as_bigint_ptr()) != 0) as i32; + } (a.to_bits() == b.to_bits()) as i32 } diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index abab8b97f..8df07fa10 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -235,10 +235,10 @@ fn form_data_bound_method_value(form_id: usize, method_name: &'static str) -> f6 /// `instanceof` kind-probe for fetch handles (registered with the runtime at /// init via `js_register_fetch_handle_kind_probe`). Returns 0 = none, -/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob. Lets `x instanceof -/// Response` (etc.) resolve for the pointer-tagged small-integer handles these -/// types use instead of heap objects. Lives here (not `mod.rs`) to keep that -/// file under the 2,000-line lint gate. +/// 1 = Response, 2 = Request, 3 = Headers, 4 = Blob, 5 = File. Lets +/// `x instanceof Response` (etc.) resolve for the pointer-tagged small-integer +/// handles these types use instead of heap objects. Lives here (not `mod.rs`) +/// to keep that file under the 2,000-line lint gate. #[no_mangle] pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if FETCH_RESPONSES.lock().unwrap().contains_key(&id) { @@ -250,8 +250,8 @@ pub extern "C" fn js_fetch_handle_kind(id: usize) -> u8 { if HEADERS_REGISTRY.lock().unwrap().contains_key(&id) { return 3; } - if BLOB_REGISTRY.lock().unwrap().contains_key(&id) { - return 4; + if let Some(blob) = BLOB_REGISTRY.lock().unwrap().get(&id) { + return if blob.file_name.is_some() { 5 } else { 4 }; } 0 } @@ -809,6 +809,17 @@ pub fn dispatch_blob_property(blob_id: usize, prop: &str) -> Option { }); } let bits = match prop { + "constructor" => { + let ctor_name = if blob.file_name.is_some() { + "File" + } else { + "Blob" + }; + return Some(perry_runtime::object::js_get_global_this_builtin_value( + ctor_name.as_ptr(), + ctor_name.len(), + )); + } "size" => return Some(blob.body.len() as f64), "type" => { let p = diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index aea2a88fb..95b704d16 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -836,6 +836,25 @@ fn collect_module_one( )); return; } + if let Some(unresolved) = set.iter().find(|p| { + cached_resolve_import_with_lexical_base(p, entry_path, &canonical, ctx) + .is_none() + }) { + let line = + perry_hir::current_module_line_at(*byte_offset).filter(|&l| l != 0); + let loc = match line { + Some(l) => format!("{}:{}", source_file_path, l), + None => source_file_path.clone(), + }; + let msg = format!( + "dynamic import() of registry value {:?} cannot be resolved in an \ + ahead-of-time compiled binary ({loc})", + unresolved + ); + perry_hir::record_deferred_aot_site("import(...)", loc); + dynamic_path_sets.push(DynImportOutcome::Deferred(msg)); + return; + } for p in &set { if !new_dyn_imports.contains(p) { new_dyn_imports.push(p.clone()); diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index a573734c7..6654aeb94 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -537,8 +537,8 @@ fn compute_object_cache_key_with_env( .join(","); h.field("namespace_member_prefixes", &s); } - // Issue #5924: per-namespace origin-name resolution. Same rationale as - // `namespace_member_prefixes` above — not reflected in the consumer + // Issue #680 / #5924: per-namespace origin-name resolution. Same rationale + // as `namespace_member_prefixes` above — not reflected in the consumer // module's HIR, but changes the symbol suffix a namespace member // call/property access targets. { @@ -552,6 +552,37 @@ fn compute_object_cache_key_with_env( .join(","); h.field("namespace_member_origin_names", &s); } + { + let mut v: Vec<&(String, String)> = opts.namespace_member_vars.iter().collect(); + v.sort(); + let s: String = v + .iter() + .map(|(ns, member)| format!("{}:{}", ns, member)) + .collect::>() + .join(","); + h.field("namespace_member_vars", &s); + } + { + let mut v: Vec<(&(String, String), &String)> = + opts.namespace_member_namespace_prefixes.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + let s: String = v + .iter() + .map(|((ns, member), prefix)| format!("{}:{}={}", ns, member, prefix)) + .collect::>() + .join(","); + h.field("namespace_member_namespace_prefixes", &s); + } + { + let mut v: Vec<(&String, &String)> = opts.namespace_import_prefixes.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + let s: String = v + .iter() + .map(|(local, prefix)| format!("{}={}", local, prefix)) + .collect::>() + .join(","); + h.field("namespace_import_prefixes", &s); + } // Imported classes — sort by name. Serialize every field that codegen // reads so a changed constructor arity or new method on a re-exported @@ -566,11 +597,12 @@ fn compute_object_cache_key_with_env( let mut buf = String::new(); for c in v { buf.push_str(&format!( - "{}@{}:ctor={}:own_ctor={}:instance_fields={}:parent={}:alias={}:id={}:fields={}:methods={}:method_arities={}|", + "{}@{}:ctor={}:own_ctor={}:ctor_new_target={}:instance_fields={}:parent={}:alias={}:id={}:fields={}:methods={}:method_arities={}|", c.name, c.source_prefix, c.constructor_param_count, if c.has_own_constructor { "1" } else { "0" }, + if c.constructor_uses_new_target { "1" } else { "0" }, if c.has_instance_fields { "1" } else { "0" }, c.parent_name.as_deref().unwrap_or(""), c.local_alias.as_deref().unwrap_or(""), @@ -1047,6 +1079,9 @@ mod object_cache_tests { namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), namespace_member_origin_names: std::collections::HashMap::new(), + namespace_member_vars: std::collections::HashSet::new(), + namespace_member_namespace_prefixes: std::collections::HashMap::new(), + namespace_import_prefixes: std::collections::HashMap::new(), emit_ir_only: false, verify_native_regions: false, disable_buffer_fast_path: false, @@ -1371,6 +1406,7 @@ mod object_cache_tests { source_prefix: "feature_ts".into(), constructor_param_count: 0, has_own_constructor: false, + constructor_uses_new_target: false, constructor_has_rest: false, has_instance_fields: true, method_names: vec![], @@ -1406,6 +1442,7 @@ mod object_cache_tests { source_prefix: "src".into(), constructor_param_count: 1, has_own_constructor: true, + constructor_uses_new_target: false, constructor_has_rest: false, has_instance_fields: true, method_names: vec!["bar".into()], @@ -1426,6 +1463,7 @@ mod object_cache_tests { source_prefix: "src".into(), constructor_param_count: 2, // different arity has_own_constructor: true, + constructor_uses_new_target: false, constructor_has_rest: false, has_instance_fields: true, method_names: vec!["bar".into()], @@ -1454,6 +1492,7 @@ mod object_cache_tests { source_prefix: "src".into(), constructor_param_count: 1, has_own_constructor: true, + constructor_uses_new_target: false, constructor_has_rest: false, has_instance_fields: true, method_names: vec!["bar".into()], @@ -1520,6 +1559,33 @@ mod object_cache_tests { compute_object_cache_key(&a, 1, "0.5.156"), compute_object_cache_key(&b, 1, "0.5.156") ); + + let mut a = empty_opts(); + let mut b = empty_opts(); + b.namespace_member_origin_names + .insert(("ns".into(), "string".into()), "stringType".into()); + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); + + let mut a = empty_opts(); + let mut b = empty_opts(); + b.namespace_member_vars + .insert(("ns".into(), "string".into())); + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); + + let mut a = empty_opts(); + let mut b = empty_opts(); + b.namespace_import_prefixes + .insert("ns".into(), "source_prefix".into()); + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); } #[test] diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 5de169627..6338e7d7b 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -17,6 +17,162 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use crate::OutputFormat; +fn expr_uses_new_target(expr: &perry_hir::Expr) -> bool { + match expr { + perry_hir::Expr::NewTarget => true, + perry_hir::Expr::Closure { + captures_new_target, + .. + } => *captures_new_target, + _ => { + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found && expr_uses_new_target(child) { + found = true; + } + }); + found + } + } +} + +fn stmt_uses_new_target(stmt: &perry_hir::Stmt) -> bool { + use perry_hir::Stmt; + match stmt { + Stmt::Let { init, .. } => init.as_ref().is_some_and(expr_uses_new_target), + Stmt::Expr(expr) | Stmt::Return(Some(expr)) | Stmt::Throw(expr) => { + expr_uses_new_target(expr) + } + Stmt::Return(None) => false, + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_uses_new_target(condition) + || then_branch.iter().any(stmt_uses_new_target) + || else_branch + .as_ref() + .is_some_and(|branch| branch.iter().any(stmt_uses_new_target)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_uses_new_target(condition) || body.iter().any(stmt_uses_new_target) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_some_and(stmt_uses_new_target) + || condition.as_ref().is_some_and(expr_uses_new_target) + || update.as_ref().is_some_and(expr_uses_new_target) + || body.iter().any(stmt_uses_new_target) + } + Stmt::Labeled { body, .. } => stmt_uses_new_target(body), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_uses_new_target) + || catch + .as_ref() + .is_some_and(|catch| catch.body.iter().any(stmt_uses_new_target)) + || finally + .as_ref() + .is_some_and(|body| body.iter().any(stmt_uses_new_target)) + } + Stmt::Switch { + discriminant, + cases, + } => { + expr_uses_new_target(discriminant) + || cases.iter().any(|case| { + case.test.as_ref().is_some_and(expr_uses_new_target) + || case.body.iter().any(stmt_uses_new_target) + }) + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) => false, + } +} + +fn ctor_uses_new_target(class: &perry_hir::Class) -> bool { + class + .constructor + .as_ref() + .is_some_and(|ctor| ctor.body.iter().any(stmt_uses_new_target)) +} + +fn exported_class_by_name<'a>( + exported_classes: &'a BTreeMap<(String, String), &'a perry_hir::Class>, + class_canonical_path: &std::collections::HashMap, + preferred_path: &str, + name: &str, +) -> Option<(String, &'a perry_hir::Class)> { + let same_path_key = (preferred_path.to_string(), name.to_string()); + if let Some(class) = exported_classes.get(&same_path_key) { + return Some((preferred_path.to_string(), *class)); + } + let mut candidates = exported_classes + .iter() + .filter(|((_, class_name), _)| class_name == name); + let first = candidates.next()?; + if candidates.next().is_none() { + return Some((first.0 .0.clone(), *first.1)); + } + exported_classes + .iter() + .find(|((_, class_name), class)| { + class_name == name + && class_canonical_path + .get(&class.id) + .is_some_and(|path| path == preferred_path) + }) + .map(|((path, _), class)| (path.clone(), *class)) +} + +fn class_chain_uses_new_target( + exported_classes: &BTreeMap<(String, String), &perry_hir::Class>, + class_canonical_path: &std::collections::HashMap, + class_path: &str, + class: &perry_hir::Class, +) -> bool { + if ctor_uses_new_target(class) { + return true; + } + let mut current_path = class_canonical_path + .get(&class.id) + .cloned() + .unwrap_or_else(|| class_path.to_string()); + let mut parent = class.extends_name.as_deref(); + let mut depth = 0usize; + while let Some(parent_name) = parent { + depth += 1; + if depth > 64 { + break; + } + let Some((path, parent_class)) = exported_class_by_name( + exported_classes, + class_canonical_path, + ¤t_path, + parent_name, + ) else { + break; + }; + if ctor_uses_new_target(parent_class) { + return true; + } + current_path = path; + parent = parent_class.extends_name.as_deref(); + } + false +} + /// Same as [`run`] but accepts an optional in-memory [`ParseCache`] that /// `perry dev` uses to reuse parsed ASTs across rebuilds in a single session. /// Pass `None` for the batch-compile path. @@ -1605,12 +1761,46 @@ pub fn run_with_parse_cache( } out }; + let sanitize_member_name = |s: &str| -> String { + if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return sanitize_module_name(s); + } + let mut out = String::from("u_"); + for c in s.chars() { + if c.is_ascii_alphanumeric() { + out.push(c); + } else { + out.push('_'); + out.push_str(&format!("{:x}", c as u32)); + out.push('_'); + } + } + out + }; let mut path_to_module_name: HashMap = HashMap::new(); let mut module_name_to_path: HashMap = HashMap::new(); for (path, hir_module) in &ctx.native_modules { path_to_module_name.insert(path.clone(), hir_module.name.clone()); - module_name_to_path.insert(hir_module.name.clone(), path.clone()); + if let Ok(canonical) = std::fs::canonicalize(path) { + path_to_module_name.insert(canonical.clone(), hir_module.name.clone()); + module_name_to_path.insert(hir_module.name.clone(), canonical); + } else { + module_name_to_path.insert(hir_module.name.clone(), path.clone()); + } } + let namespace_path_cache: std::sync::Mutex> = + std::sync::Mutex::new(HashMap::new()); + let normalize_namespace_path = |path: PathBuf| { + if let Some(cached) = namespace_path_cache.lock().unwrap().get(&path).cloned() { + return cached; + } + let normalized = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + namespace_path_cache + .lock() + .unwrap() + .insert(path, normalized.clone()); + normalized + }; // Build a normalized HIR-by-name map for `flatten_exports`. Each // module's `Export::ReExport::source`, `Export::ExportAll::source`, // and `Export::NamespaceReExport::source` strings hold the raw @@ -1634,6 +1824,7 @@ pub fn run_with_parse_cache( &ctx.compile_packages, &ctx.compile_package_dirs, ) { + let resolved_path = normalize_namespace_path(resolved_path); if let Some(name) = path_to_module_name.get(&resolved_path) { *source = name.clone(); } @@ -1644,8 +1835,8 @@ pub fn run_with_parse_cache( } module_name_to_module.insert(hir_module.name.clone(), rewritten); } - // Set of native-module paths that are dynamic-import targets. We - // also build a parallel set keyed by Module::name for flatten_exports. + // Set of native-module paths that are dynamic-import targets. Those + // drive dynamic-import dispatch maps below. let mut dyn_target_paths: std::collections::HashSet = std::collections::HashSet::new(); for hir_module in ctx.native_modules.values() { for import in &hir_module.imports { @@ -1657,14 +1848,73 @@ pub fn run_with_parse_cache( continue; } if let Some(rp) = &import.resolved_path { - dyn_target_paths.insert(PathBuf::from(rp)); + dyn_target_paths.insert(normalize_namespace_path(PathBuf::from(rp))); + } + } + } + + // Namespace objects are needed for more than dynamic import: + // `import * as ns` values and `export * as ns from "./mod"` both need the + // producer module to emit/populate `@__perry_ns_`. Dynamic imports + // are still a subset because `await import()` resolves to that same object. + let mut namespace_target_paths = dyn_target_paths.clone(); + for (path, hir_module) in &ctx.native_modules { + for import in &hir_module.imports { + if import.type_only { + continue; + } + let has_namespace_import = import + .specifiers + .iter() + .any(|spec| matches!(spec, perry_hir::ImportSpecifier::Namespace { .. })); + if has_namespace_import { + if let Some(rp) = &import.resolved_path { + namespace_target_paths.insert(normalize_namespace_path(PathBuf::from(rp))); + } + } + } + for export in &hir_module.exports { + if let perry_hir::Export::NamespaceReExport { source, .. } = export { + if let Some((resolved_path, _)) = resolve_import( + source, + path, + &ctx.project_root, + &ctx.compile_packages, + &ctx.compile_package_dirs, + ) { + namespace_target_paths.insert(normalize_namespace_path(resolved_path)); + } + } + } + } + + // A namespace target can itself expose nested namespace re-exports. Include + // those nested sources too so `export * as coerce from "./coerce.js"` can + // load a real `@__perry_ns_` when building its parent namespace. + let mut changed = true; + while changed { + changed = false; + let targets: Vec = namespace_target_paths.iter().cloned().collect(); + for target_path in targets { + let Some(target_hir) = ctx.native_modules.get(&target_path) else { + continue; + }; + let lookup = |s: &str| module_name_to_module.get(s); + for fe in perry_hir::flatten_exports(&target_hir.name, &lookup) { + if let Some(nested) = &fe.nested_namespace_of { + if let Some(nested_path) = module_name_to_path.get(nested) { + if namespace_target_paths.insert(nested_path.clone()) { + changed = true; + } + } + } } } } // Per-module precomputed namespace_entries (keyed by path). let mut per_module_namespace_entries: HashMap> = HashMap::new(); - for target_path in &dyn_target_paths { + for target_path in &namespace_target_paths { let target_hir = match ctx.native_modules.get(target_path) { Some(m) => m, None => continue, // native/JS module — handled elsewhere @@ -1697,7 +1947,7 @@ pub fn run_with_parse_cache( let scoped = format!( "perry_fn_{}__{}", sanitize_module_name(&target_hir.name), - sanitize_module_name(&func.name) + sanitize_member_name(&func.name) ); perry_codegen::NamespaceEntryKind::LocalFunction { wrap_symbol: format!("__perry_wrap_{}", scoped), @@ -1720,13 +1970,13 @@ pub fn run_with_parse_cache( ); perry_codegen::NamespaceEntryKind::LocalVar { global_name: gname } } else { - // Best-effort: treat unknown locals as Var sourced - // by getter. This covers re-export shapes that the - // local-detection misses; the cross-module getter - // for the same module returns the value too. + // Best-effort: treat unknown locals as getter-backed vars. + // For renamed exports (`export { _null as null }`) the + // producer emits the public getter name, so prefer the + // namespace key over the private local spelling here. perry_codegen::NamespaceEntryKind::ForeignVar { source_prefix: sanitize_module_name(&target_hir.name), - source_local: fe.source_local.clone(), + source_local: fe.name.clone(), } } } else { @@ -1984,7 +2234,7 @@ pub fn run_with_parse_cache( continue; } if let Some(resolved) = &import.resolved_path { - let resolved_path = PathBuf::from(resolved); + let resolved_path = normalize_namespace_path(PathBuf::from(resolved)); if let Some(src_mod) = ctx.native_modules.get(&resolved_path) { push_dep(&mut deps, &mut seen, sanitize_name(&src_mod.name)); } @@ -2007,6 +2257,7 @@ pub fn run_with_parse_cache( &ctx.compile_packages, &ctx.compile_package_dirs, ) { + let resolved_path = normalize_namespace_path(resolved_path); if let Some(src_mod) = ctx.native_modules.get(&resolved_path) { push_dep(&mut deps, &mut seen, sanitize_name(&src_mod.name)); } @@ -2086,16 +2337,19 @@ pub fn run_with_parse_cache( // member_name)` → `source_prefix`. let mut namespace_member_prefixes: std::collections::HashMap<(String, String), String> = std::collections::HashMap::new(); - // Issue #5924 (companion to #680/#678): per-namespace origin-name - // resolution. `import_function_origin_names` is flat (keyed by - // bare member name), so when two namespaces imported into the - // same file both have a member with the same name and only ONE - // of them is a re-export rename, the rename's origin-name - // override clobbers the other namespace's (correct, unrenamed) - // suffix. Keyed by `(namespace_local, member_name)` → - // `origin_name`, mirroring `namespace_member_prefixes`. - let mut namespace_member_origin_names: - std::collections::HashMap<(String, String), String> = + // Issue #680 / #5924: per-namespace origin-name resolution + // (mirroring `namespace_member_prefixes`). Being per-namespace + // instead of the flat `import_function_origin_names` avoids the + // #5924 clobber where two namespaces sharing a member name (only one + // a re-export rename) overwrite each other (`Effect`'s re-exported + // `Service` broke `Context.Service`). + let mut namespace_member_origin_names: std::collections::HashMap<(String, String), String> = + std::collections::HashMap::new(); + let mut namespace_member_vars: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); + let mut namespace_member_namespace_prefixes: std::collections::HashMap<(String, String), String> = + std::collections::HashMap::new(); + let mut namespace_import_prefixes: std::collections::HashMap = std::collections::HashMap::new(); let mut namespace_imports: Vec = Vec::new(); // Issue #321: subset of `namespace_imports` populated only by the @@ -2260,6 +2514,25 @@ pub fn run_with_parse_cache( }; if let Some(local) = namespace_like_local { namespace_imports.push(local.clone()); + let resolved_key = + normalize_namespace_path(PathBuf::from(&resolved_path_str)); + if let Some(target_mod) = ctx.native_modules.get(&resolved_key) { + let prefix = sanitize_name(&target_mod.name); + namespace_import_prefixes.insert(local.clone(), prefix); + let lookup = |s: &str| module_name_to_module.get(s); + for entry in perry_hir::flatten_exports(&target_mod.name, &lookup) { + if let Some(nested_module_name) = entry.nested_namespace_of { + let nested_prefix = module_name_to_module + .get(&nested_module_name) + .map(|m| sanitize_module_name(&m.name)) + .unwrap_or_else(|| sanitize_module_name(&nested_module_name)); + namespace_member_namespace_prefixes.insert( + (local.clone(), entry.name), + nested_prefix, + ); + } + } + } // Register all exports from the source module if let Some(exports) = all_module_exports.get(&resolved_path_str) { for (export_name, origin_path) in exports { @@ -2342,6 +2615,14 @@ pub fn run_with_parse_cache( (local.clone(), export_name.clone()), origin_prefix.clone(), ); + if let Some(ref origin_name) = resolved_origin_name { + if origin_name != export_name { + namespace_member_origin_names.insert( + (local.clone(), export_name.clone()), + origin_name.clone(), + ); + } + } let key = (origin_path.clone(), export_name.clone()); if let Some(¶m_count) = exported_func_param_counts.get(&key) { @@ -2390,6 +2671,7 @@ pub fn run_with_parse_cache( .unwrap_or(false) { imported_vars.insert(export_name.clone()); + namespace_member_vars.insert((local.clone(), export_name.clone())); } if let Some(class) = exported_classes.get(&key) { let class_prefix = canonical_class_source_prefix( @@ -2408,6 +2690,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &key.0, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -2550,39 +2838,26 @@ pub fn run_with_parse_cache( // name, then route the local through `namespace_imports` // + register the namespace target's full export surface. let mut handled_as_namespace_reexport = false; - if let Some(src_hir) = source_module { - for export in &src_hir.exports { - if let perry_hir::Export::NamespaceReExport { - source: ns_src, - name, - } = export - { - if name != &exported_name { - continue; + macro_rules! register_namespace_export_surface { + ($namespace_local:expr, $target_path:expr) => {{ + let target_path_str = $target_path; + let target_key = normalize_namespace_path(PathBuf::from(target_path_str)); + if let Some(target_hir_for_ns) = ctx.native_modules.get(&target_key) { + let lookup = |s: &str| module_name_to_module.get(s); + for entry in perry_hir::flatten_exports(&target_hir_for_ns.name, &lookup) { + if let Some(nested_module_name) = entry.nested_namespace_of { + let nested_prefix = module_name_to_module + .get(&nested_module_name) + .map(|m| sanitize_module_name(&m.name)) + .unwrap_or_else(|| sanitize_module_name(&nested_module_name)); + namespace_member_namespace_prefixes.insert( + ($namespace_local.clone(), entry.name), + nested_prefix, + ); + } } - let importer = std::path::Path::new(&resolved_path_str); - let Some((ns_target, _)) = resolve_import( - ns_src, - importer, - &ctx.project_root, - &ctx.compile_packages, - &ctx.compile_package_dirs, - ) else { - break; - }; - let ns_target_str = ns_target.to_string_lossy().to_string(); - let Some(target_exports) = all_module_exports.get(&ns_target_str) - else { - break; - }; - namespace_imports.push(local_name.clone()); - // Issue #321: tag this local as a "named-import- - // of-namespace-reexport" so codegen's - // StaticMethodCall arm knows to route var-shape - // members through `js_closure_callN`. See the - // expr.rs StaticMethodCall comment for why this - // is scoped narrowly. - namespace_reexport_named_imports.insert(local_name.clone()); + } + if let Some(target_exports) = all_module_exports.get(target_path_str) { for (export_name, origin_path) in target_exports { let origin_prefix = compute_module_prefix(origin_path, &ctx.project_root); @@ -2612,13 +2887,13 @@ pub fn run_with_parse_cache( // `import_function_prefixes`, which the // last-registered namespace silently wins. namespace_member_prefixes.insert( - (local_name.clone(), export_name.clone()), + ($namespace_local.clone(), export_name.clone()), origin_prefix.clone(), ); // Issue #678: surface origin-name overrides // for the NamespaceReExport branch too. let resolved_origin_name = all_module_export_origin_names - .get(&ns_target_str) + .get(target_path_str) .and_then(|m| m.get(export_name)) .cloned(); if let Some(ref origin_name) = resolved_origin_name { @@ -2661,8 +2936,10 @@ pub fn run_with_parse_cache( // resolved origin name when renamed, // else the export name itself. namespace_member_origin_names.insert( - (local_name.clone(), export_name.clone()), - resolved_origin_name.unwrap_or_else(|| export_name.clone()), + ($namespace_local.clone(), export_name.clone()), + resolved_origin_name + .clone() + .unwrap_or_else(|| export_name.clone()), ); let key = (origin_path.clone(), export_name.clone()); @@ -2677,27 +2954,18 @@ pub fn run_with_parse_cache( if exported_func_synthetic_arguments.contains(&key) { imported_synthetic_arguments.insert(export_name.clone()); } - // Issue #321: NamespaceReExport members - // that are var-shaped exports (the - // canonical `export const succeed = (v) => - // ...` shape in effect/Effect.ts and - // co-equivalent re-export hubs) must land - // in `imported_vars` so the codegen's - // StaticMethodCall and namespace-member - // call sites route through the zero-arg - // getter + `js_closure_callN`. Without - // this, `import { Effect } from "effect"; - // Effect.succeed(42)` emitted a 1-arg - // direct call against the 0-arg getter - // — the source returned the closure - // pointer unchanged and `typeof - // Effect.succeed(42)` was `"function"`, - // and `runSync(program)` then threw - // `Cannot read properties of undefined` - // on `program._tag`. Mirrors the - // `Namespace { local }` branch above. - if exported_var_names.contains(&key) { + let origin_key_under_origin_name = resolved_origin_name + .as_ref() + .map(|n| (origin_path.clone(), n.clone())); + if exported_var_names.contains(&key) + || origin_key_under_origin_name + .as_ref() + .map(|k| exported_var_names.contains(k)) + .unwrap_or(false) + { imported_vars.insert(export_name.clone()); + namespace_member_vars + .insert(($namespace_local.clone(), export_name.clone())); } if let Some(class) = exported_classes.get(&key) { let class_prefix = canonical_class_source_prefix( @@ -2716,6 +2984,13 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: + class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &key.0, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -2777,6 +3052,124 @@ pub fn run_with_parse_cache( imported_enums.push((export_name.clone(), members.clone())); } } + } + }}; + } + if let Some(src_hir) = source_module { + let direct_namespace_source = src_hir.imports.iter().find_map(|import| { + import.specifiers.iter().find_map(|spec| match spec { + perry_hir::ImportSpecifier::Namespace { local: ns_local } + if ns_local == &exported_name => + { + import.resolved_path.as_ref() + } + _ => None, + }) + }); + if let Some(namespace_source) = direct_namespace_source { + let resolved_key = + normalize_namespace_path(PathBuf::from(namespace_source)); + if let Some(target_mod) = ctx.native_modules.get(&resolved_key) { + namespace_imports.push(local_name.clone()); + namespace_import_prefixes + .insert(local_name.clone(), sanitize_name(&target_mod.name)); + register_namespace_export_surface!(local_name, namespace_source); + continue; + } + } + + let lookup = |name: &str| module_name_to_module.get(name); + let transitive_namespace_source = + perry_hir::flatten_exports(&src_hir.name, &lookup) + .into_iter() + .find(|entry| { + entry.name == exported_name + && entry.nested_namespace_of.is_some() + }) + .and_then(|entry| entry.nested_namespace_of); + if let Some(namespace_module_name) = transitive_namespace_source { + if let Some(namespace_path) = module_name_to_path.get(&namespace_module_name) + { + if let Some(target_mod) = ctx.native_modules.get(namespace_path) { + namespace_imports.push(local_name.clone()); + namespace_import_prefixes.insert( + local_name.clone(), + sanitize_name(&target_mod.name), + ); + let namespace_path_str = namespace_path.to_string_lossy(); + register_namespace_export_surface!( + local_name, + namespace_path_str.as_ref() + ); + continue; + } + } + } + + for export in &src_hir.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + if exported != &exported_name { + continue; + } + let namespace_source = src_hir.imports.iter().find_map(|import| { + import.specifiers.iter().find_map(|spec| match spec { + perry_hir::ImportSpecifier::Namespace { local: ns_local } + if ns_local == local => + { + import.resolved_path.as_ref() + } + _ => None, + }) + }); + let Some(namespace_source) = namespace_source else { + continue; + }; + let resolved_key = + normalize_namespace_path(PathBuf::from(namespace_source)); + let Some(target_mod) = ctx.native_modules.get(&resolved_key) else { + continue; + }; + namespace_imports.push(local_name.clone()); + namespace_import_prefixes + .insert(local_name.clone(), sanitize_name(&target_mod.name)); + register_namespace_export_surface!(local_name, namespace_source); + handled_as_namespace_reexport = true; + break; + } + if handled_as_namespace_reexport { + continue; + } + for export in &src_hir.exports { + if let perry_hir::Export::NamespaceReExport { + source: ns_src, + name, + } = export + { + if name != &exported_name { + continue; + } + let importer = std::path::Path::new(&resolved_path_str); + let Some((ns_target, _)) = resolve_import( + ns_src, + importer, + &ctx.project_root, + &ctx.compile_packages, + &ctx.compile_package_dirs, + ) else { + break; + }; + let ns_target_str = ns_target.to_string_lossy().to_string(); + namespace_imports.push(local_name.clone()); + // Issue #321: tag this local as a "named-import- + // of-namespace-reexport" so codegen's + // StaticMethodCall arm knows to route var-shape + // members through `js_closure_callN`. See the + // expr.rs StaticMethodCall comment for why this + // is scoped narrowly. + namespace_reexport_named_imports.insert(local_name.clone()); + register_namespace_export_surface!(local_name, ns_target_str.as_str()); handled_as_namespace_reexport = true; break; } @@ -2990,6 +3383,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &key.0, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -3061,6 +3460,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &key.0, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -3219,6 +3624,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &src_path, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -3692,6 +4103,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &src_path, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -3891,6 +4308,12 @@ pub fn run_with_parse_cache( .map(|c| c.params.len()) .unwrap_or(0), has_own_constructor: class.constructor.is_some(), + constructor_uses_new_target: class_chain_uses_new_target( + &exported_classes, + &class_canonical_path, + &src_path, + class, + ), constructor_has_rest: class .constructor .as_ref() @@ -3994,6 +4417,9 @@ pub fn run_with_parse_cache( namespace_v8_specifiers, namespace_member_prefixes, namespace_member_origin_names, + namespace_member_vars, + namespace_member_namespace_prefixes, + namespace_import_prefixes, emit_ir_only: bitcode_link, verify_native_regions, disable_buffer_fast_path, @@ -4024,10 +4450,10 @@ pub fn run_with_parse_cache( fast_math: ctx.fast_math, fp_contract_mode: ctx.fp_contract_mode, app_metadata: ctx.app_metadata.clone(), - // Issue #100: namespace_entries empty unless this - // module is a dynamic-import target; the consumer-side - // dispatch map is empty unless this module performs - // dynamic imports. + // Namespace entries are emitted for modules whose namespace is + // observable: dynamic import targets, `import * as` targets, + // and `export * as` nested targets. The dynamic dispatch map is + // still limited to modules that actually perform dynamic imports. namespace_entries: per_module_namespace_entries .get(path) .cloned() @@ -4039,14 +4465,11 @@ pub fn run_with_parse_cache( nextjs_path_init_modules, deferred_module_prefixes, module_init_deps, - // Issue #842: signal side-effect-only dynamic-import - // targets to codegen so it still emits - // `@__perry_ns_` + populator. `dyn_target_paths` - // is the authoritative set built from every consumer's - // `import.is_dynamic` resolved paths; `namespace_entries` - // alone is insufficient because it's empty when the - // target has no `export` statements. - is_dynamic_import_target: dyn_target_paths.contains(path), + // Issue #842 plus static namespace imports/re-exports: signal + // namespace-producing modules even when the entry list is empty. + // The field name is historical from dynamic import support, but + // the codegen behavior is "emit namespace global + populator". + is_dynamic_import_target: namespace_target_paths.contains(path), // #5247: source-location tracking for the dynamic call-dispatch // throw path. Gated by `--debug-symbols` so the default build is // unchanged (no source read, no per-call emission). When on, read diff --git a/direct b/direct new file mode 100755 index 000000000..94591b244 Binary files /dev/null and b/direct differ diff --git a/node_trace.1.log b/node_trace.1.log new file mode 100644 index 000000000..7c6981516 --- /dev/null +++ b/node_trace.1.log @@ -0,0 +1 @@ +{"traceEvents":[{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}},{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}},{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}},{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}},{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}},{"pid":78832,"tid":259,"ts":1049338985970,"tts":54122,"ph":"M","cat":"__metadata","name":"process_name","dur":0,"tdur":0,"args":{"name":"node"}},{"pid":78832,"tid":259,"ts":1049338985976,"tts":54128,"ph":"M","cat":"__metadata","name":"version","dur":0,"tdur":0,"args":{"node":"24.16.0"}},{"pid":78832,"tid":259,"ts":1049338985977,"tts":54129,"ph":"M","cat":"__metadata","name":"thread_name","dur":0,"tdur":0,"args":{"name":"JavaScriptMainThread"}},{"pid":78832,"tid":259,"ts":1049338985987,"tts":54139,"ph":"M","cat":"__metadata","name":"node","dur":0,"tdur":0,"args":{"process":{"versions":{"acorn":"8.16.0","ada":"3.4.4","amaro":"1.1.9","ares":"1.34.6","brotli":"1.2.0","cldr":"48.0","icu":"78.3","llhttp":"9.4.1","merve":"1.2.2","modules":"137","napi":"10","nbytes":"0.1.4","ncrypto":"0.0.1","nghttp2":"1.68.0","nghttp3":"","ngtcp2":"","node":"24.16.0","openssl":"3.5.6","simdjson":"4.6.1","simdutf":"6.4.0","sqlite":"3.53.0","tz":"2026b","undici":"7.25.0","unicode":"17.0","uv":"1.52.1","uvwasi":"0.0.23","v8":"13.6.233.17-node.49","zlib":"1.3.1-e00f703","zstd":"1.5.7"},"arch":"arm64","platform":"darwin","release":{"name":"node","lts":"Krypton"}}}}]} \ No newline at end of file diff --git a/test-files/test_parity_bigint_switch.ts b/test-files/test_parity_bigint_switch.ts new file mode 100644 index 000000000..daa92ccd9 --- /dev/null +++ b/test-files/test_parity_bigint_switch.ts @@ -0,0 +1,14 @@ +// Parity: `switch` on a BigInt must match `case Nn` by value, not identity. +function classify(x: bigint): string { + switch (x) { + case 0n: return "zero"; + case 1n: return "one"; + case 9007199254740993n: return "big"; + default: return "other"; + } +} +console.log(classify(1n + 0n)); +console.log(classify(9007199254740992n + 1n)); +console.log(classify(5n)); +console.log(new Map([[2n, "two"]]).get(1n + 1n) ?? "miss"); +console.log([3n].includes(1n + 2n)); diff --git a/test-files/test_parity_builtin_subclass_fields.ts b/test-files/test_parity_builtin_subclass_fields.ts new file mode 100644 index 000000000..510c4634f --- /dev/null +++ b/test-files/test_parity_builtin_subclass_fields.ts @@ -0,0 +1,11 @@ +// Parity: instance field initializers run on builtin subclasses whose +// constructor is implicit (Error/Map/Set/Array), same as an explicit one. +class E1 extends Error { tag = "e1"; n = 1 + 2; } +class M1 extends Map { tag = "m1"; } +class S1 extends Set { tag = "s1"; } +class A1 extends Array { tag = "a1"; } +class M2 extends Map { tag = "m2"; constructor() { super(); } } +const e = new E1(); +console.log(e.tag, (e as any).n, e instanceof Error); +console.log(new M1().tag, new S1().tag, (new A1() as any).tag, new M2().tag); +try { throw new E1("boom"); } catch (x: any) { console.log(x.message, x.tag); } diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 288583d3b..a2449791a 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -199,5 +199,23 @@ "added": "2026-07-04", "category": "module-inventory", "reason": "node:readline surface (#3698+) \u2014 compile_fail in the 2026-07-03 baseline (tls/zlib/readline compile cluster, possibly runner env); standing per #5917." + }, + "test_gap_class_advanced": { + "issue": "5952", + "added": "2026-07-04", + "category": "bug-open", + "reason": "Regression tracked in #5952: a mixin factory that DIRECTLY returns `class extends {}` (dynamic parent) loses its binding - `const M = make(Base); typeof M` is undefined. Workaround: assign to a local first (`const C = ...; return C`). Passes at merge-base bfa0f258d; introduced by the class-expression/factory work in #5874. Fix belongs in the HIR factory-class-inlining pass (skip dynamic-parent mixins)." + }, + "test_gap_dynamic_import_node_builtin": { + "issue": null, + "added": "2026-07-04", + "category": "gap-categorical", + "reason": "Categorical AOT gap: `import(\"node:crypto\")` and other dynamic import() of a node builtin cannot be resolved in an ahead-of-time compiled binary (compiles to a deferred runtime rejection). Standing (compile-failed at merge-base bfa0f258d, now a graceful deferred error); NOT a regression of #5874. Main's #5947 triage missed it because it was compile-failing at the time." + }, + "test_gap_disposablestack_2875": { + "issue": "2875", + "added": "2026-07-04", + "category": "gap-categorical", + "reason": "Categorical feature gap: DisposableStack / using-declaration (TC39) not implemented \u2014 methods return undefined. Standing, unrelated to #5874's Zod/namespace/class work; fails on main too (missed by the #5947 standing triage)." } } diff --git a/tests/_perry_test_lib.sh b/tests/_perry_test_lib.sh new file mode 100644 index 000000000..f77253011 --- /dev/null +++ b/tests/_perry_test_lib.sh @@ -0,0 +1,87 @@ +# Shared harness for the tests/test_*.sh regression suite. +# +# SOURCED, never executed — the leading underscore keeps it out of +# run_tests.sh's `tests/test_*.sh` glob (same convention as the fixture +# harness's _fixture_lib.sh). It centralizes the perry-binary + runtime-lib +# detection that every regression test would otherwise copy-paste, so a change +# to the compile invocation lives in one place instead of ~130 files. +# +# Usage — single source file, literal-oracle: +# source "$(dirname "$0")/_perry_test_lib.sh" +# perry_run main.ts <<'TS' +# console.log("hi"); +# TS +# perry_expect 'hi' +# perry_pass "greeting" +# +# Usage — compare against Node instead of a literal: +# perry_run main.ts <<'TS' ... TS +# perry_expect_node # SKIPs cleanly if node is absent +# +# Usage — multi-module: write extra files first, then compile the entry: +# perry_write dep.ts <<'TS' ... TS +# perry_run main.ts <<'TS' ... TS # main.ts imports ./dep + +set -euo pipefail + +_PT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)" +REPO_ROOT="$(cd "$_PT_SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +[[ -x "$PERRY" ]] || PERRY="$REPO_ROOT/target/debug/perry" +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +_PT_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$_PT_TMPDIR"' EXIT +_PT_SRC="" + +# perry_write : write a source file into the temp dir from stdin. +perry_write() { + cat > "$_PT_TMPDIR/$1" +} + +# perry_run : write from stdin, then compile and run it, +# capturing stdout to run.log. Write any extra modules with perry_write first. +perry_run() { + _PT_SRC="$_PT_TMPDIR/$1" + cat > "$_PT_SRC" + local bin="$_PT_TMPDIR/out" + local args=(compile --no-cache) + # Link the prebuilt static libs only from the SAME target dir as the + # selected perry binary, so a release binary never pairs with debug libs + # (or vice versa). If that dir has no libs, leave PERRY_RUNTIME_DIR unset + # and let perry build/link its own runtime. + local perry_dir; perry_dir="$(cd "$(dirname "$PERRY")" && pwd)" + if [[ -f "$perry_dir/libperry_runtime.a" && -f "$perry_dir/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$perry_dir"; args+=(--no-auto-optimize) + fi + "$PERRY" "${args[@]}" "$_PT_SRC" -o "$bin" > "$_PT_TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed"; sed 's/^/ /' "$_PT_TMPDIR/compile.log" | tail -80; exit 1 + } + "$bin" > "$_PT_TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed"; sed 's/^/ /' "$_PT_TMPDIR/run.log" | tail -80; exit 1 + } +} + +# perry_expect [literal] : diff run.log against the expected output. With an +# argument, expects that single line; with no argument, reads the expected +# (possibly multi-line) output from stdin — e.g. perry_expect <<'EOF' ... EOF. +perry_expect() { + if [[ $# -gt 0 ]]; then + printf '%s\n' "$1" > "$_PT_TMPDIR/expected.log" + else + cat > "$_PT_TMPDIR/expected.log" + fi + diff -u "$_PT_TMPDIR/expected.log" "$_PT_TMPDIR/run.log" || { echo "FAIL: output mismatch"; exit 1; } +} + +# perry_expect_node : diff run.log against `node `; SKIP if node absent. +perry_expect_node() { + command -v node >/dev/null 2>&1 || { echo "SKIP: node binary not found"; exit 0; } + node "$_PT_SRC" > "$_PT_TMPDIR/expected.log" + diff -u "$_PT_TMPDIR/expected.log" "$_PT_TMPDIR/run.log" || { echo "FAIL: output mismatch vs node"; exit 1; } +} + +perry_pass() { echo "PASS: $1"; } diff --git a/tests/release/packages/zod3-basic/entry.ts b/tests/release/packages/zod3-basic/entry.ts new file mode 100644 index 000000000..e4500891c --- /dev/null +++ b/tests/release/packages/zod3-basic/entry.ts @@ -0,0 +1,1836 @@ +import zDefault, { z } from "zod"; +import * as zNamespace from "zod"; +import z3Default, { z as z3 } from "zod/v3"; +import * as z3Namespace from "zod/v3"; + +function print(label: string, value: unknown): void { + console.log(`${label}=${JSON.stringify(value)}`); +} + +function summarizeIssues(error: z.ZodError): { path: string; code: string; message: string }[] { + return error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })); +} + +function issueMetadata(error: z.ZodError) { + return error.issues.map((issue) => { + const metadata: Record = { + code: issue.code, + path: issue.path.join("."), + }; + for (const key of [ + "expected", + "received", + "keys", + "options", + "validation", + "minimum", + "maximum", + "inclusive", + "exact", + "multipleOf", + "type", + "params", + ]) { + if (key in issue) { + metadata[key] = issue[key as keyof typeof issue]; + } + } + return metadata; + }); +} + +function issueMetadataFromResult(result: z.SafeParseReturnType) { + return result.success ? [] : issueMetadata(result.error); +} + +async function issueMetadataFromThrown(call: () => Promise) { + try { + await call(); + return []; + } catch (error) { + return issueMetadata(error as z.ZodError); + } +} + +function thrownSummary(call: () => unknown) { + try { + call(); + return "ok"; + } catch (error) { + return error instanceof Error ? `${error.constructor.name}:${error.message.split("\n")[0]}` : String(error); + } +} + +const userSchema = z.object({ + id: z.number().int().positive(), + name: z.string().min(2).transform((value) => value.trim().toUpperCase()), + tags: z.array(z.string()).default([]), + role: z.enum(["admin", "user"]).default("user"), + meta: z.object({ active: z.boolean(), score: z.number().optional() }).passthrough(), +}); + +const parsed = userSchema.parse({ + id: 7, + name: " ada ", + meta: { active: true, source: "seed" }, +}); +print("parse", parsed); + +const badUser = userSchema.safeParse({ + id: -1, + name: "x", + tags: ["ok", 2], + meta: { active: "yes" }, +}); +print("safeParse.success", badUser.success); +if (!badUser.success) { + print("safeParse.issues", summarizeIssues(badUser.error)); +} + +print("primitives", { + string: z.string().parse("x"), + number: z.number().parse(1.5), + int: z.number().int().safeParse(2.2).success, + boolean: z.boolean().parse(false), + bigint: z.bigint().parse(10n).toString(), + symbol: typeof z.symbol().parse(Symbol.for("fixture")), + nan: Number.isNaN(z.nan().parse(NaN)), + void: z.void().parse(undefined) === undefined, + null: z.null().parse(null), + undefined: z.undefined().parse(undefined) === undefined, + any: z.any().parse({ a: 1 }).a, + unknown: z.unknown().parse([1, 2]).length, + never: z.never().safeParse("nope").success, +}); + +print("packageExports", { + parsed: { + string: z.getParsedType("x"), + nan: z.getParsedType(NaN), + map: z.getParsedType(new Map()), + promise: z.getParsedType(Promise.resolve(1)), + }, + status: { + ok: z.OK(1), + dirty: z.DIRTY(2), + invalid: z.INVALID, + isValid: z.isValid(z.OK(1)), + isDirty: z.isDirty(z.DIRTY(2)), + isAborted: z.isAborted(z.INVALID), + }, + parsedType: { + string: z.ZodParsedType.string, + nan: z.ZodParsedType.nan, + map: z.ZodParsedType.map, + promise: z.ZodParsedType.promise, + }, + typeKind: { + object: z.ZodFirstPartyTypeKind.ZodObject, + pipeline: z.ZodFirstPartyTypeKind.ZodPipeline, + readonly: z.ZodFirstPartyTypeKind.ZodReadonly, + }, + aliases: { + schema: z.Schema === z.ZodType, + schemaInstance: z.string() instanceof z.Schema, + brand: typeof z.BRAND, + brandDescription: z.BRAND.description, + defaultEqualsNamed: zDefault === z, + namespaceHasZ: zNamespace.z === z, + namespaceDefaultEqualsNamed: zNamespace.default === z, + namespaceString: zNamespace.string().parse("namespace"), + }, + names: [z.ZodString.name, z.ZodNumber.name, z.ZodObject.name, z.ZodError.name], +}); + +const parseStatus = new z.ParseStatus(); +parseStatus.dirty(); +const parseStatusMerged = z.ParseStatus.mergeArray(parseStatus, [z.OK("a"), z.DIRTY("b")]); +parseStatus.abort(); +const datetimeWithOffset = z.datetimeRegex({ offset: true, precision: 3 }); +const validStatus = z.OK("valid"); +const dirtyStatus = z.DIRTY("dirty"); +const abortedStatus = z.INVALID; +print("packageUtilities", { + isAsync: [z.isAsync(Promise.resolve(1)), z.isAsync(1)], + parseStatus: { + dirty: parseStatusMerged.status, + merged: parseStatusMerged.value, + aborted: parseStatus.value, + }, + parsedTypes: [ + z.getParsedType(null), + z.getParsedType([]), + z.getParsedType(new Map()), + z.getParsedType(1n), + z.getParsedType(Promise.resolve(1)), + z.getParsedType(NaN), + ], + statusHelpers: { + valid: [validStatus.status, z.isValid(validStatus), z.isDirty(validStatus), z.isAborted(validStatus)], + dirty: [dirtyStatus.status, z.isValid(dirtyStatus), z.isDirty(dirtyStatus), z.isAborted(dirtyStatus)], + aborted: [abortedStatus.status, z.isValid(abortedStatus), z.isDirty(abortedStatus), z.isAborted(abortedStatus)], + never: z.NEVER.status, + emptyPathLength: z.EMPTY_PATH.length, + }, + util: { + arrayToEnum: z.util.arrayToEnum(["a", "b"]).b, + validEnumValues: z.util.getValidEnumValues({ A: "a", B: 1, 1: "B" }), + joinValues: z.util.joinValues(["a", 1, true]), + objectKeys: z.util.objectKeys({ b: 1, a: 2 }).join("|"), + objectValues: z.util.objectValues({ b: 1, a: 2 }), + find: z.util.find([1, 2, 3], (value) => value > 1), + isInteger: [z.util.isInteger(1), z.util.isInteger(1.5)], + jsonStringifyReplacer: JSON.stringify({ n: 1n }, z.util.jsonStringifyReplacer), + }, + quoteless: z.quotelessJson({ a: "x", nested: { b: 1 }, arr: [true, null] }).split("\n").length, + datetimeRegex: [ + datetimeWithOffset.test("2020-01-02T03:04:05.123Z"), + datetimeWithOffset.test("2020-01-02T03:04:05Z"), + ], + defaultError: z.defaultErrorMap( + { code: z.ZodIssueCode.invalid_type, expected: "string", received: "number", path: [] }, + { data: 1, defaultError: "fallback" }, + ).message, +}); + +const directIssue = z.makeIssue({ + data: 1, + path: ["root"], + issueData: { code: z.ZodIssueCode.custom, path: ["leaf"], message: "direct" }, + errorMaps: [z.defaultErrorMap], +}); +const mappedIssue = z.makeIssue({ + data: "bad", + path: ["root"], + issueData: { code: z.ZodIssueCode.invalid_type, expected: "number", received: "string" }, + errorMaps: [z.defaultErrorMap], +}); +const parseContext = { + common: { issues: [], async: false }, + path: ["ctx"], + schemaErrorMap: undefined, + parent: null, + data: "bad", + parsedType: z.ZodParsedType.string, +} as any; +z.addIssueToContext(parseContext, { + code: z.ZodIssueCode.invalid_type, + expected: "number", + received: "string", +}); +const transformerSchema = z.ZodTransformer.create(z.string(), { + type: "transform", + transform: (value: string) => value.length, +}); +print("parseHelpers", { + direct: { code: directIssue.code, path: directIssue.path.join("."), message: directIssue.message }, + mapped: { path: mappedIssue.path.join("."), message: mappedIssue.message }, + context: parseContext.common.issues.map((issue: z.ZodIssue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + aliases: { + schema: z.string() instanceof z.ZodSchema, + type: z.string() instanceof z.ZodType, + transformer: transformerSchema instanceof z.ZodTransformer, + transformerParse: transformerSchema.parse("tuna"), + }, +}); + +print("coerce", { + string: z.coerce.string().parse(42), + number: z.coerce.number().parse("12.5"), + boolean: z.coerce.boolean().parse(1), + bigint: z.coerce.bigint().parse("42").toString(), + date: z.coerce.date().parse("2020-01-02T00:00:00.000Z").toISOString(), +}); + +print("primitiveEdges", { + boolean: z.boolean().parse(true), + booleanRejectsString: z.boolean().safeParse("true").success, + coerceBooleanString: z.coerce.boolean().parse("false"), + coerceNumberRejectsNaN: z.coerce.number().safeParse("not-a-number").success, + coerceBigintRejectsText: z.coerce.bigint().safeParse("not-a-bigint").success, + coerceDateEpoch: z.coerce.date().parse(0).toISOString(), + coerceDateRejectsInvalid: z.coerce.date().safeParse("not-a-date").success, +}); + +const boundedDateSchema = z.date() + .min(new Date("2020-01-01T00:00:00.000Z"), "too old") + .max(new Date("2020-12-31T00:00:00.000Z"), "too new"); +const primitiveInstanceSchema = z.instanceof(Date); +print("primitiveMetadata", { + types: [ + z.string()._def.typeName, + z.number()._def.typeName, + z.bigint()._def.typeName, + z.boolean()._def.typeName, + z.nan()._def.typeName, + z.symbol()._def.typeName, + z.any()._def.typeName, + z.unknown()._def.typeName, + z.never()._def.typeName, + z.undefined()._def.typeName, + z.null()._def.typeName, + z.void()._def.typeName, + ], + date: { + typeName: boundedDateSchema._def.typeName, + coerce: boundedDateSchema._def.coerce, + minDate: boundedDateSchema.minDate?.toISOString(), + maxDate: boundedDateSchema.maxDate?.toISOString(), + checks: boundedDateSchema._def.checks.map((check) => ({ + kind: check.kind, + value: check.value, + message: check.message, + })), + }, + coerceDate: z.coerce.date()._def.coerce, + instanceof: { + typeName: primitiveInstanceSchema._def.typeName, + inner: primitiveInstanceSchema._def.schema.constructor.name, + effect: primitiveInstanceSchema._def.effect.type, + }, +}); + +const colorEnum = z.enum(["red", "blue", "green"]); +const literalReady = z.literal("ready"); +const nativeTextEnum = z.nativeEnum({ A: "a", B: "b" } as const); +const nativeMixedEnumObject = { Text: "text", Zero: 0, One: 1, 0: "Zero", 1: "One" } as const; +const nativeMixedEnum = z.nativeEnum(nativeMixedEnumObject); +const nativeMixedReverseResult = nativeMixedEnum.safeParse("Zero"); +const enumRequiredResult = z + .enum(["red", "blue"], { required_error: "need color", invalid_type_error: "bad color" }) + .safeParse(undefined); +const enumInvalidTypeResult = z + .enum(["red", "blue"], { required_error: "need color", invalid_type_error: "bad color" }) + .safeParse(1); +print("literals.enums", { + literal: literalReady.safeParse("ready").success, + literalBigint: z.literal(2n).safeParse(2n).success, + literalBoolean: z.literal(true).safeParse(false).success, + literalNull: z.literal(null).parse(null), + literalFail: z.literal(3).safeParse(4).success, + literalValue: literalReady.value, + literalTypeName: literalReady._def.typeName, + enum: colorEnum.parse("blue"), + enumBlue: colorEnum.enum.blue, + enumValues: colorEnum.Values.green, + enumEnum: colorEnum.Enum.red, + enumOptions: colorEnum.options.join("|"), + enumTypeName: colorEnum._def.typeName, + enumDefValues: colorEnum._def.values.join("|"), + enumKeys: Object.keys(colorEnum.enum).sort(), + enumExtract: colorEnum.extract(["red", "green"]).safeParse("blue").success, + enumExtractOk: colorEnum.extract(["red", "green"]).parse("red"), + enumExtractOptions: colorEnum.extract(["red", "green"]).options.join("|"), + enumExclude: colorEnum.exclude(["blue"]).parse("green"), + enumExcludeOptions: colorEnum.exclude(["blue"]).options.join("|"), + enumRequiredMessage: enumRequiredResult.success ? "ok" : enumRequiredResult.error.issues[0].message, + enumInvalidTypeMessage: enumInvalidTypeResult.success ? "ok" : enumInvalidTypeResult.error.issues[0].message, + nativeEnum: nativeTextEnum.parse("a"), + nativeEnumNumber: z.nativeEnum({ A: 1, B: 2 } as const).parse(2), + nativeEnumMixed: [nativeMixedEnum.parse("text"), nativeMixedEnum.parse(0), nativeMixedEnum.parse(1)], + nativeEnumReverseRejected: issueMetadataFromResult(nativeMixedReverseResult), + nativeEnumTypeName: nativeTextEnum._def.typeName, + nativeEnumKeys: Object.keys(nativeTextEnum.enum).join("|"), + nativeEnumMixedKeys: Object.keys(nativeMixedEnum.enum).join("|"), +}); + +const stringSchema = z + .string() + .min(3) + .max(8) + .regex(/^[a-z-]+$/) + .startsWith("ab") + .endsWith("yz") + .includes("-") + .trim() + .toUpperCase(); +print("strings", { + value: stringSchema.parse("ab-yz"), + email: z.string().email().safeParse("a@example.com").success, + uuid: z.string().uuid().safeParse("550e8400-e29b-41d4-a716-446655440000").success, + url: z.string().url().safeParse("https://example.com/a?b=1").success, + datetime: z.string().datetime().safeParse("2020-01-02T03:04:05.000Z").success, + datetimeOffset: z.string().datetime({ offset: true }).safeParse("2020-01-02T03:04:05+02:00").success, + datetimePrecision: z.string().datetime({ precision: 3 }).safeParse("2020-01-02T03:04:05.123Z").success, + datetimeLocal: z.string().datetime({ local: true }).safeParse("2020-01-02T03:04:05").success, + ip: z.string().ip().safeParse("127.0.0.1").success, + ipv4: z.string().ip({ version: "v4" }).safeParse("127.0.0.1").success, + ipv6: z.string().ip({ version: "v6" }).safeParse("::1").success, + length: z.string().length(3).safeParse("abc").success, + nonempty: z.string().nonempty().safeParse("").success, + timePrecision: z.string().time({ precision: 0 }).safeParse("03:04:05.123").success, + includesPosition: z.string().includes("b", { position: 1 }).safeParse("abc").success, + includesPositionFail: z.string().includes("b", { position: 2 }).safeParse("abc").success, + startsWith: z.string().startsWith("tu").safeParse("tuna").success, + startsWithFail: z.string().startsWith("tu").safeParse("fish").success, + endsWith: z.string().endsWith("na").safeParse("tuna").success, + endsWithFail: z.string().endsWith("na").safeParse("tune").success, + trim: z.string().trim().parse(" tuna "), + uppercase: z.string().toUpperCase().parse("tuna"), + lowercase: z.string().toLowerCase().parse("ABC"), + chainedTransform: z.string().trim().toUpperCase().startsWith("TU").endsWith("NA").parse(" tuna "), + cuid: z.string().cuid().safeParse("ckj8lp2e90000v4j5x6j8s9abc").success, + cuid2: z.string().cuid2().safeParse("tz4a98xxat96iws9zmbrgj3a").success, + ulid: z.string().ulid().safeParse("01ARZ3NDEKTSV4RRFFQ69G5FAV").success, + emoji: z.string().emoji().safeParse("😀").success, + nanoid: z.string().nanoid().safeParse("V1StGXR8_Z5jdHi6B-myT").success, + base64: z.string().base64().safeParse("aGVsbG8=").success, + base64url: z.string().base64url().safeParse("aGVsbG8").success, + jwt: z.string().jwt().safeParse("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature").success, + date: z.string().date().safeParse("2020-01-02").success, + time: z.string().time().safeParse("03:04:05").success, + duration: z.string().duration().safeParse("P1Y2M3DT4H5M6S").success, + cidr: z.string().cidr().safeParse("192.168.0.0/24").success, + cidrv4: z.string().cidr({ version: "v4" }).safeParse("192.168.0.0/24").success, + cidrv6: z.string().cidr({ version: "v6" }).safeParse("2001:db8::/32").success, + regexFail: z.string().regex(/^[a-z]+$/).safeParse("abc1").success, +}); + +const boundedString = z.string().min(2).max(5); +const formattedStrings = { + email: z.string().email(), + url: z.string().url(), + uuid: z.string().uuid(), + datetime: z.string().datetime(), + ip: z.string().ip(), +}; +const extraFormattedStrings = { + base64: z.string().base64(), + base64url: z.string().base64url(), + cidr: z.string().cidr(), + cuid: z.string().cuid(), + cuid2: z.string().cuid2(), + date: z.string().date(), + duration: z.string().duration(), + emoji: z.string().emoji(), + nanoid: z.string().nanoid(), + time: z.string().time(), + ulid: z.string().ulid(), +}; +const boundedNumber = z.number().int().min(1).max(3).finite(); +print("schemaIntrospection", { + stringBounds: [boundedString.minLength, boundedString.maxLength], + stringFormats: Object.fromEntries( + Object.entries(formattedStrings).map(([name, schema]) => [ + name, + { + isEmail: schema.isEmail, + isURL: schema.isURL, + isUUID: schema.isUUID, + isDatetime: schema.isDatetime, + isIP: schema.isIP, + }, + ]), + ), + extraStringFormats: { + base64: extraFormattedStrings.base64.isBase64, + base64url: extraFormattedStrings.base64url.isBase64url, + cidr: extraFormattedStrings.cidr.isCIDR, + cuid: extraFormattedStrings.cuid.isCUID, + cuid2: extraFormattedStrings.cuid2.isCUID2, + date: extraFormattedStrings.date.isDate, + duration: extraFormattedStrings.duration.isDuration, + emoji: extraFormattedStrings.emoji.isEmoji, + nanoid: extraFormattedStrings.nanoid.isNANOID, + time: extraFormattedStrings.time.isTime, + ulid: extraFormattedStrings.ulid.isULID, + }, + numberBounds: [boundedNumber.minValue, boundedNumber.maxValue], + numberFlags: { isInt: boundedNumber.isInt, isFinite: boundedNumber.isFinite }, +}); + +print("numbers", { + finite: z.number().finite().safeParse(Number.POSITIVE_INFINITY).success, + min: z.number().min(2).safeParse(2).success, + max: z.number().max(2).safeParse(3).success, + gt: z.number().gt(1).safeParse(2).success, + gte: z.number().gte(2).safeParse(2).success, + lt: z.number().lt(3).safeParse(3).success, + lte: z.number().lte(3).safeParse(3).success, + multiple: z.number().multipleOf(5).safeParse(15).success, + step: z.number().step(0.5).safeParse(1.5).success, + positive: z.number().positive().safeParse(1).success, + nonpositive: z.number().nonpositive().safeParse(0).success, + negative: z.number().negative().safeParse(-1).success, + nonnegative: z.number().nonnegative().safeParse(0).success, + safe: z.number().safe().safeParse(Number.MAX_SAFE_INTEGER + 1).success, +}); + +print("bigints", { + min: z.bigint().min(2n).safeParse(2n).success, + max: z.bigint().max(2n).safeParse(3n).success, + gt: z.bigint().gt(1n).safeParse(2n).success, + gte: z.bigint().gte(2n).safeParse(2n).success, + lt: z.bigint().lt(3n).safeParse(3n).success, + lte: z.bigint().lte(3n).safeParse(3n).success, + multiple: z.bigint().multipleOf(5n).safeParse(15n).success, + positive: z.bigint().positive().safeParse(1n).success, + nonpositive: z.bigint().nonpositive().safeParse(0n).success, + negative: z.bigint().negative().safeParse(-1n).success, + nonnegative: z.bigint().nonnegative().safeParse(0n).success, +}); + +const eventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), value: z.string() }), + z.object({ type: z.literal("count"), value: z.number().int() }), +]); +const multiValueDiscriminatedSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.enum(["text", "markdown"]), body: z.string() }), + z.object({ kind: z.literal("count"), value: z.number() }), +]); +const multiValueInvalidKind = multiValueDiscriminatedSchema.safeParse({ kind: "image", body: "x" }); +const multiValueInvalidBranch = multiValueDiscriminatedSchema.safeParse({ kind: "text", body: 1 }); +const inheritedDiscriminatedInput = Object.create({ type: "count", value: 3 }); +inheritedDiscriminatedInput.extra = "own"; +const inheritedDiscriminatedParsed = eventSchema.parse(inheritedDiscriminatedInput); +const inheritedDiscriminatedInvalidInput = Object.create({ type: "count", value: "bad" }); +inheritedDiscriminatedInvalidInput.extra = "own"; +const objectUnionSchema = z.union([ + z.object({ kind: z.literal("a"), value: z.string() }), + z.object({ kind: z.literal("b"), count: z.number() }), +]); +const unionBranchFailure = objectUnionSchema.safeParse({ kind: "a", value: 1 }); +const inheritedUnionInput = Object.create({ kind: "b", count: 2 }); +inheritedUnionInput.extra = "own"; +const inheritedUnionParsed = objectUnionSchema.parse(inheritedUnionInput); +print("discriminatedUnion", [ + eventSchema.parse({ type: "text", value: "hello" }), + eventSchema.parse({ type: "count", value: 3 }), +]); +print("discriminatedUnion.options", { + length: eventSchema.options.length, + discriminator: eventSchema.discriminator, + optionKeys: Array.from(eventSchema.optionsMap.keys()).join("|"), + typeName: eventSchema._def.typeName, + inherited: inheritedDiscriminatedParsed, + inheritedOwnType: Object.prototype.hasOwnProperty.call(inheritedDiscriminatedParsed, "type"), + inheritedOwnValue: Object.prototype.hasOwnProperty.call(inheritedDiscriminatedParsed, "value"), + inheritedInvalid: issueMetadataFromResult(eventSchema.safeParse(inheritedDiscriminatedInvalidInput)), + multiValue: [ + multiValueDiscriminatedSchema.parse({ kind: "text", body: "hello" }), + multiValueDiscriminatedSchema.parse({ kind: "markdown", body: "**hello**" }), + ], + multiValueOptionKeys: Array.from(multiValueDiscriminatedSchema.optionsMap.keys()).join("|"), + multiValueInvalidKind: issueMetadataFromResult(multiValueInvalidKind), + multiValueInvalidBranch: issueMetadataFromResult(multiValueInvalidBranch), +}); + +const primitiveSchema = z.union([z.string().regex(/^id-/), z.number().int()]); +print("union", { + results: [primitiveSchema.safeParse("id-42").success, primitiveSchema.safeParse(4.5).success], + options: primitiveSchema.options.length, + typeName: primitiveSchema._def.typeName, + optionTypes: primitiveSchema._def.options.map((option) => option.constructor.name), + branchFailure: unionBranchFailure.success + ? [] + : unionBranchFailure.error.issues[0].unionErrors.map((error) => + error.issues.map((issue) => `${issue.path.join(".")}:${issue.code}`), + ), + inheritedObject: inheritedUnionParsed, + inheritedObjectOwnKind: Object.prototype.hasOwnProperty.call(inheritedUnionParsed, "kind"), + inheritedObjectOwnCount: Object.prototype.hasOwnProperty.call(inheritedUnionParsed, "count"), +}); + +const objectBase = z.object({ id: z.number(), name: z.string(), active: z.boolean().optional() }); +const nestedObject = z.object({ nested: z.object({ label: z.string() }) }); +const defaultedOptionalObject = z.object({ defaulted: z.string().default("tuna").optional() }); +const maskedObjectBase = z.object({ + requiredName: z.string(), + optionalAge: z.number().optional(), + nested: z.object({ label: z.string() }), +}); +const maskedPartialObject = maskedObjectBase.partial({ requiredName: true }); +const maskedRequiredObject = maskedPartialObject.required({ requiredName: true }); +const inheritedObjectInput = Object.create({ inherited: "from-proto" }); +inheritedObjectInput.own = 1; +const inheritedObjectParsed = z.object({ own: z.number(), inherited: z.string() }).parse(inheritedObjectInput); +const inheritedPassthroughInput = Object.create({ inheritedExtra: "from-proto" }); +inheritedPassthroughInput.id = 1; +const inheritedPassthroughParsed = z.object({ id: z.number() }).passthrough().parse(inheritedPassthroughInput); +const nullProtoInput = Object.create(null) as Record; +nullProtoInput.id = 1; +nullProtoInput.extra = "x"; +const nullProtoPassthroughParsed = z.object({ id: z.number() }).passthrough().parse(nullProtoInput); +const nullProtoStrippedParsed = z.object({ id: z.number() }).parse(nullProtoInput); +let getterOwnReads = 0; +let getterInheritedReads = 0; +const getterInput = Object.create({ + get inherited() { + getterInheritedReads += 1; + return "from-getter"; + }, +}); +Object.defineProperty(getterInput, "id", { + enumerable: true, + get() { + getterOwnReads += 1; + return 7; + }, +}); +const getterObjectParsed = z.object({ id: z.number(), inherited: z.string() }).parse(getterInput); +const getterPassthroughParsed = z.object({ id: z.number() }).passthrough().parse(getterInput); +const partialObjectSchema = objectBase.partial(); +const partialNameObjectSchema = objectBase.partial({ name: true }); +const requiredObjectSchema = objectBase.required(); +const requiredActiveObjectSchema = objectBase.required({ active: true }); +const deepPartialObjectSchema = nestedObject.deepPartial(); +const objectShapeTypes = (schema: z.AnyZodObject) => + Object.fromEntries(Object.entries(schema.shape).map(([key, value]) => [key, value.constructor.name])); +const categorySchema: z.ZodType = z.object({ + name: z.string(), + get subcategories() { + return z.array(categorySchema); + }, +}); +const authorSchema: z.ZodType = z.object({ + email: z.string().email(), + get posts() { + return z.array(postSchema); + }, +}); +const postSchema: z.ZodType = z.object({ + title: z.string(), + get author() { + return authorSchema; + }, +}); +const recursiveEventSchema: z.ZodType = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("leaf"), value: z.string() }), + z.object({ + kind: z.literal("branch"), + get children() { + return z.array(recursiveEventSchema); + }, + }), +]); +print("objects", { + strip: objectBase.parse({ id: 1, name: "a", extra: true } as unknown), + explicitStrip: objectBase.strip().parse({ id: 1, name: "a", extra: true } as unknown), + strict: objectBase.strict().safeParse({ id: 1, name: "a", extra: true }).success, + strictObject: z.strictObject({ name: z.string() }).safeParse({ name: "a", extra: true }).success, + nonstrict: objectBase.nonstrict().parse({ id: 1, name: "a", extra: true } as unknown), + passthrough: objectBase.passthrough().parse({ id: 1, name: "a", extra: true } as unknown), + catchall: z.object({ id: z.number() }).catchall(z.string()).safeParse({ id: 1, extra: "ok" }).success, + catchallFail: z.object({ id: z.number() }).catchall(z.string()).safeParse({ id: 1, extra: 1 }).success, + extend: objectBase.extend({ role: z.literal("admin") }).parse({ id: 1, name: "a", role: "admin" }), + augment: z.object({ id: z.number() }).augment({ name: z.string() }).parse({ id: 1, name: "a" }), + setKey: z.object({ id: z.number() }).setKey("name", z.string()).parse({ id: 1, name: "a" }), + merge: objectBase.merge(z.object({ role: z.string() })).parse({ id: 1, name: "a", role: "user" }), + strictMessage: z.object({ id: z.number() }).strict("no extras").safeParse({ id: 1, extra: true }).success, + keyof: objectBase.keyof().parse("name"), + shapeName: objectBase.shape.name.safeParse("a").success, + pick: objectBase.pick({ id: true }).parse({ id: 1 }), + omit: objectBase.omit({ active: true }).parse({ id: 1, name: "a" }), + spreadShape: z.object({ ...objectBase.shape, role: z.string() }).parse({ + id: 1, + name: "a", + role: "user", + }), + inheritedInput: inheritedObjectInput, + inheritedOutput: inheritedObjectParsed, + inheritedOwnOutput: Object.prototype.hasOwnProperty.call(inheritedObjectParsed, "inherited"), + inheritedOwnInput: Object.prototype.hasOwnProperty.call(inheritedObjectInput, "inherited"), + inheritedOutputPrototype: Object.getPrototypeOf(inheritedObjectParsed) === Object.prototype, + inheritedPassthrough: inheritedPassthroughParsed, + inheritedPassthroughOwn: Object.prototype.hasOwnProperty.call(inheritedPassthroughParsed, "inheritedExtra"), + inheritedStrict: z.object({ id: z.number() }).strict().safeParse(inheritedPassthroughInput).success, + nullProto: nullProtoPassthroughParsed, + nullProtoStripped: nullProtoStrippedParsed, + nullProtoInputPrototype: Object.getPrototypeOf(nullProtoInput) === null, + nullProtoOutputPrototype: Object.getPrototypeOf(nullProtoPassthroughParsed) === Object.prototype, + nullProtoStrict: z.object({ id: z.number() }).strict().safeParse(nullProtoInput).success, + getterInput: getterObjectParsed, + getterPassthrough: getterPassthroughParsed, + getterReads: [getterOwnReads, getterInheritedReads], + getterOutputOwnInherited: Object.prototype.hasOwnProperty.call(getterObjectParsed, "inherited"), + partial: partialObjectSchema.parse({ id: 1 }), + partialName: partialNameObjectSchema.safeParse({ id: 1 }).success, + deepPartial: deepPartialObjectSchema.parse({ nested: {} }), + required: requiredObjectSchema.safeParse({ id: 1, name: "a" }).success, + requiredActive: requiredActiveObjectSchema.safeParse({ id: 1, name: "a" }).success, + nestedRequired: nestedObject.required().safeParse({ nested: { label: "x" } }).success, + partialRequiredMask: { + baseMissingName: maskedObjectBase.safeParse({ nested: { label: "x" } }).success, + partialMissingName: maskedPartialObject.safeParse({ nested: { label: "x" } }).success, + partialMissingNested: maskedPartialObject.safeParse({}).success, + requiredMissingName: maskedRequiredObject.safeParse({ nested: { label: "x" } }).success, + requiredMissingOptionalAge: maskedRequiredObject.safeParse({ requiredName: "x", nested: { label: "x" } }).success, + base: objectShapeTypes(maskedObjectBase), + partial: objectShapeTypes(maskedPartialObject), + required: objectShapeTypes(maskedRequiredObject), + partialNameInner: maskedPartialObject.shape.requiredName._def.innerType.constructor.name, + }, + defaultedOptionalEmpty: defaultedOptionalObject.parse({}), + defaultedOptionalUndefined: defaultedOptionalObject.parse({ defaulted: undefined }), + anyUnknownMissing: z.object({ a: z.any(), b: z.unknown() }).safeParse({}).success, + unknownKeys: [ + objectBase._def.unknownKeys, + objectBase.passthrough()._def.unknownKeys, + objectBase.strict()._def.unknownKeys, + objectBase.strip()._def.unknownKeys, + ], + catchallType: z.object({ id: z.number() }).catchall(z.string())._def.catchall.constructor.name, + strictCreate: z.ZodObject.strictCreate({ id: z.number() }).safeParse({ id: 1, extra: true }).success, + strictCreateParse: z.ZodObject.strictCreate({ id: z.number() }).parse({ id: 1 }).id, + lazycreate: z.ZodObject.lazycreate(() => ({ name: z.string() })).parse({ name: "lazy" }).name, + shapeMetadata: { + partial: objectShapeTypes(partialObjectSchema), + partialName: objectShapeTypes(partialNameObjectSchema), + partialActiveInner: partialObjectSchema.shape.active._def.innerType.constructor.name, + required: objectShapeTypes(requiredObjectSchema), + requiredActive: objectShapeTypes(requiredActiveObjectSchema), + deepPartial: [ + deepPartialObjectSchema.shape.nested.constructor.name, + deepPartialObjectSchema.shape.nested._def.innerType.shape.label.constructor.name, + ], + }, +}); + +const mergeLeftPassthrough = z.object({ a: z.string() }).passthrough(); +const mergeRightStrict = z.object({ b: z.number() }).strict(); +const mergeRightPassthrough = z.object({ b: z.number() }).passthrough(); +const mergeLeftCatchall = z.object({ a: z.string() }).catchall(z.string()); +const mergeRightCatchall = z.object({ b: z.number() }).catchall(z.number()); +const mergedStrict = mergeLeftPassthrough.merge(mergeRightStrict); +const mergedPassthrough = mergeRightStrict.merge(mergeLeftPassthrough); +const mergedCatchall = mergeLeftCatchall.merge(mergeRightCatchall); +print("objectMergeSemantics", { + extendOverwrite: z.object({ id: z.string() }).extend({ id: z.number() }).parse({ id: 1 }).id, + strictWinsUnknownKeys: mergedStrict._def.unknownKeys, + strictRejectsExtra: mergedStrict.safeParse({ a: "x", b: 1, extra: true }).success, + passthroughWinsUnknownKeys: mergedPassthrough._def.unknownKeys, + passthroughAllowsExtra: mergedPassthrough.safeParse({ a: "x", b: 1, extra: true }).success, + catchallWinsType: mergedCatchall._def.catchall.constructor.name, + catchallAcceptsNumber: mergedCatchall.safeParse({ a: "x", b: 1, extra: 2 }).success, + catchallRejectsString: mergedCatchall.safeParse({ a: "x", b: 1, extra: "x" }).success, + mergedShapeKeys: Object.keys(mergedCatchall.shape).join("|"), +}); + +print("recursiveObjects", { + category: categorySchema.parse({ name: "root", subcategories: [{ name: "leaf", subcategories: [] }] }), + categoryIssue: issueMetadataFromResult(categorySchema.safeParse({ name: "root", subcategories: [{ name: 1 }] })), + mutual: postSchema.parse({ title: "post", author: { email: "a@example.com", posts: [] } }).author.email, + mutualNested: postSchema.parse({ + title: "post", + author: { + email: "a@example.com", + posts: [{ title: "nested", author: { email: "b@example.com", posts: [] } }], + }, + }).author.posts[0].author.email, + discriminatedRecursive: recursiveEventSchema.parse({ kind: "branch", children: [{ kind: "leaf", value: "x" }] }), + discriminatedRecursiveIssue: issueMetadataFromResult( + recursiveEventSchema.safeParse({ kind: "branch", children: [{ kind: "leaf", value: 1 }] }), + ), +}); + +const arrayElementIssue = z.array(z.string()).safeParse([1]); +const tupleLengthIssue = z.tuple([z.string()]).safeParse(["a", "b"]); +const boundedArray = z.array(z.string()).min(1).max(3).length(2); +const tupleWithRest = z.tuple([z.string(), z.number()]).rest(z.boolean()); +const arrayMinMessage = z.array(z.string()).min(2, "need two").safeParse(["a"]); +const arrayMaxMessage = z.array(z.string()).max(1, "too many").safeParse(["a", "b"]); +const arrayLengthMessage = z.array(z.string()).length(2, "exactly two").safeParse(["a"]); +const sparseArrayInput = ["a", , "c"] as (string | undefined)[]; +const sparseOptionalArrayParsed = z.array(z.string().optional()).parse(sparseArrayInput); +const sparseRequiredArrayResult = z.array(z.string()).safeParse(sparseArrayInput); +const inheritedArrayInput = ["a", , "c"] as string[]; +let inheritedArrayParsed: string[] = []; +let inheritedTupleParsed: [string, string, string] | [] = []; +Object.defineProperty(Array.prototype, "1", { + configurable: true, + enumerable: true, + writable: true, + value: "proto", +}); +try { + inheritedArrayParsed = z.array(z.string()).parse(inheritedArrayInput); + inheritedTupleParsed = z.tuple([z.string(), z.string(), z.string()]).parse(inheritedArrayInput); +} finally { + delete (Array.prototype as unknown as Record)["1"]; +} +print("arrays.tuples", { + array: z.array(z.number()).min(2).max(3).parse([1, 2]), + transformedArray: z.array(z.string().transform((value) => value.length)).parse(["aa", "bbb"]), + exactLength: z.array(z.string()).length(2).safeParse(["a", "b"]).success, + nonempty: z.array(z.string()).nonempty().safeParse([]).success, + elementDescription: z.array(z.string().describe("array item")).element.description, + elementIssuePath: arrayElementIssue.success ? "ok" : arrayElementIssue.error.issues[0].path.join("."), + typeName: boundedArray._def.typeName, + bounds: [ + boundedArray._def.minLength?.value, + boundedArray._def.maxLength?.value, + boundedArray._def.exactLength?.value, + ], + elementType: boundedArray.element.constructor.name, + minMessage: arrayMinMessage.success ? "ok" : arrayMinMessage.error.issues[0].message, + maxMessage: arrayMaxMessage.success ? "ok" : arrayMaxMessage.error.issues[0].message, + lengthMessage: arrayLengthMessage.success ? "ok" : arrayLengthMessage.error.issues[0].message, + sparseOptional: sparseOptionalArrayParsed, + sparseOptionalOwn1: Object.prototype.hasOwnProperty.call(sparseOptionalArrayParsed, "1"), + sparseRequiredIssue: issueMetadataFromResult(sparseRequiredArrayResult), + inheritedIndexArray: inheritedArrayParsed, + inheritedIndexArrayOwn1: Object.prototype.hasOwnProperty.call(inheritedArrayParsed, "1"), + inheritedIndexTuple: inheritedTupleParsed, + inheritedIndexTupleOwn1: Object.prototype.hasOwnProperty.call(inheritedTupleParsed, "1"), + tuple: z.tuple([z.string(), z.number()]).parse(["a", 1]), + tupleLengthIssue: tupleLengthIssue.success ? "ok" : tupleLengthIssue.error.issues[0].code, + tupleRest: z.tuple([z.string()]).rest(z.number()).parse(["a", 1, 2]), + tupleItems: tupleWithRest.items.map((item) => item.constructor.name), + tupleRestType: tupleWithRest._def.rest?.constructor.name, + tupleTypeName: tupleWithRest._def.typeName, +}); + +const parsedMap = z.map(z.string(), z.number()).parse(new Map([["a", 1], ["b", 2]])); +const parsedSet = z.set(z.string()).min(2).parse(new Set(["a", "b"])); +const transformedMap = z + .map(z.string(), z.string().transform((value) => value.length)) + .parse(new Map([["size", "abcd"]])); +const transformedSet = z + .set(z.string().transform((value) => value.length)) + .parse(new Set(["aa", "bbb"])); +const cloneInput = { nested: { label: "x" }, tags: ["a"] }; +const cloneParsed = z.object({ nested: z.object({ label: z.string() }), tags: z.array(z.string()) }).parse(cloneInput); +cloneParsed.nested.label = "changed"; +cloneParsed.tags.push("b"); +class FixtureMap extends Map {} +class FixtureSet extends Set<{ id: number }> {} +const cloneMapInput = new Map([["a", { count: 1 }]]); +const cloneMapParsed = z.map(z.string(), z.object({ count: z.number() })).parse(cloneMapInput); +cloneMapParsed.get("a")!.count = 2; +const subclassMapInput = new FixtureMap([["a", { count: 1 }]]); +const subclassMapParsed = z.map(z.string(), z.object({ count: z.number() })).parse(subclassMapInput); +const subclassMapParsedValue = subclassMapParsed.get("a")!; +subclassMapParsedValue.count = 2; +const cloneMapKeyValueKey = { id: 1 }; +const cloneMapKeyValueValue = { count: 1 }; +const cloneMapKeyValueInput = new Map([[cloneMapKeyValueKey, cloneMapKeyValueValue]]); +const cloneMapKeyValueParsed = z + .map(z.object({ id: z.number() }), z.object({ count: z.number() })) + .parse(cloneMapKeyValueInput); +const cloneMapKeyValueParsedKey = Array.from(cloneMapKeyValueParsed.keys())[0]; +const cloneMapKeyValueParsedValue = cloneMapKeyValueParsed.get(cloneMapKeyValueParsedKey)!; +cloneMapKeyValueParsedKey.id = 2; +cloneMapKeyValueParsedValue.count = 2; +const cloneSetValue = { id: 1 }; +const cloneSetInput = new Set([cloneSetValue]); +const cloneSetParsed = z.set(z.object({ id: z.number() })).parse(cloneSetInput); +const cloneSetParsedValue = Array.from(cloneSetParsed)[0]; +cloneSetParsedValue.id = 2; +const subclassSetValue = { id: 1 }; +const subclassSetInput = new FixtureSet([subclassSetValue]); +const subclassSetParsed = z.set(z.object({ id: z.number() })).parse(subclassSetInput); +const subclassSetParsedValue = Array.from(subclassSetParsed)[0]; +subclassSetParsedValue.id = 2; +const cloneDateInput = new Date("2020-01-02T00:00:00.000Z"); +const cloneDateParsed = z.date().parse(cloneDateInput); +cloneDateParsed.setUTCFullYear(2021); +const inheritedRecordInput = Object.create({ inherited: 2 }); +inheritedRecordInput.own = 1; +const inheritedRecordParsed = z.record(z.number()).parse(inheritedRecordInput); +const inheritedRecordInvalidInput = Object.create({ inherited: 2, badInherited: "x" }); +inheritedRecordInvalidInput.own = 1; +const recordHiddenSymbol = Symbol("record-hidden"); +const recordHiddenInput: Record = { own: 1 }; +Object.defineProperty(recordHiddenInput, "hidden", { value: 2, enumerable: false }); +recordHiddenInput[recordHiddenSymbol] = 3; +const recordHiddenParsed = z.record(z.number()).parse(recordHiddenInput); +const recordMetadataSchema = z.record(z.string(), z.number()); +const mapMetadataSchema = z.map(z.string(), z.number()); +const setMinMetadataSchema = z.set(z.string()).min(1); +const setMaxMetadataSchema = z.set(z.string()).max(3); +const setSizeMetadataSchema = z.set(z.string()).size(2); +const setMinMessage = z.set(z.string()).min(2, "need two").safeParse(new Set(["a"])); +const setMaxMessage = z.set(z.string()).max(1, "too many").safeParse(new Set(["a", "b"])); +const setSizeMessage = z.set(z.string()).size(2, "exactly two").safeParse(new Set(["a"])); +const collectionIssueSummary = (result: z.SafeParseReturnType) => + result.success ? [] : result.error.issues.map((issue) => ({ code: issue.code, path: issue.path.join(".") })); +print("collections", { + record: z.record(z.number()).safeParse({ a: 1, b: 2 }).success, + keyedRecord: z.record(z.enum(["a", "b"]), z.number()).safeParse({ a: 1, b: 2 }).success, + keyedRecordMissing: z.record(z.enum(["a", "b"]), z.number()).safeParse({ a: 1 }).success, + stringKeyRecord: z.record(z.string().min(1), z.number()).safeParse({ "": 1 }).success, + transformedRecord: z + .record(z.string().transform((value) => value.length)) + .parse({ a: "abcd" }), + recordFail: z.record(z.number()).refine((scores) => Object.values(scores).every((score) => score >= 0)).safeParse({ a: 1, b: -1 }).success, + recordNumberKey: collectionIssueSummary(z.record(z.number(), z.string()).safeParse({ 1: "one" })), + recordNumericStringKey: z.record(z.string().regex(/^\d+$/), z.string()).parse({ 1: "one" }), + recordNumericStringFail: collectionIssueSummary(z.record(z.string().regex(/^\d+$/), z.string()).safeParse({ abc: "bad" })), + inheritedRecord: inheritedRecordParsed, + inheritedRecordOwn: Object.prototype.hasOwnProperty.call(inheritedRecordParsed, "inherited"), + inheritedRecordIssue: collectionIssueSummary(z.record(z.number()).safeParse(inheritedRecordInvalidInput)), + nullProtoRecord: z.record(z.union([z.number(), z.string()])).parse(nullProtoInput), + hiddenSymbolRecord: recordHiddenParsed, + hiddenSymbolRecordHasHidden: Object.prototype.hasOwnProperty.call(recordHiddenParsed, "hidden"), + hiddenSymbolRecordSymbols: Object.getOwnPropertySymbols(recordHiddenParsed).length, + map: Array.from(parsedMap.entries()), + mapKeyFail: z.map(z.string().min(2), z.number()).safeParse(new Map([["a", 1]])).success, + mapFail: z.map(z.string(), z.number()).safeParse(new Map([["bad", "x"]])).success, + transformedMap: Array.from(transformedMap.entries()), + set: Array.from(parsedSet.values()), + setNonempty: z.set(z.string()).nonempty().safeParse(new Set()).success, + setFail: z.set(z.string()).min(2).safeParse(new Set(["a"])).success, + setMax: z.set(z.string()).max(2).safeParse(new Set(["a", "b", "c"])).success, + setSize: z.set(z.string()).size(2).safeParse(new Set(["a", "b"])).success, + transformedSet: Array.from(transformedSet.values()), + recordMeta: { + typeName: recordMetadataSchema._def.typeName, + key: recordMetadataSchema.keySchema.constructor.name, + value: recordMetadataSchema.valueSchema.constructor.name, + element: recordMetadataSchema.element.constructor.name, + }, + mapMeta: { + typeName: mapMetadataSchema._def.typeName, + key: mapMetadataSchema.keySchema.constructor.name, + value: mapMetadataSchema.valueSchema.constructor.name, + }, + setMeta: { + typeName: setSizeMetadataSchema._def.typeName, + value: setSizeMetadataSchema._def.valueType.constructor.name, + min: setMinMetadataSchema._def.minSize?.value, + max: setMaxMetadataSchema._def.maxSize?.value, + size: [ + setSizeMetadataSchema._def.minSize?.value, + setSizeMetadataSchema._def.maxSize?.value, + ], + }, + setMessages: [ + setMinMessage.success ? "ok" : setMinMessage.error.issues[0].message, + setMaxMessage.success ? "ok" : setMaxMessage.error.issues[0].message, + setSizeMessage.success ? "ok" : setSizeMessage.error.issues[0].message, + ], + recordIssues: collectionIssueSummary(z.record(z.number()).safeParse({ ok: 1, bad: "x" })), + mapKeyIssues: collectionIssueSummary(z.map(z.string().min(2), z.number()).safeParse(new Map([["a", 1]]))), + mapValueIssues: collectionIssueSummary(z.map(z.string(), z.number()).safeParse(new Map([["bad", "x"]]))), + setElementIssues: collectionIssueSummary(z.set(z.string().min(2)).safeParse(new Set(["a"]))), + setSizeIssues: collectionIssueSummary(z.set(z.string()).size(2).safeParse(new Set(["a"]))), +}); + +print("cloneSemantics", { + objectIdentity: cloneParsed !== cloneInput, + nestedIdentity: cloneParsed.nested !== cloneInput.nested, + arrayIdentity: cloneParsed.tags !== cloneInput.tags, + objectSource: cloneInput, + mapIdentity: cloneMapParsed !== cloneMapInput, + mapValueIdentity: cloneMapParsed.get("a") !== cloneMapInput.get("a"), + mapSource: Array.from(cloneMapInput.entries()), + subclassMapIsMap: subclassMapParsed instanceof Map, + subclassMapIsSubclass: subclassMapParsed instanceof FixtureMap, + subclassMapValueIdentity: subclassMapParsedValue !== subclassMapInput.get("a"), + subclassMapSource: Array.from(subclassMapInput.entries()), + subclassMapParsed: Array.from(subclassMapParsed.entries()), + mapObjectKeyIdentity: cloneMapKeyValueParsedKey !== cloneMapKeyValueKey, + mapObjectValueIdentity: cloneMapKeyValueParsedValue !== cloneMapKeyValueValue, + mapObjectSource: Array.from(cloneMapKeyValueInput.entries()), + mapObjectParsed: Array.from(cloneMapKeyValueParsed.entries()), + setIdentity: cloneSetParsed !== cloneSetInput, + setValueIdentity: cloneSetParsedValue !== cloneSetValue, + setSource: Array.from(cloneSetInput.values()), + subclassSetIsSet: subclassSetParsed instanceof Set, + subclassSetIsSubclass: subclassSetParsed instanceof FixtureSet, + subclassSetValueIdentity: subclassSetParsedValue !== subclassSetValue, + subclassSetSource: Array.from(subclassSetInput.values()), + subclassSetParsed: Array.from(subclassSetParsed.values()), + dateIdentity: cloneDateParsed !== cloneDateInput, + dateSource: cloneDateInput, + dateParsed: cloneDateParsed, +}); + +const intersectionObjectSchema = z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })); +const inheritedIntersectionInput = Object.create({ b: 2 }); +inheritedIntersectionInput.a = "x"; +const inheritedIntersectionParsed = intersectionObjectSchema.parse(inheritedIntersectionInput); +const nestedIntersectionSchema = z.intersection( + z.object({ nested: z.object({ a: z.string() }), shared: z.literal("same") }), + z.object({ nested: z.object({ b: z.number() }), shared: z.literal("same") }), +); +const transformedIntersectionSchema = z.intersection( + z.string().transform(() => ({ a: 1 })), + z.string().transform(() => ({ b: 2 })), +); +const conflictingIntersectionSchema = z.intersection( + z.string().transform(() => ({ shared: { value: 1 } })), + z.string().transform(() => ({ shared: { value: 2 } })), +); +const arrayIntersectionConflictSchema = z.intersection( + z.string().transform(() => [1, 2]), + z.string().transform(() => [1, 3]), +); +print("composition", { + intersection: intersectionObjectSchema.parse({ a: "x", b: 1 }), + inheritedIntersection: inheritedIntersectionParsed, + inheritedIntersectionOwnB: Object.prototype.hasOwnProperty.call(inheritedIntersectionParsed, "b"), + nestedIntersection: nestedIntersectionSchema.parse({ nested: { a: "x", b: 1 }, shared: "same" }), + conflictingIntersection: issueMetadataFromResult(conflictingIntersectionSchema.safeParse("x")), + arrayIntersectionConflict: issueMetadataFromResult(arrayIntersectionConflictSchema.safeParse("x")), + intersectionTypeName: intersectionObjectSchema._def.typeName, + intersectionSides: [ + intersectionObjectSchema._def.left.constructor.name, + intersectionObjectSchema._def.right.constructor.name, + ], + transformedIntersection: transformedIntersectionSchema.parse("x"), + or: z.string().or(z.number()).parse(5), + and: z.object({ a: z.string() }).and(z.object({ b: z.boolean() })).parse({ a: "x", b: true }), + strictObject: z.strictObject({ id: z.number() }).safeParse({ id: 1, extra: true }).success, + schemaArray: z.string().array().parse(["a", "b"]), + schemaOr: z.literal("a").or(z.literal("b")).parse("b"), + schemaOrIssue: issueMetadataFromResult(z.string().min(2).or(z.number().min(10)).safeParse(5)), + schemaAnd: z.object({ a: z.string() }).and(z.object({ b: z.number() })).parse({ a: "x", b: 1 }), + pipelineFactory: z.pipeline(z.string().transform((value) => value.length), z.number().min(2)).safeParse("abc").success, +}); + +const csvSchema = z + .string() + .transform((value) => value.split(",").map((item) => item.trim()).filter(Boolean)) + .refine((items) => items.length >= 2, { message: "need two values" }); +print("transform", csvSchema.parse("a, b, c")); +print("refine", csvSchema.safeParse("single").success); + +const preprocessSchema = z.preprocess((value) => (typeof value === "string" ? value.trim() : value), z.string().min(2)); +const metadataPreprocessSchema = z.preprocess((value) => value, z.string()); +const metadataTransformSchema = z.string().transform((value) => value.length); +const metadataRefineSchema = z.string().refine((value) => value.length > 1, "too short"); +const metadataPipelineSchema = z.string().transform((value) => value.length).pipe(z.number().min(1)); +const metadataPipelineFactorySchema = z.pipeline(z.string(), z.coerce.number()); +const matchingPasswords = z.object({ password: z.string(), confirm: z.string() }).refine((values) => values.password === values.confirm, { + path: ["confirm"], + message: "password mismatch", +}); +const superRefineSchema = z.array(z.number()).superRefine((values, ctx) => { + if (new Set(values).size !== values.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "duplicates" }); + } +}); +const fatalCalls: string[] = []; +const fatalRefineSchema = z + .string() + .superRefine((value, ctx) => { + fatalCalls.push(`first:${value}`); + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "stop", fatal: true }); + return z.NEVER; + }) + .superRefine(() => { + fatalCalls.push("second"); + }); +const neverTransformSchema = z + .string() + .transform((value, ctx) => { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `bad transform:${value}` }); + return z.NEVER; + }) + .pipe(z.string()); +const matchingPasswordsResult = matchingPasswords.safeParse({ password: "a", confirm: "b" }); +const fatalRefineResult = fatalRefineSchema.safeParse("x"); +const neverTransformResult = neverTransformSchema.safeParse("x"); +const refinementAliasResult = z + .string() + .refinement((value) => value === "ok", { code: z.ZodIssueCode.custom, message: "bad refinement" }) + .safeParse("no"); +print("effects", { + preprocess: preprocessSchema.parse(" ok "), + pipe: z.string().transform((value) => value.length).pipe(z.number().min(2)).safeParse("abc").success, + superRefine: superRefineSchema.safeParse([1, 1]).success, + custom: z.custom((value) => value === "token").safeParse("token").success, + refinementAlias: refinementAliasResult.success ? "ok" : refinementAliasResult.error.issues[0].message, + innerType: z.string().transform((value) => value.length).innerType().parse("inner"), + sourceType: z.string().transform((value) => value.length).sourceType().parse("source"), + refinePath: matchingPasswordsResult.success ? "ok" : matchingPasswordsResult.error.issues[0].path.join("."), + refineMessage: matchingPasswordsResult.success ? "ok" : matchingPasswordsResult.error.issues[0].message, + fatalCalls, + fatalIssue: fatalRefineResult.success ? "ok" : fatalRefineResult.error.issues[0].message, + fatalFlag: fatalRefineResult.success ? false : fatalRefineResult.error.issues[0].fatal === true, + neverTransform: neverTransformResult.success ? "ok" : neverTransformResult.error.issues[0].message, + neverStatus: z.NEVER.status, + metadata: { + preprocess: [ + metadataPreprocessSchema._def.typeName, + metadataPreprocessSchema._def.effect.type, + metadataPreprocessSchema._def.schema.constructor.name, + metadataPreprocessSchema.innerType().constructor.name, + metadataPreprocessSchema.sourceType().constructor.name, + ], + transform: [ + metadataTransformSchema._def.typeName, + metadataTransformSchema._def.effect.type, + metadataTransformSchema._def.schema.constructor.name, + metadataTransformSchema.innerType().constructor.name, + metadataTransformSchema.sourceType().constructor.name, + ], + refine: [ + metadataRefineSchema._def.typeName, + metadataRefineSchema._def.effect.type, + metadataRefineSchema._def.schema.constructor.name, + ], + pipeline: [ + metadataPipelineSchema._def.typeName, + metadataPipelineSchema._def.in.constructor.name, + metadataPipelineSchema._def.out.constructor.name, + metadataPipelineFactorySchema._def.in.constructor.name, + metadataPipelineFactorySchema._def.out.constructor.name, + ], + }, +}); + +const customTokenResult = z.custom((value) => typeof value === "string" && value.startsWith("tok_")).safeParse("tok_123"); +const customTokenFailure = z.custom((value) => typeof value === "string" && value.startsWith("tok_"), { + message: "not a token", +}).safeParse("bad"); +print("customSchemas", { + unchecked: z.custom<{ id: number }>().parse({ id: 1 }).id, + predicate: customTokenResult.success, + message: customTokenFailure.success ? "ok" : customTokenFailure.error.issues[0].message, +}); + +const originalDescriptionSchema = z.string(); +const describedStringSchema = originalDescriptionSchema.describe("fixture string"); +const describedObjectSchema = z.object({ id: z.number().describe("identifier") }).describe("fixture object"); +const describedArraySchema = z.array(z.string().describe("array item")).describe("fixture array"); +const describedOptionalSchema = z.string().describe("inner").optional().describe("outer optional"); +print("descriptions", { + original: originalDescriptionSchema.description ?? null, + string: [describedStringSchema.description, describedStringSchema._def.description, describedStringSchema.parse("ok")], + object: [ + describedObjectSchema.description, + describedObjectSchema._def.description, + describedObjectSchema.shape.id.description, + describedObjectSchema.parse({ id: 1 }).id, + ], + array: [ + describedArraySchema.description, + describedArraySchema._def.description, + describedArraySchema.element.description, + describedArraySchema.parse(["x"]).length, + ], + optional: [ + describedOptionalSchema.description, + describedOptionalSchema._def.description, + describedOptionalSchema._def.innerType.description, + describedOptionalSchema.parse(undefined) === undefined, + ], +}); + +const optionalMetadataSchema = z.string().optional(); +const nullableMetadataSchema = z.string().nullable(); +const defaultMetadataSchema = z.string().default("fallback"); +const catchMetadataSchema = z.number().catch(9); +const promiseMetadataSchema = z.promise(z.number()); +const readonlyMetadataSchema = z.object({ id: z.number() }).readonly(); +const brandedMetadataSchema = z.string().brand<"FixtureId">(); +const readonlyObjectValue = z.object({ id: z.number() }).readonly().parse({ id: 1 }); +const readonlyNestedInput = { nested: { id: 1 }, tags: ["a"] }; +const readonlyNestedValue = z + .object({ nested: z.object({ id: z.number() }), tags: z.array(z.string()) }) + .readonly() + .parse(readonlyNestedInput); +const readonlyArrayValue = z.array(z.string()).readonly().parse(["a"]); +const readonlyTupleValue = z.tuple([z.string()]).readonly().parse(["a"]); +const readonlyMapValue = z.map(z.string(), z.number()).readonly().parse(new Map([["a", 1]])); +const readonlySetValue = z.set(z.string()).readonly().parse(new Set(["a"])); +let defaultFactoryCalls = 0; +let catchFactoryCalls = 0; +const countedDefaultSchema = z.string().default(() => `fallback-${++defaultFactoryCalls}`); +const countedCatchSchema = z.number().catch((ctx) => { + catchFactoryCalls += 1; + return ctx.error.issues.length + catchFactoryCalls; +}); +const tryErrorName = (fn: () => void) => { + try { + fn(); + return "ok"; + } catch (error) { + return error instanceof Error ? error.constructor.name : String(error); + } +}; +print("modifiers", { + ostring: z.ostring().parse(undefined) === undefined, + onumber: z.onumber().parse(undefined) === undefined, + oboolean: z.oboolean().parse(undefined) === undefined, + optional: z.string().optional().parse(undefined) === undefined, + nullable: z.string().nullable().parse(null) === null, + nullish: z.string().nullish().parse(undefined) === undefined, + default: z.string().default("fallback").parse(undefined), + defaultFunction: z.string().default(() => "factory").parse(undefined), + defaultSkipsPresent: z.string().default(() => "factory").parse("present"), + defaultFactorySequence: [ + countedDefaultSchema.parse(undefined), + countedDefaultSchema.parse(undefined), + countedDefaultSchema.parse("present"), + defaultFactoryCalls, + ], + defaultTransform: z.string().transform((value) => value.length).default("tuna").parse(undefined), + removeDefault: z.string().default("fallback").removeDefault().safeParse(undefined).success, + catch: z.number().catch(9).parse("bad"), + catchTransform: z.string().transform((value) => value.length).catch(9).parse(123), + catchFunction: z.number().catch((ctx) => ctx.error.issues.length).parse("bad"), + catchFactorySequence: [ + countedCatchSchema.parse("bad"), + countedCatchSchema.parse(3), + catchFactoryCalls, + ], + catchContext: z.number().catch((ctx) => `${ctx.input}:${ctx.error.issues[0].code}`).parse("bad"), + removeCatch: z.number().catch(9).removeCatch().safeParse("bad").success, + brand: z.string().brand<"FixtureId">().parse("id-1"), + described: z.string().describe("fixture string").description, + describedObject: z.object({ id: z.number() }).describe("fixture object").description, + describedParse: z.string().describe("parse still works").parse("described"), + optionalUnwrap: z.string().optional().unwrap().parse("wrapped"), + nullableUnwrap: z.string().nullable().unwrap().parse("wrapped"), + arrayElement: z.array(z.string()).element.parse("element"), + promiseUnwrap: z.promise(z.number()).unwrap().parse(3), + isOptional: z.string().optional().isOptional(), + isNullable: z.string().nullable().isNullable(), + readonlyFrozen: Object.isFrozen(z.object({ id: z.number() }).readonly().parse({ id: 1 })), + readonlyArray: Object.isFrozen(z.array(z.string()).readonly().parse(["a"])), + readonlyTuple: Object.isFrozen(z.tuple([z.string()]).readonly().parse(["a"])), + readonlyMap: Object.isFrozen(z.map(z.string(), z.number()).readonly().parse(new Map([["a", 1]]))), + readonlySet: Object.isFrozen(z.set(z.string()).readonly().parse(new Set(["a"]))), + readonlyMutations: { + object: [Reflect.set(readonlyObjectValue, "id", 2), readonlyObjectValue.id], + nestedObject: [ + Object.isFrozen(readonlyNestedValue), + Object.isFrozen(readonlyNestedValue.nested), + Object.isFrozen(readonlyNestedValue.tags), + Reflect.set(readonlyNestedValue, "extra", true), + Reflect.set(readonlyNestedValue.nested, "id", 2), + tryErrorName(() => readonlyNestedValue.tags.push("b")), + readonlyNestedValue, + readonlyNestedInput, + readonlyNestedValue.nested !== readonlyNestedInput.nested, + readonlyNestedValue.tags !== readonlyNestedInput.tags, + ], + array: [ + Reflect.set(readonlyArrayValue, "0", "b"), + tryErrorName(() => readonlyArrayValue.push("b")), + readonlyArrayValue[0], + readonlyArrayValue.length, + ], + tuple: [ + Reflect.set(readonlyTupleValue, "0", "b"), + tryErrorName(() => readonlyTupleValue.push("b")), + readonlyTupleValue[0], + readonlyTupleValue.length, + ], + map: [ + tryErrorName(() => readonlyMapValue.set("b", 2)), + readonlyMapValue.size, + readonlyMapValue.get("b"), + ], + set: [ + tryErrorName(() => readonlySetValue.add("b")), + readonlySetValue.size, + readonlySetValue.has("b"), + ], + }, + schemaArray: z.string().array().parse(["a", "b"]).length, + schemaOr: z.string().or(z.number()).parse(2), + schemaAnd: z.object({ a: z.string() }).and(z.object({ b: z.number() })).parse({ a: "x", b: 1 }), + metadata: { + optional: [optionalMetadataSchema._def.typeName, optionalMetadataSchema._def.innerType.constructor.name], + nullable: [nullableMetadataSchema._def.typeName, nullableMetadataSchema._def.innerType.constructor.name], + default: [defaultMetadataSchema._def.typeName, defaultMetadataSchema._def.defaultValue()], + catch: [catchMetadataSchema._def.typeName, catchMetadataSchema._def.catchValue({ error: null, input: "bad" })], + promise: [promiseMetadataSchema._def.typeName, promiseMetadataSchema._def.type.constructor.name], + readonly: [readonlyMetadataSchema._def.typeName, readonlyMetadataSchema._def.innerType.constructor.name], + branded: [ + brandedMetadataSchema._def.typeName, + brandedMetadataSchema.unwrap().constructor.name, + brandedMetadataSchema instanceof z.ZodBranded, + z.ZodBranded.name, + ], + }, +}); + +type Tree = { name: string; children?: Tree[] }; +const treeSchema: z.ZodType = z.lazy(() => z.object({ name: z.string(), children: z.array(treeSchema).optional() })); +const lazyTreeInput: Tree = { name: "root", children: [{ name: "leaf" }] }; +const lazyTreeParsed = treeSchema.parse(lazyTreeInput); +lazyTreeParsed.children![0].name = "changed"; +print("lazy", { + parsed: treeSchema.parse({ name: "root", children: [{ name: "leaf" }] }), + cloneIdentity: lazyTreeParsed !== lazyTreeInput, + childCloneIdentity: lazyTreeParsed.children![0] !== lazyTreeInput.children![0], + source: lazyTreeInput, + mutated: lazyTreeParsed, +}); + +const lateNodeSchema: z.ZodType = z.late.object(() => ({ + name: z.string(), + children: z.array(lateNodeSchema).default([]), +})); +const jsonLiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([jsonLiteralSchema, z.array(jsonValueSchema), z.record(jsonValueSchema)]), +); +const jsonStringSchema = z.string().transform((value) => JSON.parse(value)).pipe(jsonValueSchema); +const mapSizeSchema = z.object({ + box: z.instanceof(Map).refine((value) => value.size === 1, { path: ["size"], message: "size one" }), +}); +const mergedStaticShape = z.objectUtil.mergeShapes( + { a: z.string(), shared: z.string() }, + { b: z.number(), shared: z.number() }, +); +const effectAliasSchema = z.effect(z.string(), { + type: "transform", + transform: (value: string) => value.length, +}); +const transformerAliasSchema = z.transformer(z.string(), { + type: "transform", + transform: (value: string) => value.toUpperCase(), +}); +print("factories", { + arrayFactory: z.array(z.string()).parse(["factory"]).join("|"), + optionalFactory: z.optional(z.string()).parse(undefined) === undefined, + nullableFactory: z.nullable(z.string()).parse(null) === null, + promiseFactory: await z.promise(z.number()).parse(Promise.resolve(4)), + ostringFactory: z.ostring().parse(undefined) === undefined, + onumberFactory: z.onumber().parse(3), + obooleanFactory: z.oboolean().parse(undefined) === undefined, + unionFactory: z.union([z.literal("left"), z.literal("right")]).parse("right"), + intersectionFactory: z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() })).parse({ a: "x", b: 2 }), + lazyFactory: z.lazy(() => z.string()).parse("lazy"), + lateObject: lateNodeSchema.parse({ name: "root", children: [{ name: "leaf" }] }), +}); + +print("staticFactories", { + string: z.ZodString.create().parse("x"), + number: z.ZodNumber.create().parse(1), + nan: Number.isNaN(z.ZodNaN.create().parse(NaN)), + array: z.ZodArray.create(z.string()).parse(["a"]).length, + object: z.ZodObject.create({ id: z.number() }).parse({ id: 1 }).id, + tuple: z.ZodTuple.create([z.string(), z.number()]).parse(["a", 1])[1], + record: z.ZodRecord.create(z.string()).parse({ a: "x" }).a, + map: Array.from(z.ZodMap.create(z.string(), z.number()).parse(new Map([["a", 1]])).entries()), + set: Array.from(z.ZodSet.create(z.string()).parse(new Set(["a"])).values()), + optional: z.ZodOptional.create(z.string()).parse(undefined) === undefined, + nullable: z.ZodNullable.create(z.string()).parse(null) === null, + promise: await z.ZodPromise.create(z.number()).parse(Promise.resolve(2)), + readonly: Object.isFrozen(z.ZodReadonly.create(z.object({ id: z.number() })).parse({ id: 1 })), + effectAlias: effectAliasSchema.parse("tuna"), + transformerAlias: transformerAliasSchema.parse("tuna"), + objectUtil: z.object(mergedStaticShape).parse({ a: "x", b: 1, shared: 2 }), + literal: z.ZodLiteral.create("ready").parse("ready"), + enum: z.ZodEnum.create(["red", "blue"]).parse("blue"), + nativeEnum: z.ZodNativeEnum.create({ A: "a", B: "b" }).parse("a"), + date: z.ZodDate.create().parse(new Date("2020-01-02T00:00:00.000Z")).toISOString(), + bigint: z.ZodBigInt.create().parse(2n).toString(), + boolean: z.ZodBoolean.create().parse(true), + any: z.ZodAny.create().parse({ ok: true }).ok, + unknown: z.ZodUnknown.create().parse(["x"]).length, + never: z.ZodNever.create().safeParse("x").success, + undefined: z.ZodUndefined.create().parse(undefined) === undefined, + null: z.ZodNull.create().parse(null) === null, + void: z.ZodVoid.create().parse(undefined) === undefined, + symbol: typeof z.ZodSymbol.create().parse(Symbol.for("x")), + union: z.ZodUnion.create([z.string(), z.number()]).parse(3), + intersection: z.ZodIntersection.create(z.object({ a: z.string() }), z.object({ b: z.number() })).parse({ a: "x", b: 1 }), + discriminatedUnion: z.ZodDiscriminatedUnion.create("kind", [ + z.object({ kind: z.literal("a"), value: z.string() }), + z.object({ kind: z.literal("b"), value: z.number() }), + ]).parse({ kind: "b", value: 2 }).value, + function: z.ZodFunction.create() + .args(z.string()) + .returns(z.number()) + .implement((value) => value.length)("abc"), + lazy: z.ZodLazy.create(() => z.string()).parse("lazy"), + effects: z.ZodEffects.create(z.string(), { type: "transform", transform: (value: string) => value.length }).parse("fish"), + default: z.ZodDefault.create(z.string(), { default: () => "fallback" }).parse(undefined), + catch: z.ZodCatch.create(z.number(), { catch: () => 7 }).parse("bad"), + pipeline: z.ZodPipeline.create(z.string(), z.coerce.number()).parse("6"), +}); + +print("jsonLike", { + object: jsonValueSchema.safeParse({ nested: [1, "two", null] }).success, + rejectsFunction: jsonValueSchema.safeParse(() => 1).success, + rejectsSymbol: jsonValueSchema.safeParse(Symbol.for("x")).success, + rejectsBigint: jsonValueSchema.safeParse(1n).success, + badNested: issueMetadataFromResult(jsonValueSchema.safeParse({ ok: true, bad: () => 1 })), + fromString: jsonStringSchema.parse('{"a":[1,true,null]}'), + mapSize: mapSizeSchema.safeParse({ box: new Map([["a", 1]]) }).success, + mapSizeFail: issueMetadataFromResult(mapSizeSchema.safeParse({ box: new Map() })), + parsed: jsonValueSchema.parse({ ok: true, items: [1, "two"] }), +}); + +class Box { + value: string; + constructor(value: string) { + this.value = value; + } +} +const invalidBoxResult = z.instanceof(Box, { message: "not a box" }).safeParse({}); +const customDateSchema = z.date({ + required_error: "required date", + invalid_type_error: "not a date", +}); +const requiredDateResult = customDateSchema.safeParse(undefined); +const invalidDateResult = customDateSchema.safeParse("nope"); +print("instances.dates", { + instanceof: z.instanceof(Box).parse(new Box("ok")).value, + instanceMessage: invalidBoxResult.success ? "ok" : invalidBoxResult.error.issues[0].message, + date: z.date().parse(new Date("2020-01-02T00:00:00.000Z")).toISOString(), + dateRequired: requiredDateResult.success ? "ok" : requiredDateResult.error.issues[0].message, + dateInvalid: invalidDateResult.success ? "ok" : invalidDateResult.error.issues[0].message, + dateMin: z.date().min(new Date("2020-01-01T00:00:00.000Z")).safeParse(new Date("2020-01-02T00:00:00.000Z")).success, + dateMax: z.date().max(new Date("2020-01-03T00:00:00.000Z")).safeParse(new Date("2020-01-04T00:00:00.000Z")).success, +}); + +const validatedFn = z.function().args(z.string()).returns(z.number()).implement((value) => value.trim().length); +const strictValidatedFn = z.function().args(z.string()).returns(z.number()).strictImplement((value) => value.trim().length); +const invalidReturnFn = z.function().args(z.string()).returns(z.number()).implement(() => "bad" as unknown as number); +const strictInvalidReturnFn = z.function().args(z.string()).returns(z.number()).strictImplement(() => "bad" as unknown as number); +const validatedAliasFn = z.function().args(z.string()).returns(z.number()).validate((value) => value.trim().length); +const restValidatedFn = z + .function(z.tuple([z.string()]).rest(z.number()), z.number()) + .implement((label, ...values) => label.length + values.reduce((total, value) => total + value, 0)); +const functionSchema = z.function().args(z.string(), z.number()).returns(z.boolean()); +const functionMetadataSchema = z.function().args(z.string(), z.number()).returns(z.boolean()); +const defaultFunctionMetadataSchema = z.function(); +const argsOnlyFunctionMetadataSchema = z.function().args(z.string()); +const returnsOnlyFunctionMetadataSchema = z.function().returns(z.number()); +const asyncValidatedFn = z.function().args(z.string()).returns(z.promise(z.number())).implement(async (value) => value.trim().length); +const asyncInvalidReturnFn = z.function().args(z.string()).returns(z.promise(z.number())).implement(async () => "bad" as unknown as number); +const functionCloneInput = { nested: { id: 1 } }; +let functionCloneSeenArg: { nested: { id: number } } | undefined; +const functionCloneValidatedFn = z + .function() + .args(z.object({ nested: z.object({ id: z.number() }) })) + .returns(z.object({ nested: z.object({ id: z.number() }) })) + .implement((value) => { + functionCloneSeenArg = value; + value.nested.id = 2; + return value; + }); +const functionCloneReturned = functionCloneValidatedFn(functionCloneInput); +functionCloneReturned.nested.id = 3; +const functionNestedIssueCount = (issue: z.ZodIssue) => { + if (issue.code === "invalid_arguments") { + return issue.argumentsError.issues.length; + } + if (issue.code === "invalid_return_type") { + return issue.returnTypeError.issues.length; + } + return 0; +}; +const functionIssueSummary = (call: () => unknown) => { + try { + call(); + return []; + } catch (error) { + return (error as z.ZodError).issues.map((issue) => ({ + code: issue.code, + path: issue.path.join("."), + nestedCount: functionNestedIssueCount(issue), + })); + } +}; +const functionArgumentIssuePaths = (call: () => unknown) => { + try { + call(); + return []; + } catch (error) { + const issue = (error as z.ZodError).issues[0]; + return issue.code === "invalid_arguments" + ? issue.argumentsError.issues.map((nestedIssue) => `${nestedIssue.path.join(".")}:${nestedIssue.code}`) + : []; + } +}; +const invalidAsyncReturn = await (async () => { + try { + await asyncInvalidReturnFn("x"); + return false; + } catch { + return true; + } +})(); +print("function", { + valid: validatedFn(" tuna "), + strictValid: strictValidatedFn(" trout "), + validateAlias: validatedAliasFn(" bass "), + restValid: restValidatedFn("ab", 1, 2, 3), + restInvalid: functionArgumentIssuePaths(() => (restValidatedFn as unknown as (...values: unknown[]) => number)("ab", 1, "x")), + asyncValid: await asyncValidatedFn(" salmon "), + cloneSemantics: { + argIdentity: functionCloneSeenArg !== functionCloneInput, + nestedArgIdentity: functionCloneSeenArg?.nested !== functionCloneInput.nested, + source: functionCloneInput, + returnIdentity: functionCloneReturned !== functionCloneSeenArg, + nestedReturnIdentity: functionCloneReturned.nested !== functionCloneSeenArg?.nested, + returned: functionCloneReturned, + }, + parameters: functionSchema.parameters().items.length, + returnType: functionSchema.returnType().safeParse(true).success, + invalidArgs: (() => { + try { + (validatedFn as unknown as (value: number) => number)(1); + return false; + } catch { + return true; + } + })(), + invalidReturns: (() => { + try { + invalidReturnFn("x"); + return false; + } catch { + return true; + } + })(), + argumentIssues: functionIssueSummary(() => (validatedFn as unknown as (value: number) => number)(1)), + returnIssues: functionIssueSummary(() => invalidReturnFn("x")), + strictReturnIssues: functionIssueSummary(() => strictInvalidReturnFn("x")), + invalidAsyncReturn, + metadata: { + typed: { + typeName: functionMetadataSchema._def.typeName, + argsType: functionMetadataSchema._def.args.constructor.name, + argItems: functionMetadataSchema._def.args.items.map((item) => item.constructor.name), + argRest: functionMetadataSchema._def.args._def.rest?.constructor.name, + returns: functionMetadataSchema._def.returns.constructor.name, + parameters: functionMetadataSchema.parameters().items.map((item) => item.constructor.name), + returnType: functionMetadataSchema.returnType().constructor.name, + }, + defaults: { + args: defaultFunctionMetadataSchema._def.args.items.length, + rest: defaultFunctionMetadataSchema._def.args._def.rest?.constructor.name, + returns: defaultFunctionMetadataSchema._def.returns.constructor.name, + }, + argsOnly: { + args: argsOnlyFunctionMetadataSchema._def.args.items.map((item) => item.constructor.name), + returns: argsOnlyFunctionMetadataSchema._def.returns.constructor.name, + }, + returnsOnly: { + args: returnsOnlyFunctionMetadataSchema._def.args.items.length, + returns: returnsOnlyFunctionMetadataSchema._def.returns.constructor.name, + }, + }, +}); + +const asyncSchema = z.string().refine(async (value) => value === "ok"); +const asyncTransformSchema = z.string().transform(async (value) => value.trim().length); +const asyncPipeSchema = z + .string() + .transform(async (value) => value.length) + .pipe(z.number().min(2)); +const asyncSuperRefineSchema = z.string().superRefine(async (value, ctx) => { + if (value !== "ok") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "bad async" }); + } +}); +const asyncPreprocessSchema = z.preprocess( + async (value) => (typeof value === "string" ? value.trim() : value), + z.string().min(2), +); +const numericPreprocessSchema = z.preprocess( + (value) => (typeof value === "string" ? Number(value) : value), + z.number().min(2), +); +const objectPipelineSchema = z.pipeline( + z.string().transform((value) => ({ len: value.length })), + z.object({ len: z.number().min(2) }), +); +const transformIssueSchema = z.string().transform((value, ctx) => { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "warn" }); + return value.length; +}); +const asyncPromiseMetadataSchema = z.promise(z.number()); +const promisedNumber = await z.promise(z.number()).parse(Promise.resolve(5)); +const methodPromisedNumber = await z.number().promise().parse(Promise.resolve(3)); +const asyncParsed = await asyncSchema.parseAsync("ok"); +const spaResult = await z.string().spa("ok"); +const promisedFailure = await (async () => { + try { + await z.promise(z.number()).parse(Promise.resolve("bad")); + return false; + } catch { + return true; + } +})(); +const rejectedReason = await (async () => { + try { + await z.promise(z.number()).parse(Promise.reject(new Error("promise boom"))); + return "ok"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +})(); +print("async", { + refine: await asyncSchema.safeParseAsync("ok"), + parseAsync: asyncParsed, + spa: spaResult.success, + promise: promisedNumber, + methodPromise: methodPromisedNumber, + promiseRejects: promisedFailure, + rejectedReason, +}); +const asyncSuperRefineResult = await asyncSuperRefineSchema.safeParseAsync("bad"); +const asyncRefineMessageResult = await z + .string() + .refine(async (value) => value === "ok", { message: "not ok" }) + .safeParseAsync("bad"); +print("asyncEffects", { + transform: await asyncTransformSchema.parseAsync(" tuna "), + pipe: await asyncPipeSchema.safeParseAsync("abc"), + superRefineMessage: asyncSuperRefineResult.success ? "ok" : asyncSuperRefineResult.error.issues[0].message, + refineMessage: asyncRefineMessageResult.success ? "ok" : asyncRefineMessageResult.error.issues[0].message, + preprocess: await asyncPreprocessSchema.parseAsync(" ok "), + syncThrows: { + transformParse: thrownSummary(() => asyncTransformSchema.parse("x")), + transformSafeParse: thrownSummary(() => asyncTransformSchema.safeParse("x")), + refineParse: thrownSummary(() => asyncSchema.parse("bad")), + refineSafeParse: thrownSummary(() => asyncSchema.safeParse("bad")), + }, + metadata: { + refine: [asyncSchema._def.typeName, asyncSchema._def.effect.type, asyncSchema._def.schema.constructor.name], + transform: [ + asyncTransformSchema._def.typeName, + asyncTransformSchema._def.effect.type, + asyncTransformSchema._def.schema.constructor.name, + ], + pipe: [asyncPipeSchema._def.typeName, asyncPipeSchema._def.in.constructor.name, asyncPipeSchema._def.out.constructor.name], + superRefine: [ + asyncSuperRefineSchema._def.typeName, + asyncSuperRefineSchema._def.effect.type, + asyncSuperRefineSchema._def.schema.constructor.name, + ], + preprocess: [ + asyncPreprocessSchema._def.typeName, + asyncPreprocessSchema._def.effect.type, + asyncPreprocessSchema._def.schema.constructor.name, + ], + promise: [asyncPromiseMetadataSchema._def.typeName, asyncPromiseMetadataSchema._def.type.constructor.name], + }, +}); +print("asyncIssues", { + parseAsync: issueMetadataFromResult(await z.string().min(2).safeParseAsync("x")), + pipe: issueMetadataFromResult(await asyncPipeSchema.safeParseAsync("x")), + preprocess: issueMetadataFromResult(await asyncPreprocessSchema.safeParseAsync(" x ")), + promiseInner: await issueMetadataFromThrown(() => z.promise(z.number()).parse(Promise.resolve("bad"))), +}); + +const promiseSchema = z.promise(z.number()); +const promiseSafeResult = promiseSchema.safeParse(Promise.resolve(8)); +const promiseObjectValue = await z.promise(z.object({ id: z.number() })).parse(Promise.resolve({ id: 1 })); +const promiseCloneInput = { nested: { id: 1 } }; +const promiseCloneParsed = await z + .promise(z.object({ nested: z.object({ id: z.number() }) })) + .parse(Promise.resolve(promiseCloneInput)); +promiseCloneParsed.nested.id = 2; +print("promiseSchemas", { + safeParse: promiseSafeResult.success, + safeValue: promiseSafeResult.success ? await promiseSafeResult.data : null, + parseThen: typeof promiseSchema.parse(Promise.resolve(2)).then, + parseValue: await promiseSchema.parse(Promise.resolve(2)), + nonPromise: issueMetadataFromResult(promiseSchema.safeParse(1)), + inner: await issueMetadataFromThrown(() => promiseSchema.parse(Promise.resolve("x"))), + object: promiseObjectValue, + cloneIdentity: promiseCloneParsed !== promiseCloneInput, + cloneNestedIdentity: promiseCloneParsed.nested !== promiseCloneInput.nested, + cloneSource: promiseCloneInput, + cloneParsed: promiseCloneParsed, + methodType: z.number().promise()._def.type.constructor.name, +}); + +print("effectFactories", { + preprocessNumber: numericPreprocessSchema.parse("3"), + preprocessIssue: issueMetadataFromResult(numericPreprocessSchema.safeParse("1")), + pipelineObject: objectPipelineSchema.parse("abc"), + pipelineIssue: issueMetadataFromResult(objectPipelineSchema.safeParse("a")), + transformIssue: issueMetadataFromResult(transformIssueSchema.safeParse("abc")), +}); + +const formatted = objectBase.safeParse({ id: "x", name: 1 }); +if (!formatted.success) { + const nestedFormatted = z.object({ user: z.object({ tags: z.array(z.string().min(2)).min(2) }) }).safeParse({ user: { tags: ["x"] } }); + const unionFailure = z + .union([ + z.object({ kind: z.literal("a"), value: z.string() }), + z.object({ kind: z.literal("b"), count: z.number() }), + ]) + .safeParse({ kind: "a", value: 1 }); + const discriminatedFailure = eventSchema.safeParse({ type: "missing", value: true }); + const unionIssue = unionFailure.success ? null : unionFailure.error.issues[0]; + const discriminatedIssue = discriminatedFailure.success ? null : discriminatedFailure.error.issues[0]; + print("errors", { + formatKeys: Object.keys(formatted.error.format()).sort(), + flatten: formatted.error.flatten(), + nestedTagErrors: nestedFormatted.success ? [] : nestedFormatted.error.format().user?.tags?._errors, + nestedTagItemErrors: nestedFormatted.success ? [] : nestedFormatted.error.format().user?.tags?.[0]?._errors, + unionIssue: unionIssue?.code, + unionBranchIssueCounts: unionIssue?.code === "invalid_union" ? unionIssue.unionErrors.map((error) => error.issues.length) : [], + unionFirstBranchPath: unionIssue?.code === "invalid_union" ? unionIssue.unionErrors[0].issues[0].path.join(".") : "", + discriminatedIssue: discriminatedIssue?.code, + discriminatedPath: discriminatedIssue?.path.join("."), + }); +} + +print("issueMetadata", { + literal: issueMetadataFromResult(z.literal("ready").safeParse("no")), + invalidType: issueMetadataFromResult(z.number().safeParse("x")), + enum: issueMetadataFromResult(z.enum(["red", "blue"]).safeParse("green")), + invalidString: issueMetadataFromResult(z.string().email().safeParse("bad")), + invalidRegex: issueMetadataFromResult(z.string().regex(/^a+$/).safeParse("bbb")), + strict: issueMetadataFromResult(z.object({ id: z.number() }).strict().safeParse({ id: 1, extra: true })), + invalidDate: issueMetadataFromResult(z.date().safeParse(new Date("bad"))), + notFinite: issueMetadataFromResult(z.number().finite().safeParse(Infinity)), + dateTooSmall: issueMetadataFromResult( + z.date().min(new Date("2020-01-02T00:00:00.000Z")).safeParse(new Date("2020-01-01T00:00:00.000Z")), + ), + dateTooBig: issueMetadataFromResult( + z.date().max(new Date("2020-01-02T00:00:00.000Z")).safeParse(new Date("2020-01-03T00:00:00.000Z")), + ), + tooBigArray: issueMetadataFromResult(z.array(z.string()).max(1).safeParse(["a", "b"])), + tooSmallNumber: issueMetadataFromResult(z.number().min(2).safeParse(1)), + exactArray: issueMetadataFromResult(z.array(z.string()).length(2).safeParse(["a"])), + exactString: issueMetadataFromResult(z.string().length(2).safeParse("a")), + notMultiple: issueMetadataFromResult(z.number().multipleOf(5).safeParse(12)), + invalidIntersection: issueMetadataFromResult( + z.intersection(z.string().transform(() => 1), z.string().transform(() => 2)).safeParse("x"), + ), + custom: issueMetadataFromResult( + z.string() + .superRefine((value, ctx) => { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `custom:${value}`, params: { kind: "fixture" } }); + }) + .safeParse("x"), + ), +}); + +const manualError = new z.ZodError([]); +manualError.addIssue({ code: z.ZodIssueCode.custom, path: ["manual"], message: "manual issue" }); +manualError.addIssues([{ code: z.ZodIssueCode.custom, path: ["more"], message: "more issue" }]); +const createdError = z.ZodError.create([ + { code: z.ZodIssueCode.custom, path: ["create"], message: "created issue" }, +]); +const mappedFormat = objectBase.safeParse({ id: "x", name: 1 }); +const unionFormatted = z + .union([z.object({ a: z.string() }), z.object({ b: z.number() })]) + .safeParse({ a: 1, b: "x" }); +print("errorMethods", { + manualCount: manualError.issues.length, + manualEmpty: manualError.isEmpty, + manualMessage: manualError.message.includes("manual issue"), + manualToString: manualError.toString().includes("more issue"), + manualErrorsAlias: manualError.errors === manualError.issues, + manualFormErrors: manualError.formErrors.fieldErrors.manual?.[0], + manualFlatten: manualError.flatten((issue) => `${issue.path.join(".")}:${issue.message}`), + created: createdError.issues[0].message, + mappedFormat: mappedFormat.success + ? null + : mappedFormat.error.format((issue) => `${issue.code}:${issue.message}`).id?._errors, + unionFormat: unionFormatted.success + ? null + : unionFormatted.error.format((issue) => `${issue.path.join(".")}:${issue.code}`), + unionFlatten: unionFormatted.success + ? null + : unionFormatted.error.flatten((issue) => `${issue.path.join(".")}:${issue.code}`), +}); + +const formattingResult = z + .object({ + user: z.object({ + name: z.string(), + tags: z.array(z.string().min(2)).min(2), + }), + }) + .strict() + .safeParse({ user: { name: 1, tags: ["x"] }, extra: true }); +if (!formattingResult.success) { + const formattedError = formattingResult.error.format(); + print("errorFormatting", { + issueCodes: formattingResult.error.issues.map((issue) => issue.code), + formatKeys: Object.keys(formattedError.user ?? {}).sort(), + formatRoot: formattedError._errors, + formatName: formattedError.user?.name?._errors, + formatTags: formattedError.user?.tags?._errors, + formatTag0: formattedError.user?.tags?.[0]?._errors, + flatten: formattingResult.error.flatten(), + flattenMapped: formattingResult.error.flatten((issue) => `${issue.path.join(".")}:${issue.code}`), + formErrors: formattingResult.error.formErrors, + }); +} + +const customMessageString = z.string({ + required_error: "required string", + invalid_type_error: "not a string", +}); +const requiredStringResult = customMessageString.safeParse(undefined); +const invalidStringResult = customMessageString.safeParse(1); +const minMessageResult = z.string().min(3, "too short").safeParse("x"); +const parsePathResult = z.string().safeParse(1, { path: ["root"] }); +const perParseMapResult = z.string().safeParse(1, { + errorMap: (issue) => ({ message: `local:${issue.code}` }), +}); +print("customErrors", { + required: requiredStringResult.success ? "ok" : requiredStringResult.error.issues[0].message, + invalidType: invalidStringResult.success ? "ok" : invalidStringResult.error.issues[0].message, + min: minMessageResult.success ? "ok" : minMessageResult.error.issues[0].message, + path: parsePathResult.success ? "ok" : parsePathResult.error.issues[0].path.join("."), + perParseMap: perParseMapResult.success ? "ok" : perParseMapResult.error.issues[0].message, +}); + +const parseOptionsObjectResult = z.object({ item: z.string().min(2) }).safeParse({ item: "x" }, { path: ["root"] }); +const parseOptionsUnionResult = z.union([z.string(), z.number()]).safeParse(true, { + path: ["value"], + errorMap: (issue, ctx) => ({ message: `union:${issue.code}:${ctx.defaultError}` }), +}); +const parseOptionsAsyncResult = await z.string().min(2).safeParseAsync("x", { + path: ["asyncRoot"], + errorMap: (issue) => ({ message: `async:${issue.code}` }), +}); +print("parseOptions", { + object: issueMetadataFromResult(parseOptionsObjectResult), + union: issueMetadataFromResult(parseOptionsUnionResult), + async: issueMetadataFromResult(parseOptionsAsyncResult), +}); + +const summarySchema = z.array(userSchema.pick({ id: true, role: true })).min(1); +print("array", summarySchema.parse([{ id: 1, role: "admin" }])); + +const standardOk = objectBase["~standard"].validate({ id: 1, name: "a" }); +const standardBad = objectBase["~standard"].validate({ id: "x", name: 1 }); +const legacyStandardOk = (objectBase as any)["~validate"]({ id: 1, name: "a" }); +const legacyStandardBad = (objectBase as any)["~validate"]({ id: "x", name: 1 }); +const legacyAsyncStandardOk = await (z.string().refine(async (value) => value === "ok") as any)["~validate"]("ok"); +const legacyAsyncStandardBad = await (z.string().refine(async (value) => value === "ok") as any)["~validate"]("bad"); +print("standard", { + vendor: objectBase["~standard"].vendor, + version: objectBase["~standard"].version, + ok: "value" in standardOk ? standardOk.value : null, + badIssues: "issues" in standardBad ? standardBad.issues?.length : 0, + legacyOk: "value" in legacyStandardOk ? legacyStandardOk.value : null, + legacyBadIssues: "issues" in legacyStandardBad ? legacyStandardBad.issues?.map((issue: z.ZodIssue) => `${issue.path.join(".")}:${issue.code}`) : [], + legacyAsyncOk: "value" in legacyAsyncStandardOk ? legacyAsyncStandardOk.value : null, + legacyAsyncBad: "issues" in legacyAsyncStandardBad ? legacyAsyncStandardBad.issues?.[0].message : null, +}); + +const z3SubpathResult = z3.object({ id: z3.number(), name: z3.string().default("subpath") }).parse({ id: 1 }); +const z3SubpathBad = z3.object({ id: z3.number() }).safeParse({ id: "bad" }); +print("packageSubpaths", { + v3Default: z3SubpathResult.name, + v3Issue: z3SubpathBad.success ? "ok" : z3SubpathBad.error.issues[0].code, + v3DefaultEqualsNamed: z3Default === z3, + v3NamespaceHasZ: z3Namespace.z === z3, + v3NamespaceDefaultEqualsNamed: z3Namespace.default === z3, + v3NamespaceParse: z3Namespace.string().parse("v3 namespace"), +}); + +const originalErrorMap = z.getErrorMap(); +z.setErrorMap((issue, ctx) => ({ message: `global:${issue.code}:${ctx.defaultError}` })); +const mappedError = z.string().safeParse(1); +const schemaMappedError = z.string({ invalid_type_error: "schema invalid" }).safeParse(1); +const localMappedError = z.string().safeParse(1, { + errorMap: (issue, ctx) => ({ message: `local:${issue.code}:${ctx.defaultError}` }), +}); +z.setErrorMap(originalErrorMap); +print("errorMap", { + mapped: mappedError.success ? "ok" : mappedError.error.issues[0].message, + schema: schemaMappedError.success ? "ok" : schemaMappedError.error.issues[0].message, + local: localMappedError.success ? "ok" : localMappedError.error.issues[0].message, + restored: z.getErrorMap() === originalErrorMap, +}); diff --git a/tests/release/packages/zod3-basic/expected.txt b/tests/release/packages/zod3-basic/expected.txt new file mode 100644 index 000000000..63958f0c1 --- /dev/null +++ b/tests/release/packages/zod3-basic/expected.txt @@ -0,0 +1,52 @@ +parse={"id":7,"name":"ADA","tags":[],"role":"user","meta":{"active":true,"source":"seed"}} +safeParse.success=false +safeParse.issues=[{"path":"id","code":"too_small","message":"Number must be greater than 0"},{"path":"name","code":"too_small","message":"String must contain at least 2 character(s)"},{"path":"tags.1","code":"invalid_type","message":"Expected string, received number"},{"path":"meta.active","code":"invalid_type","message":"Expected boolean, received string"}] +primitives={"string":"x","number":1.5,"int":false,"boolean":false,"bigint":"10","symbol":"symbol","nan":true,"void":true,"null":null,"undefined":true,"any":1,"unknown":2,"never":false} +packageExports={"parsed":{"string":"string","nan":"nan","map":"map","promise":"promise"},"status":{"ok":{"status":"valid","value":1},"dirty":{"status":"dirty","value":2},"invalid":{"status":"aborted"},"isValid":true,"isDirty":true,"isAborted":true},"parsedType":{"string":"string","nan":"nan","map":"map","promise":"promise"},"typeKind":{"object":"ZodObject","pipeline":"ZodPipeline","readonly":"ZodReadonly"},"aliases":{"schema":true,"schemaInstance":true,"brand":"symbol","brandDescription":"zod_brand","defaultEqualsNamed":true,"namespaceHasZ":true,"namespaceDefaultEqualsNamed":true,"namespaceString":"namespace"},"names":["ZodString","ZodNumber","ZodObject","ZodError"]} +packageUtilities={"isAsync":[true,false],"parseStatus":{"dirty":"dirty","merged":["a","b"],"aborted":"aborted"},"parsedTypes":["null","array","map","bigint","promise","nan"],"statusHelpers":{"valid":["valid",true,false,false],"dirty":["dirty",false,true,false],"aborted":["aborted",false,false,true],"never":"aborted","emptyPathLength":0},"util":{"arrayToEnum":"b","validEnumValues":["a",1],"joinValues":"'a' | 1 | true","objectKeys":"b|a","objectValues":[1,2],"find":2,"isInteger":[true,false],"jsonStringifyReplacer":"{\"n\":\"1\"}"},"quoteless":10,"datetimeRegex":[true,false],"defaultError":"Expected string, received number"} +parseHelpers={"direct":{"code":"custom","path":"root.leaf","message":"direct"},"mapped":{"path":"root","message":"Expected number, received string"},"context":[{"path":"ctx","message":"Expected number, received string"}],"aliases":{"schema":true,"type":true,"transformer":true,"transformerParse":4}} +coerce={"string":"42","number":12.5,"boolean":true,"bigint":"42","date":"2020-01-02T00:00:00.000Z"} +primitiveEdges={"boolean":true,"booleanRejectsString":false,"coerceBooleanString":true,"coerceNumberRejectsNaN":false,"coerceBigintRejectsText":false,"coerceDateEpoch":"1970-01-01T00:00:00.000Z","coerceDateRejectsInvalid":false} +primitiveMetadata={"types":["ZodString","ZodNumber","ZodBigInt","ZodBoolean","ZodNaN","ZodSymbol","ZodAny","ZodUnknown","ZodNever","ZodUndefined","ZodNull","ZodVoid"],"date":{"typeName":"ZodDate","coerce":false,"minDate":"2020-01-01T00:00:00.000Z","maxDate":"2020-12-31T00:00:00.000Z","checks":[{"kind":"min","value":1577836800000,"message":"too old"},{"kind":"max","value":1609372800000,"message":"too new"}]},"coerceDate":true,"instanceof":{"typeName":"ZodEffects","inner":"ZodAny","effect":"refinement"}} +literals.enums={"literal":true,"literalBigint":true,"literalBoolean":false,"literalNull":null,"literalFail":false,"literalValue":"ready","literalTypeName":"ZodLiteral","enum":"blue","enumBlue":"blue","enumValues":"green","enumEnum":"red","enumOptions":"red|blue|green","enumTypeName":"ZodEnum","enumDefValues":"red|blue|green","enumKeys":["blue","green","red"],"enumExtract":false,"enumExtractOk":"red","enumExtractOptions":"red|green","enumExclude":"green","enumExcludeOptions":"red|green","enumRequiredMessage":"need color","enumInvalidTypeMessage":"bad color","nativeEnum":"a","nativeEnumNumber":2,"nativeEnumMixed":["text",0,1],"nativeEnumReverseRejected":[{"code":"invalid_enum_value","path":"","received":"Zero","options":["text",0,1]}],"nativeEnumTypeName":"ZodNativeEnum","nativeEnumKeys":"A|B","nativeEnumMixedKeys":"0|1|Text|Zero|One"} +strings={"value":"AB-YZ","email":true,"uuid":true,"url":true,"datetime":true,"datetimeOffset":true,"datetimePrecision":true,"datetimeLocal":true,"ip":true,"ipv4":true,"ipv6":true,"length":true,"nonempty":false,"timePrecision":false,"includesPosition":true,"includesPositionFail":false,"startsWith":true,"startsWithFail":false,"endsWith":true,"endsWithFail":false,"trim":"tuna","uppercase":"TUNA","lowercase":"abc","chainedTransform":"TUNA","cuid":true,"cuid2":true,"ulid":true,"emoji":true,"nanoid":true,"base64":true,"base64url":true,"jwt":true,"date":true,"time":true,"duration":true,"cidr":true,"cidrv4":true,"cidrv6":true,"regexFail":false} +schemaIntrospection={"stringBounds":[2,5],"stringFormats":{"email":{"isEmail":true,"isURL":false,"isUUID":false,"isDatetime":false,"isIP":false},"url":{"isEmail":false,"isURL":true,"isUUID":false,"isDatetime":false,"isIP":false},"uuid":{"isEmail":false,"isURL":false,"isUUID":true,"isDatetime":false,"isIP":false},"datetime":{"isEmail":false,"isURL":false,"isUUID":false,"isDatetime":true,"isIP":false},"ip":{"isEmail":false,"isURL":false,"isUUID":false,"isDatetime":false,"isIP":true}},"extraStringFormats":{"base64":true,"base64url":true,"cidr":true,"cuid":true,"cuid2":true,"date":true,"duration":true,"emoji":true,"nanoid":true,"time":true,"ulid":true},"numberBounds":[1,3],"numberFlags":{"isInt":true,"isFinite":true}} +numbers={"finite":false,"min":true,"max":false,"gt":true,"gte":true,"lt":false,"lte":true,"multiple":true,"step":true,"positive":true,"nonpositive":true,"negative":true,"nonnegative":true,"safe":false} +bigints={"min":true,"max":false,"gt":true,"gte":true,"lt":false,"lte":true,"multiple":true,"positive":true,"nonpositive":true,"negative":true,"nonnegative":true} +discriminatedUnion=[{"type":"text","value":"hello"},{"type":"count","value":3}] +discriminatedUnion.options={"length":2,"discriminator":"type","optionKeys":"text|count","typeName":"ZodDiscriminatedUnion","inherited":{"type":"count","value":3},"inheritedOwnType":true,"inheritedOwnValue":true,"inheritedInvalid":[{"code":"invalid_type","path":"value","expected":"number","received":"string"}],"multiValue":[{"kind":"text","body":"hello"},{"kind":"markdown","body":"**hello**"}],"multiValueOptionKeys":"text|markdown|count","multiValueInvalidKind":[{"code":"invalid_union_discriminator","path":"kind","options":["text","markdown","count"]}],"multiValueInvalidBranch":[{"code":"invalid_type","path":"body","expected":"string","received":"number"}]} +union={"results":[true,false],"options":2,"typeName":"ZodUnion","optionTypes":["ZodString","ZodNumber"],"branchFailure":[["value:invalid_type"],["kind:invalid_literal","count:invalid_type"]],"inheritedObject":{"kind":"b","count":2},"inheritedObjectOwnKind":true,"inheritedObjectOwnCount":true} +objects={"strip":{"id":1,"name":"a"},"explicitStrip":{"id":1,"name":"a"},"strict":false,"strictObject":false,"nonstrict":{"id":1,"name":"a","extra":true},"passthrough":{"id":1,"name":"a","extra":true},"catchall":true,"catchallFail":false,"extend":{"id":1,"name":"a","role":"admin"},"augment":{"id":1,"name":"a"},"setKey":{"id":1,"name":"a"},"merge":{"id":1,"name":"a","role":"user"},"strictMessage":false,"keyof":"name","shapeName":true,"pick":{"id":1},"omit":{"id":1,"name":"a"},"spreadShape":{"id":1,"name":"a","role":"user"},"inheritedInput":{"own":1},"inheritedOutput":{"own":1,"inherited":"from-proto"},"inheritedOwnOutput":true,"inheritedOwnInput":false,"inheritedOutputPrototype":true,"inheritedPassthrough":{"id":1,"inheritedExtra":"from-proto"},"inheritedPassthroughOwn":true,"inheritedStrict":false,"nullProto":{"id":1,"extra":"x"},"nullProtoStripped":{"id":1},"nullProtoInputPrototype":true,"nullProtoOutputPrototype":true,"nullProtoStrict":false,"getterInput":{"id":7,"inherited":"from-getter"},"getterPassthrough":{"id":7,"inherited":"from-getter"},"getterReads":[2,2],"getterOutputOwnInherited":true,"partial":{"id":1},"partialName":true,"deepPartial":{"nested":{}},"required":false,"requiredActive":false,"nestedRequired":true,"partialRequiredMask":{"baseMissingName":false,"partialMissingName":true,"partialMissingNested":false,"requiredMissingName":false,"requiredMissingOptionalAge":true,"base":{"requiredName":"ZodString","optionalAge":"ZodOptional","nested":"ZodObject"},"partial":{"requiredName":"ZodOptional","optionalAge":"ZodOptional","nested":"ZodObject"},"required":{"requiredName":"ZodString","optionalAge":"ZodOptional","nested":"ZodObject"},"partialNameInner":"ZodString"},"defaultedOptionalEmpty":{},"defaultedOptionalUndefined":{},"anyUnknownMissing":true,"unknownKeys":["strip","passthrough","strict","strip"],"catchallType":"ZodString","strictCreate":false,"strictCreateParse":1,"lazycreate":"lazy","shapeMetadata":{"partial":{"id":"ZodOptional","name":"ZodOptional","active":"ZodOptional"},"partialName":{"id":"ZodNumber","name":"ZodOptional","active":"ZodOptional"},"partialActiveInner":"ZodOptional","required":{"id":"ZodNumber","name":"ZodString","active":"ZodBoolean"},"requiredActive":{"id":"ZodNumber","name":"ZodString","active":"ZodBoolean"},"deepPartial":["ZodOptional","ZodOptional"]}} +objectMergeSemantics={"extendOverwrite":1,"strictWinsUnknownKeys":"strict","strictRejectsExtra":false,"passthroughWinsUnknownKeys":"passthrough","passthroughAllowsExtra":true,"catchallWinsType":"ZodNumber","catchallAcceptsNumber":true,"catchallRejectsString":false,"mergedShapeKeys":"a|b"} +recursiveObjects={"category":{"name":"root","subcategories":[{"name":"leaf","subcategories":[]}]},"categoryIssue":[{"code":"invalid_type","path":"subcategories.0.name","expected":"string","received":"number"},{"code":"invalid_type","path":"subcategories.0.subcategories","expected":"array","received":"undefined"}],"mutual":"a@example.com","mutualNested":"b@example.com","discriminatedRecursive":{"kind":"branch","children":[{"kind":"leaf","value":"x"}]},"discriminatedRecursiveIssue":[{"code":"invalid_type","path":"children.0.value","expected":"string","received":"number"}]} +arrays.tuples={"array":[1,2],"transformedArray":[2,3],"exactLength":true,"nonempty":false,"elementDescription":"array item","elementIssuePath":"0","typeName":"ZodArray","bounds":[1,3,2],"elementType":"ZodString","minMessage":"need two","maxMessage":"too many","lengthMessage":"exactly two","sparseOptional":["a",null,"c"],"sparseOptionalOwn1":true,"sparseRequiredIssue":[{"code":"invalid_type","path":"1","expected":"string","received":"undefined"}],"inheritedIndexArray":["a","proto","c"],"inheritedIndexArrayOwn1":true,"inheritedIndexTuple":["a","proto","c"],"inheritedIndexTupleOwn1":true,"tuple":["a",1],"tupleLengthIssue":"too_big","tupleRest":["a",1,2],"tupleItems":["ZodString","ZodNumber"],"tupleRestType":"ZodBoolean","tupleTypeName":"ZodTuple"} +collections={"record":true,"keyedRecord":true,"keyedRecordMissing":true,"stringKeyRecord":false,"transformedRecord":{"a":4},"recordFail":false,"recordNumberKey":[{"code":"invalid_type","path":"1"}],"recordNumericStringKey":{"1":"one"},"recordNumericStringFail":[{"code":"invalid_string","path":"abc"}],"inheritedRecord":{"own":1,"inherited":2},"inheritedRecordOwn":true,"inheritedRecordIssue":[{"code":"invalid_type","path":"badInherited"}],"nullProtoRecord":{"id":1,"extra":"x"},"hiddenSymbolRecord":{"own":1},"hiddenSymbolRecordHasHidden":false,"hiddenSymbolRecordSymbols":0,"map":[["a",1],["b",2]],"mapKeyFail":false,"mapFail":false,"transformedMap":[["size",4]],"set":["a","b"],"setNonempty":false,"setFail":false,"setMax":false,"setSize":true,"transformedSet":[2,3],"recordMeta":{"typeName":"ZodRecord","key":"ZodString","value":"ZodNumber","element":"ZodNumber"},"mapMeta":{"typeName":"ZodMap","key":"ZodString","value":"ZodNumber"},"setMeta":{"typeName":"ZodSet","value":"ZodString","min":1,"max":3,"size":[2,2]},"setMessages":["need two","too many","exactly two"],"recordIssues":[{"code":"invalid_type","path":"bad"}],"mapKeyIssues":[{"code":"too_small","path":"0.key"}],"mapValueIssues":[{"code":"invalid_type","path":"0.value"}],"setElementIssues":[{"code":"too_small","path":"0"}],"setSizeIssues":[{"code":"too_small","path":""}]} +cloneSemantics={"objectIdentity":true,"nestedIdentity":true,"arrayIdentity":true,"objectSource":{"nested":{"label":"x"},"tags":["a"]},"mapIdentity":true,"mapValueIdentity":true,"mapSource":[["a",{"count":1}]],"subclassMapIsMap":true,"subclassMapIsSubclass":false,"subclassMapValueIdentity":true,"subclassMapSource":[["a",{"count":1}]],"subclassMapParsed":[["a",{"count":2}]],"mapObjectKeyIdentity":true,"mapObjectValueIdentity":true,"mapObjectSource":[[{"id":1},{"count":1}]],"mapObjectParsed":[[{"id":2},{"count":2}]],"setIdentity":true,"setValueIdentity":true,"setSource":[{"id":1}],"subclassSetIsSet":true,"subclassSetIsSubclass":false,"subclassSetValueIdentity":true,"subclassSetSource":[{"id":1}],"subclassSetParsed":[{"id":2}],"dateIdentity":true,"dateSource":"2020-01-02T00:00:00.000Z","dateParsed":"2021-01-02T00:00:00.000Z"} +composition={"intersection":{"a":"x","b":1},"inheritedIntersection":{"a":"x","b":2},"inheritedIntersectionOwnB":true,"nestedIntersection":{"nested":{"a":"x","b":1},"shared":"same"},"conflictingIntersection":[{"code":"invalid_intersection_types","path":""}],"arrayIntersectionConflict":[{"code":"invalid_intersection_types","path":""}],"intersectionTypeName":"ZodIntersection","intersectionSides":["ZodObject","ZodObject"],"transformedIntersection":{"a":1,"b":2},"or":5,"and":{"a":"x","b":true},"strictObject":false,"schemaArray":["a","b"],"schemaOr":"b","schemaOrIssue":[{"code":"too_small","path":"","minimum":10,"inclusive":true,"exact":false,"type":"number"}],"schemaAnd":{"a":"x","b":1},"pipelineFactory":true} +transform=["a","b","c"] +refine=false +effects={"preprocess":"ok","pipe":true,"superRefine":false,"custom":true,"refinementAlias":"bad refinement","innerType":"inner","sourceType":"source","refinePath":"confirm","refineMessage":"password mismatch","fatalCalls":["first:x"],"fatalIssue":"stop","fatalFlag":true,"neverTransform":"bad transform:x","neverStatus":"aborted","metadata":{"preprocess":["ZodEffects","preprocess","ZodString","ZodString","ZodString"],"transform":["ZodEffects","transform","ZodString","ZodString","ZodString"],"refine":["ZodEffects","refinement","ZodString"],"pipeline":["ZodPipeline","ZodEffects","ZodNumber","ZodString","ZodNumber"]}} +customSchemas={"unchecked":1,"predicate":true,"message":"not a token"} +descriptions={"original":null,"string":["fixture string","fixture string","ok"],"object":["fixture object","fixture object","identifier",1],"array":["fixture array","fixture array","array item",1],"optional":["outer optional","outer optional","inner",true]} +modifiers={"ostring":true,"onumber":true,"oboolean":true,"optional":true,"nullable":true,"nullish":true,"default":"fallback","defaultFunction":"factory","defaultSkipsPresent":"present","defaultFactorySequence":["fallback-1","fallback-2","present",2],"defaultTransform":4,"removeDefault":false,"catch":9,"catchTransform":9,"catchFunction":1,"catchFactorySequence":[2,3,1],"catchContext":"bad:invalid_type","removeCatch":false,"brand":"id-1","described":"fixture string","describedObject":"fixture object","describedParse":"described","optionalUnwrap":"wrapped","nullableUnwrap":"wrapped","arrayElement":"element","promiseUnwrap":3,"isOptional":true,"isNullable":true,"readonlyFrozen":true,"readonlyArray":true,"readonlyTuple":true,"readonlyMap":true,"readonlySet":true,"readonlyMutations":{"object":[false,1],"nestedObject":[true,false,false,false,true,"ok",{"nested":{"id":2},"tags":["a","b"]},{"nested":{"id":1},"tags":["a"]},true,true],"array":[false,"TypeError","a",1],"tuple":[false,"TypeError","a",1],"map":["ok",2,2],"set":["ok",2,true]},"schemaArray":2,"schemaOr":2,"schemaAnd":{"a":"x","b":1},"metadata":{"optional":["ZodOptional","ZodString"],"nullable":["ZodNullable","ZodString"],"default":["ZodDefault","fallback"],"catch":["ZodCatch",9],"promise":["ZodPromise","ZodNumber"],"readonly":["ZodReadonly","ZodObject"],"branded":["ZodBranded","ZodString",true,"ZodBranded"]}} +lazy={"parsed":{"name":"root","children":[{"name":"leaf"}]},"cloneIdentity":true,"childCloneIdentity":true,"source":{"name":"root","children":[{"name":"leaf"}]},"mutated":{"name":"root","children":[{"name":"changed"}]}} +factories={"arrayFactory":"factory","optionalFactory":true,"nullableFactory":true,"promiseFactory":4,"ostringFactory":true,"onumberFactory":3,"obooleanFactory":true,"unionFactory":"right","intersectionFactory":{"a":"x","b":2},"lazyFactory":"lazy","lateObject":{"name":"root","children":[{"name":"leaf","children":[]}]}} +staticFactories={"string":"x","number":1,"nan":true,"array":1,"object":1,"tuple":1,"record":"x","map":[["a",1]],"set":["a"],"optional":true,"nullable":true,"promise":2,"readonly":true,"effectAlias":4,"transformerAlias":"TUNA","objectUtil":{"a":"x","shared":2,"b":1},"literal":"ready","enum":"blue","nativeEnum":"a","date":"2020-01-02T00:00:00.000Z","bigint":"2","boolean":true,"any":true,"unknown":1,"never":false,"undefined":true,"null":true,"void":true,"symbol":"symbol","union":3,"intersection":{"a":"x","b":1},"discriminatedUnion":2,"function":3,"lazy":"lazy","effects":4,"default":"fallback","catch":7,"pipeline":6} +jsonLike={"object":true,"rejectsFunction":false,"rejectsSymbol":false,"rejectsBigint":false,"badNested":[{"code":"invalid_union","path":""}],"fromString":{"a":[1,true,null]},"mapSize":true,"mapSizeFail":[{"code":"custom","path":"box.size"}],"parsed":{"ok":true,"items":[1,"two"]}} +instances.dates={"instanceof":"ok","instanceMessage":"not a box","date":"2020-01-02T00:00:00.000Z","dateRequired":"required date","dateInvalid":"not a date","dateMin":true,"dateMax":false} +function={"valid":4,"strictValid":5,"validateAlias":4,"restValid":8,"restInvalid":["2:invalid_type"],"asyncValid":6,"cloneSemantics":{"argIdentity":true,"nestedArgIdentity":true,"source":{"nested":{"id":1}},"returnIdentity":true,"nestedReturnIdentity":true,"returned":{"nested":{"id":3}}},"parameters":2,"returnType":true,"invalidArgs":true,"invalidReturns":true,"argumentIssues":[{"code":"invalid_arguments","path":"","nestedCount":1}],"returnIssues":[{"code":"invalid_return_type","path":"","nestedCount":1}],"strictReturnIssues":[{"code":"invalid_return_type","path":"","nestedCount":1}],"invalidAsyncReturn":true,"metadata":{"typed":{"typeName":"ZodFunction","argsType":"ZodTuple","argItems":["ZodString","ZodNumber"],"argRest":"ZodUnknown","returns":"ZodBoolean","parameters":["ZodString","ZodNumber"],"returnType":"ZodBoolean"},"defaults":{"args":0,"rest":"ZodUnknown","returns":"ZodUnknown"},"argsOnly":{"args":["ZodString"],"returns":"ZodUnknown"},"returnsOnly":{"args":0,"returns":"ZodNumber"}}} +async={"refine":{"success":true,"data":"ok"},"parseAsync":"ok","spa":true,"promise":5,"methodPromise":3,"promiseRejects":true,"rejectedReason":"promise boom"} +asyncEffects={"transform":4,"pipe":{"success":true,"data":3},"superRefineMessage":"bad async","refineMessage":"not ok","preprocess":"ok","syncThrows":{"transformParse":"Error:Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.","transformSafeParse":"Error:Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.","refineParse":"Error:Async refinement encountered during synchronous parse operation. Use .parseAsync instead.","refineSafeParse":"Error:Async refinement encountered during synchronous parse operation. Use .parseAsync instead."},"metadata":{"refine":["ZodEffects","refinement","ZodString"],"transform":["ZodEffects","transform","ZodString"],"pipe":["ZodPipeline","ZodEffects","ZodNumber"],"superRefine":["ZodEffects","refinement","ZodString"],"preprocess":["ZodEffects","preprocess","ZodString"],"promise":["ZodPromise","ZodNumber"]}} +asyncIssues={"parseAsync":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":false,"type":"string"}],"pipe":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":false,"type":"number"}],"preprocess":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":false,"type":"string"}],"promiseInner":[{"code":"invalid_type","path":"","expected":"number","received":"string"}]} +promiseSchemas={"safeParse":true,"safeValue":8,"parseThen":"function","parseValue":2,"nonPromise":[{"code":"invalid_type","path":"","expected":"promise","received":"number"}],"inner":[{"code":"invalid_type","path":"","expected":"number","received":"string"}],"object":{"id":1},"cloneIdentity":true,"cloneNestedIdentity":true,"cloneSource":{"nested":{"id":1}},"cloneParsed":{"nested":{"id":2}},"methodType":"ZodNumber"} +effectFactories={"preprocessNumber":3,"preprocessIssue":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":false,"type":"number"}],"pipelineObject":{"len":3},"pipelineIssue":[{"code":"too_small","path":"len","minimum":2,"inclusive":true,"exact":false,"type":"number"}],"transformIssue":[{"code":"custom","path":""}]} +errors={"formatKeys":["_errors","id","name"],"flatten":{"formErrors":[],"fieldErrors":{"id":["Expected number, received string"],"name":["Expected string, received number"]}},"nestedTagErrors":["Array must contain at least 2 element(s)"],"nestedTagItemErrors":["String must contain at least 2 character(s)"],"unionIssue":"invalid_union","unionBranchIssueCounts":[1,2],"unionFirstBranchPath":"value","discriminatedIssue":"invalid_union_discriminator","discriminatedPath":"type"} +issueMetadata={"literal":[{"code":"invalid_literal","path":"","expected":"ready","received":"no"}],"invalidType":[{"code":"invalid_type","path":"","expected":"number","received":"string"}],"enum":[{"code":"invalid_enum_value","path":"","received":"green","options":["red","blue"]}],"invalidString":[{"code":"invalid_string","path":"","validation":"email"}],"invalidRegex":[{"code":"invalid_string","path":"","validation":"regex"}],"strict":[{"code":"unrecognized_keys","path":"","keys":["extra"]}],"invalidDate":[{"code":"invalid_date","path":""}],"notFinite":[{"code":"not_finite","path":""}],"dateTooSmall":[{"code":"too_small","path":"","minimum":1577923200000,"inclusive":true,"exact":false,"type":"date"}],"dateTooBig":[{"code":"too_big","path":"","maximum":1577923200000,"inclusive":true,"exact":false,"type":"date"}],"tooBigArray":[{"code":"too_big","path":"","maximum":1,"inclusive":true,"exact":false,"type":"array"}],"tooSmallNumber":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":false,"type":"number"}],"exactArray":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":true,"type":"array"}],"exactString":[{"code":"too_small","path":"","minimum":2,"inclusive":true,"exact":true,"type":"string"}],"notMultiple":[{"code":"not_multiple_of","path":"","multipleOf":5}],"invalidIntersection":[{"code":"invalid_intersection_types","path":""}],"custom":[{"code":"custom","path":"","params":{"kind":"fixture"}}]} +errorMethods={"manualCount":2,"manualEmpty":false,"manualMessage":true,"manualToString":true,"manualErrorsAlias":true,"manualFormErrors":"manual issue","manualFlatten":{"formErrors":[],"fieldErrors":{"manual":["manual:manual issue"],"more":["more:more issue"]}},"created":"created issue","mappedFormat":["invalid_type:Expected number, received string"],"unionFormat":{"_errors":[],"a":{"_errors":["a:invalid_type"]},"b":{"_errors":["b:invalid_type"]}},"unionFlatten":{"formErrors":[":invalid_union"],"fieldErrors":{}}} +errorFormatting={"issueCodes":["invalid_type","too_small","too_small","unrecognized_keys"],"formatKeys":["_errors","name","tags"],"formatRoot":["Unrecognized key(s) in object: 'extra'"],"formatName":["Expected string, received number"],"formatTags":["Array must contain at least 2 element(s)"],"formatTag0":["String must contain at least 2 character(s)"],"flatten":{"formErrors":["Unrecognized key(s) in object: 'extra'"],"fieldErrors":{"user":["Expected string, received number","Array must contain at least 2 element(s)","String must contain at least 2 character(s)"]}},"flattenMapped":{"formErrors":[":unrecognized_keys"],"fieldErrors":{"user":["user.name:invalid_type","user.tags:too_small","user.tags.0:too_small"]}},"formErrors":{"formErrors":["Unrecognized key(s) in object: 'extra'"],"fieldErrors":{"user":["Expected string, received number","Array must contain at least 2 element(s)","String must contain at least 2 character(s)"]}}} +customErrors={"required":"required string","invalidType":"not a string","min":"too short","path":"root","perParseMap":"local:invalid_type"} +parseOptions={"object":[{"code":"too_small","path":"root.item","minimum":2,"inclusive":true,"exact":false,"type":"string"}],"union":[{"code":"invalid_union","path":"value"}],"async":[{"code":"too_small","path":"asyncRoot","minimum":2,"inclusive":true,"exact":false,"type":"string"}]} +array=[{"id":1,"role":"admin"}] +standard={"vendor":"zod","version":1,"ok":{"id":1,"name":"a"},"badIssues":2,"legacyOk":{"id":1,"name":"a"},"legacyBadIssues":["id:invalid_type","name:invalid_type"],"legacyAsyncOk":"ok","legacyAsyncBad":"Invalid input"} +packageSubpaths={"v3Default":"subpath","v3Issue":"invalid_type","v3DefaultEqualsNamed":true,"v3NamespaceHasZ":true,"v3NamespaceDefaultEqualsNamed":true,"v3NamespaceParse":"v3 namespace"} +errorMap={"mapped":"global:invalid_type:Expected string, received number","schema":"schema invalid","local":"local:invalid_type:global:invalid_type:Expected string, received number","restored":true} diff --git a/tests/release/packages/zod3-basic/fixture.sh b/tests/release/packages/zod3-basic/fixture.sh new file mode 100755 index 000000000..3ea94e0de --- /dev/null +++ b/tests/release/packages/zod3-basic/fixture.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -uo pipefail +cd "$(dirname "$0")" +. "$(dirname "$0")/../_fixture_lib.sh" + +NAME="zod3-basic" + +now_ms() { + python3 -c 'import time; print(time.perf_counter_ns() // 1000000)' +} + +fixture_setup "$NAME" || exit 1 + +node entry.ts > node-out.txt 2> node-run.log || { + echo "FAIL $NAME - node reference errored" + sed 's/^/ /' node-run.log | tail -40 + exit 1 +} +if ! diff -u expected.txt node-out.txt > node-diff.log; then + echo "FAIL $NAME - committed expected.txt differs from node output" + sed 's/^/ /' node-diff.log + exit 1 +fi + +start_ms="$(now_ms)" +echo " [perry compile] entry.ts" +if ! "$PERRY_BIN" compile entry.ts -o ./out > perry-compile.log 2>&1; then + echo "FAIL $NAME - perry compile errored" + sed 's/^/ /' perry-compile.log | tail -80 + exit 1 +fi +compile_done_ms="$(now_ms)" + +echo " [./out] (timeout=${PERRY_FIXTURE_TIMEOUT_SECS}s)" +set +e +_fixture_run_with_timeout "$PERRY_FIXTURE_TIMEOUT_SECS" ./out > perry-out.txt 2> perry-run.log +rc=$? +set -e +run_done_ms="$(now_ms)" + +cat > zod3-perf.txt < diff.log; then + echo "FAIL $NAME - output diverges" + sed 's/^/ /' diff.log + exit 1 +fi + +echo " [perf] $(tr '\n' ' ' < zod3-perf.txt)" +echo "PASS $NAME" diff --git a/tests/release/packages/zod3-basic/package-lock.json b/tests/release/packages/zod3-basic/package-lock.json new file mode 100644 index 000000000..76f3118cc --- /dev/null +++ b/tests/release/packages/zod3-basic/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "perry-release-fixture-zod3-basic", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-zod3-basic", + "version": "0.0.0", + "dependencies": { + "zod": "3.25.76" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tests/release/packages/zod3-basic/package.json b/tests/release/packages/zod3-basic/package.json new file mode 100644 index 000000000..76338df45 --- /dev/null +++ b/tests/release/packages/zod3-basic/package.json @@ -0,0 +1,16 @@ +{ + "name": "perry-release-fixture-zod3-basic", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tier-3 fixture: Zod 3 parse, safeParse, unions, defaults, transforms and refinements under Perry.", + "dependencies": { + "zod": "3.25.76" + }, + "perry": { + "compilePackages": ["zod"], + "allow": { + "compilePackages": ["zod"] + } + } +} diff --git a/tests/release/packages/zod4-basic/entry.ts b/tests/release/packages/zod4-basic/entry.ts new file mode 100644 index 000000000..8f30e33dc --- /dev/null +++ b/tests/release/packages/zod4-basic/entry.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +// ---- object schema: parse, safeParse (ok + fail), defaults, optionals ---- +const User = z.object({ + name: z.string().min(2), + age: z.number().int().nonnegative(), + email: z.string().email().optional(), + tags: z.array(z.string()).default([]), + role: z.enum(["admin", "user", "guest"]), +}); + +const okUser = User.parse({ name: "Ada", age: 36, role: "admin" }); +const badUser = User.safeParse({ name: "A", age: -1, role: "root", email: "nope" }); + +// ---- unions / discriminated unions ---- +const Shape = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("circle"), r: z.number() }), + z.object({ kind: z.literal("rect"), w: z.number(), h: z.number() }), +]); +const circle = Shape.parse({ kind: "circle", r: 2 }); +const rectBad = Shape.safeParse({ kind: "rect", w: 3 }); + +// ---- transforms / refinements / pipes / coercion ---- +const Slug = z.string().transform((s) => s.trim().toLowerCase().replace(/\s+/g, "-")); +const Even = z.number().refine((n) => n % 2 === 0, { message: "must be even" }); +const AgeFromString = z.coerce.number().pipe(z.number().int().positive()); + +// ---- records / tuples / nullable ---- +const Scores = z.record(z.string(), z.number()); +const Pair = z.tuple([z.string(), z.number()]); +const MaybeName = z.string().nullable(); + +// ---- nested + partial-ish ---- +const Config = z.object({ + server: z.object({ host: z.string(), port: z.number().default(8080) }), + flags: z.array(z.boolean()), +}); + +const out = { + okUser, + badOk: badUser.success, + badPaths: badUser.success ? [] : badUser.error.issues.map((i) => i.path.join(".")).sort(), + badCount: badUser.success ? 0 : badUser.error.issues.length, + circle, + rectBadOk: rectBad.success, + slug: Slug.parse(" Hello World Foo "), + evenOk: Even.safeParse(10).success, + evenBad: Even.safeParse(7).success, + ageFromStr: AgeFromString.parse("42"), + scores: Scores.parse({ a: 1, b: 2 }), + pair: Pair.parse(["x", 9]), + maybeNull: MaybeName.parse(null), + config: Config.parse({ server: { host: "h" }, flags: [true, false] }), + isZodError: !badUser.success && badUser.error instanceof z.ZodError, +}; + +console.log(JSON.stringify(out)); diff --git a/tests/release/packages/zod4-basic/expected.txt b/tests/release/packages/zod4-basic/expected.txt new file mode 100644 index 000000000..1c2c3b3fb --- /dev/null +++ b/tests/release/packages/zod4-basic/expected.txt @@ -0,0 +1 @@ +{"okUser":{"name":"Ada","age":36,"tags":[],"role":"admin"},"badOk":false,"badPaths":["age","email","name","role"],"badCount":4,"circle":{"kind":"circle","r":2},"rectBadOk":false,"slug":"hello-world-foo","evenOk":true,"evenBad":false,"ageFromStr":42,"scores":{"a":1,"b":2},"pair":["x",9],"maybeNull":null,"config":{"server":{"host":"h","port":8080},"flags":[true,false]},"isZodError":true} diff --git a/tests/release/packages/zod4-basic/fixture.sh b/tests/release/packages/zod4-basic/fixture.sh new file mode 100755 index 000000000..4235e637d --- /dev/null +++ b/tests/release/packages/zod4-basic/fixture.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -uo pipefail +cd "$(dirname "$0")" +. "$(dirname "$0")/../_fixture_lib.sh" + +NAME="zod4-basic" + +now_ms() { + python3 -c 'import time; print(time.perf_counter_ns() // 1000000)' +} + +fixture_setup "$NAME" || exit 1 + +node entry.ts > node-out.txt 2> node-run.log || { + echo "FAIL $NAME - node reference errored" + sed 's/^/ /' node-run.log | tail -40 + exit 1 +} +if ! diff -u expected.txt node-out.txt > node-diff.log; then + echo "FAIL $NAME - committed expected.txt differs from node output" + sed 's/^/ /' node-diff.log + exit 1 +fi + +start_ms="$(now_ms)" +echo " [perry compile] entry.ts" +if ! "$PERRY_BIN" compile entry.ts -o ./out > perry-compile.log 2>&1; then + echo "FAIL $NAME - perry compile errored" + sed 's/^/ /' perry-compile.log | tail -80 + exit 1 +fi +compile_done_ms="$(now_ms)" + +echo " [./out] (timeout=${PERRY_FIXTURE_TIMEOUT_SECS}s)" +set +e +_fixture_run_with_timeout "$PERRY_FIXTURE_TIMEOUT_SECS" ./out > perry-out.txt 2> perry-run.log +rc=$? +set -e +run_done_ms="$(now_ms)" + +cat > zod4-perf.txt < diff.log; then + echo "FAIL $NAME - output diverges" + sed 's/^/ /' diff.log + exit 1 +fi + +echo " [perf] $(tr '\n' ' ' < zod4-perf.txt)" +echo "PASS $NAME" diff --git a/tests/release/packages/zod4-basic/package-lock.json b/tests/release/packages/zod4-basic/package-lock.json new file mode 100644 index 000000000..150211804 --- /dev/null +++ b/tests/release/packages/zod4-basic/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "perry-release-fixture-zod4-basic", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-zod4-basic", + "version": "0.0.0", + "dependencies": { + "zod": "4.4.3" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tests/release/packages/zod4-basic/package.json b/tests/release/packages/zod4-basic/package.json new file mode 100644 index 000000000..0279b0c89 --- /dev/null +++ b/tests/release/packages/zod4-basic/package.json @@ -0,0 +1,16 @@ +{ + "name": "perry-release-fixture-zod4-basic", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tier-3 fixture: Zod 4 parse, safeParse, discriminated unions, transforms, refinements, coercion and pipes under Perry.", + "dependencies": { + "zod": "4.4.3" + }, + "perry": { + "compilePackages": ["zod"], + "allow": { + "compilePackages": ["zod"] + } + } +} diff --git a/tests/test_array_not_instanceof_error_subclass.sh b/tests/test_array_not_instanceof_error_subclass.sh new file mode 100755 index 000000000..ccce0c29c --- /dev/null +++ b/tests/test_array_not_instanceof_error_subclass.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/main.ts" <<'TS' +class ArraySubclass extends Array {} +class MapSubclass extends Map {} +class SetSubclass extends Set {} +class CustomA extends Error {} +class CustomB extends Error {} + +const arrays = [ + [], + [{}], + [{ code: "invalid_format", path: [], message: "Invalid URL", format: "url" }], + new Array(1), + new Array(2), + [new CustomA("x")], +]; + +for (let i = 0; i < arrays.length; i++) { + console.log(i, arrays[i] instanceof Error, arrays[i] instanceof CustomA, arrays[i] instanceof CustomB); +} + +const values = [ + ["array", new ArraySubclass(1, 2)], + ["map", new MapSubclass([["a", 1]])], + ["set", new SetSubclass([1])], + ["error", new CustomA("boom")], +] as const; + +for (const [name, value] of values) { + console.log(name, JSON.stringify({ + ArraySubclass: value instanceof ArraySubclass, + Array: value instanceof Array, + MapSubclass: value instanceof MapSubclass, + Map: value instanceof Map, + SetSubclass: value instanceof SetSubclass, + Set: value instanceof Set, + CustomA: value instanceof CustomA, + Error: value instanceof Error, + Object: value instanceof Object, + })); +} +TS + +cat >"$TMPDIR/expected.log" <<'EOF_EXPECTED' +0 false false false +1 false false false +2 false false false +3 false false false +4 false false false +5 false false false +array {"ArraySubclass":true,"Array":true,"MapSubclass":false,"Map":false,"SetSubclass":false,"Set":false,"CustomA":false,"Error":false,"Object":true} +map {"ArraySubclass":false,"Array":false,"MapSubclass":true,"Map":true,"SetSubclass":false,"Set":false,"CustomA":false,"Error":false,"Object":true} +set {"ArraySubclass":false,"Array":false,"MapSubclass":false,"Map":false,"SetSubclass":true,"Set":true,"CustomA":false,"Error":false,"Object":true} +error {"ArraySubclass":false,"Array":false,"MapSubclass":false,"Map":false,"SetSubclass":false,"Set":false,"CustomA":true,"Error":true,"Object":true} +EOF_EXPECTED + +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +COMPILE_OUT="$(PERRY_ALLOW_PERRY_FEATURES=1 "$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$TMPDIR/out" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} + +"$TMPDIR/out" >"$TMPDIR/stdout.log" 2>"$TMPDIR/stderr.log" || { + echo "FAIL: compiled binary errored" + sed 's/^/ /' "$TMPDIR/stdout.log" + sed 's/^/ /' "$TMPDIR/stderr.log" + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/stdout.log"; then + echo "FAIL: stdout mismatch" + exit 1 +fi + +if [[ -s "$TMPDIR/stderr.log" ]]; then + echo "FAIL: expected empty stderr" + sed 's/^/ /' "$TMPDIR/stderr.log" + exit 1 +fi + +echo "PASS: arrays are not Error subclass instances" diff --git a/tests/test_bigint_samevalue_map.sh b/tests/test_bigint_samevalue_map.sh new file mode 100644 index 000000000..cade128ac --- /dev/null +++ b/tests/test_bigint_samevalue_map.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# BigInt keys must compare by value, not by allocation identity, in Map/WeakMap +# just like Set (test_bigint_samevalue_set.sh). The literal `1n` in the lookup +# is a distinct allocation from the one stored, so a bit-identity comparison +# would miss it. +cat > "$TMPDIR/main.ts" <<'TS' +const m = new Map([[1n, "a"], [2n, "b"]]); +const big = 9007199254740993n; +m.set(big, "big"); +console.log(JSON.stringify({ + get1: m.get(1n), + get2: m.get(2n), + getBig: m.get(9007199254740993n), + has: m.has(1n), + dedupes: new Map([[3n, "x"], [3n, "y"]]).size, + getMissing: m.get(99n) ?? "none", +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"get1":"a","get2":"b","getBig":"big","has":true,"dedupes":1,"getMissing":"none"} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: BigInt SameValue Map lookup" diff --git a/tests/test_bigint_samevalue_set.sh b/tests/test_bigint_samevalue_set.sh new file mode 100755 index 000000000..f2b7534a5 --- /dev/null +++ b/tests/test_bigint_samevalue_set.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +console.log(JSON.stringify({ + objectIs: Object.is(2n, 2n), + setHas: new Set([2n]).has(2n), + setDedupes: new Set([2n, 2n]).size, + arrayIncludes: [2n].includes(2n), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"objectIs":true,"setHas":true,"setDedupes":1,"arrayIncludes":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: BigInt SameValue and Set lookup" diff --git a/tests/test_builtin_prototype_and_fetch_reflection.sh b/tests/test_builtin_prototype_and_fetch_reflection.sh new file mode 100755 index 000000000..d97c8cc00 --- /dev/null +++ b/tests/test_builtin_prototype_and_fetch_reflection.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function protoOwn(name: string) { + const proto = (globalThis as any)[name]?.prototype; + if (!proto) return null; + return { + hasOwn: Object.prototype.hasOwnProperty.call(proto, "isPrototypeOf"), + hasOwnStatic: Object.hasOwn(proto, "isPrototypeOf"), + descriptorIsUndefined: Object.getOwnPropertyDescriptor(proto, "isPrototypeOf") === undefined, + namesHas: Object.getOwnPropertyNames(proto).includes("isPrototypeOf"), + }; +} + +const blob = new Blob(["hello"], { type: "text/plain" }); +const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + +console.log(JSON.stringify({ + prototypes: { + Array: protoOwn("Array"), + Date: protoOwn("Date"), + RegExp: protoOwn("RegExp"), + Promise: protoOwn("Promise"), + Map: protoOwn("Map"), + Set: protoOwn("Set"), + Blob: protoOwn("Blob"), + File: protoOwn("File"), + Object: protoOwn("Object"), + }, + blob: { + toString: Object.prototype.toString.call(blob), + tag: (blob as any)[Symbol.toStringTag], + ctorName: (blob as any).constructor?.name, + hasCtor: "constructor" in (blob as any), + }, + file: { + toString: Object.prototype.toString.call(file), + tag: (file as any)[Symbol.toStringTag], + ctorName: (file as any).constructor?.name, + hasCtor: "constructor" in (file as any), + }, +})); +TS + +cat > "$TMPDIR/expected.log" <<'LOG' +{"prototypes":{"Array":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Date":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"RegExp":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Promise":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Map":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Set":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Blob":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"File":{"hasOwn":false,"hasOwnStatic":false,"descriptorIsUndefined":true,"namesHas":false},"Object":{"hasOwn":true,"hasOwnStatic":true,"descriptorIsUndefined":false,"namesHas":true}},"blob":{"toString":"[object Blob]","tag":"Blob","ctorName":"Blob","hasCtor":true},"file":{"toString":"[object File]","tag":"File","ctorName":"File","hasCtor":true}} +LOG + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +PERRY_DIR="$(cd "$(dirname "$PERRY")" && pwd)" +if [[ -f "$PERRY_DIR/libperry_runtime.a" && -f "$PERRY_DIR/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$PERRY_DIR" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: builtin prototype and fetch reflection" diff --git a/tests/test_builtin_subclass_implicit_ctor_fields.sh b/tests/test_builtin_subclass_implicit_ctor_fields.sh new file mode 100644 index 000000000..7ba295eb1 --- /dev/null +++ b/tests/test_builtin_subclass_implicit_ctor_fields.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/_perry_test_lib.sh" + +# A subclass of a builtin (Error/Map/Set/Array) with NO explicit constructor +# must still run its instance field initializers — they run right after the +# implicit super(). Regression: the synthesized default constructor dropped +# them (new E().tag was 0 instead of the initializer value). The explicit-ctor +# case (M2) already worked and guards against a regression the other way. +perry_run main.ts <<'TS' +class E1 extends Error { tag = "e1"; count = 1 + 2; } +class M1 extends Map { tag = "m1"; } +class S1 extends Set { tag = "s1"; } +class A1 extends Array { tag = "a1"; } +class M2 extends Map { tag = "m2"; constructor() { super(); } } + +const e = new E1(); +console.log(JSON.stringify({ + errTag: e.tag, + errCount: (e as any).count, + errIsError: e instanceof Error, + errMessage: (() => { try { throw new E1("boom"); } catch (x: any) { return x.message; } })(), + mapTag: new M1().tag, + setTag: new S1().tag, + arrTag: (new A1() as any).tag, + explicitCtorTag: new M2().tag, +})); +TS + +perry_expect_node +perry_pass "builtin subclass implicit-ctor field initializers" diff --git a/tests/test_class_define_property_name.sh b/tests/test_class_define_property_name.sh new file mode 100755 index 000000000..57ffbdc09 --- /dev/null +++ b/tests/test_class_define_property_name.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function f() {} +Object.defineProperty(f, "name", { value: "FunctionName" }); + +class PlainClass {} +Object.defineProperty(PlainClass, "name", { value: "PlainName" }); + +class Definition extends Error {} +Object.defineProperty(Definition, "name", { value: "$ZodError" }); +const err = new Definition("boom"); + +console.log(JSON.stringify({ + functionName: f.name, + functionDescriptor: Object.getOwnPropertyDescriptor(f, "name")?.value, + className: PlainClass.name, + classDescriptor: Object.getOwnPropertyDescriptor(PlainClass, "name")?.value, + errorCtor: err.constructor.name, + errorCtorDescriptor: Object.getOwnPropertyDescriptor(err.constructor, "name")?.value, +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"functionName":"FunctionName","functionDescriptor":"FunctionName","className":"PlainName","classDescriptor":"PlainName","errorCtor":"$ZodError","errorCtorDescriptor":"$ZodError"} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: class defineProperty name" diff --git a/tests/test_dynamic_arithmetic_bigint_any.sh b/tests/test_dynamic_arithmetic_bigint_any.sh new file mode 100755 index 000000000..698fbc388 --- /dev/null +++ b/tests/test_dynamic_arithmetic_bigint_any.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +class Parser { + _def: any; + constructor(value: bigint) { + this._def = { checks: [{ kind: "multipleOf", value }] }; + } + safeParse(data: any) { + const input: any = { data }; + for (const check of this._def.checks) { + if (check.kind === "multipleOf" && input.data % check.value !== BigInt(0)) { + return { success: false }; + } + } + return { success: true }; + } +} +const parser: any = new Parser(5n); +console.log(JSON.stringify({ + ok: parser.safeParse(15n).success, + bad: parser.safeParse(14n).success, + stringSub: ("6" as any) - ("2" as any), + stringMod: ("15" as any) % ("5" as any), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"ok":true,"bad":false,"stringSub":4,"stringMod":0} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: dynamic arithmetic bigint any" diff --git a/tests/test_dynamic_class_extends_once.sh b/tests/test_dynamic_class_extends_once.sh new file mode 100755 index 000000000..ff395b629 --- /dev/null +++ b/tests/test_dynamic_class_extends_once.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +let count = 0; +const order: string[] = []; + +function parent() { + count++; + return class Base {}; +} + +function make() { + return class Child extends parent() { + static before = order.push("before"); + static { + order.push("block"); + } + static after = order.push("after"); + static observed = count; + }; +} + +const Child = make(); +console.log(JSON.stringify({ + count, + observed: (Child as any).observed, + order, + construct: new Child() instanceof Child, +})); +TS + +cat > "$TMPDIR/expected.log" <<'LOG' +{"count":1,"observed":1,"order":["before","block","after"],"construct":true} +LOG + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +PERRY_DIR="$(cd "$(dirname "$PERRY")" && pwd)" +if [[ -f "$PERRY_DIR/libperry_runtime.a" && -f "$PERRY_DIR/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$PERRY_DIR" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: dynamic class extends expression evaluated once" diff --git a/tests/test_dynamic_extends_class_binding.sh b/tests/test_dynamic_extends_class_binding.sh new file mode 100755 index 000000000..2b6807ec7 --- /dev/null +++ b/tests/test_dynamic_extends_class_binding.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function make(name: string, Parent: any) { + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + + function create(value: string) { + const inst = new Definition(value); + inst.tag = name; + return inst; + } + + return create; +} + +const A = make("AName", Error); +const B = make("BName", Error); +const a = new A("a"); +const b = new B("b"); + +console.log(JSON.stringify({ + aCtor: a.constructor.name, + bCtor: b.constructor.name, + aName: a.name, + bName: b.name, + aTag: a.tag, + bTag: b.tag, +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"aCtor":"AName","bCtor":"BName","aName":"Error","bName":"Error","aTag":"AName","bTag":"BName"} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: dynamic extends class binding" diff --git a/tests/test_dynamic_function_probe_fallback_stderr.sh b/tests/test_dynamic_function_probe_fallback_stderr.sh new file mode 100755 index 000000000..99a1d1337 --- /dev/null +++ b/tests/test_dynamic_function_probe_fallback_stderr.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/main.ts" <<'TS' +let enabled = true; +try { + new Function(""); +} catch { + enabled = false; +} +console.log("empty feature", enabled ? "dynamic" : "fallback"); + +enabled = true; +try { + const body = String(Math.random()).slice(100); + const fn: any = new Function(body); + fn(); +} catch { + enabled = false; +} +console.log("feature", enabled ? "dynamic" : "fallback"); + +try { + const Ctor: any = Function; + const body = ["return", "7"].join(" "); + new Ctor(body); +} catch { + console.log("reflected fallback"); +} +TS + +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +COMPILE_OUT="$(PERRY_ALLOW_PERRY_FEATURES=1 "$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$TMPDIR/out" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} + +"$TMPDIR/out" >"$TMPDIR/stdout.log" 2>"$TMPDIR/stderr.log" || { + echo "FAIL: compiled binary errored" + sed 's/^/ /' "$TMPDIR/stdout.log" + sed 's/^/ /' "$TMPDIR/stderr.log" + exit 1 +} + +cat >"$TMPDIR/expected.log" <<'EOF_EXPECTED' +empty feature fallback +feature fallback +reflected fallback +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/stdout.log"; then + echo "FAIL: stdout mismatch" + exit 1 +fi + +if [[ -s "$TMPDIR/stderr.log" ]]; then + echo "FAIL: expected empty stderr" + sed 's/^/ /' "$TMPDIR/stderr.log" + exit 1 +fi + +echo "PASS: dynamic Function feature probe fallback is silent" diff --git a/tests/test_dynamic_import_package_registry.sh b/tests/test_dynamic_import_package_registry.sh new file mode 100755 index 000000000..373b3ad5e --- /dev/null +++ b/tests/test_dynamic_import_package_registry.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +mkdir -p "$TMPDIR/node_modules/dynpkg" +cat >"$TMPDIR/package.json" <<'JSON' +{ + "type": "module", + "dependencies": { + "dynpkg": "1.0.0" + }, + "perry": { + "allow": { + "compilePackages": ["dynpkg"] + }, + "compilePackages": ["dynpkg"] + } +} +JSON +cat >"$TMPDIR/node_modules/dynpkg/package.json" <<'JSON' +{ + "name": "dynpkg", + "version": "1.0.0", + "type": "module", + "exports": { + "./a.js": "./a.js", + "./b.js": "./b.js", + "./c.js": "./c.js" + } +} +JSON +cat >"$TMPDIR/node_modules/dynpkg/a.js" <<'JS' +export default function () { return { name: "a" }; } +JS +cat >"$TMPDIR/node_modules/dynpkg/b.js" <<'JS' +export default function () { return { name: "b" }; } +JS +cat >"$TMPDIR/node_modules/dynpkg/c.js" <<'JS' +export default function () { return { name: "c" }; } +JS +cat >"$TMPDIR/main.ts" <<'TS' +async function main() { + const direct = await import("dynpkg/a.js"); + const registry = { b: "dynpkg/b.js" } as const; + const viaRegistry = await import(registry.b); + const loaders = { c: () => import("dynpkg/c.js") } as const; + const viaLoader = await loaders.c(); + const data = { name: "not-a-real-package", port: "3000" } as const; + let deferred = "no"; + try { + await import(data.name); + } catch { + deferred = "caught"; + } + console.log(JSON.stringify({ + direct: direct.default().name, + registry: viaRegistry.default().name, + loader: viaLoader.default().name, + deferred, + })); +} +main(); +TS + +BIN="$TMPDIR/main" +COMPILE_OUT="$($PERRY compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$BIN" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} +OUT="$($BIN 2>&1)" || { + echo "FAIL: compiled program errored" + echo "$OUT" + exit 1 +} +EXPECTED='{"direct":"a","registry":"b","loader":"c","deferred":"caught"}' +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: unexpected output" + echo "expected: $EXPECTED" + echo "actual: $OUT" + exit 1 +fi + +echo "PASS: dynamic import package registry" diff --git a/tests/test_dynamic_parent_builtin_no_downgrade.sh b/tests/test_dynamic_parent_builtin_no_downgrade.sh new file mode 100755 index 000000000..beabb4b45 --- /dev/null +++ b/tests/test_dynamic_parent_builtin_no_downgrade.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function makeCtor(name: string, Parent?: any) { + const Base = Parent ?? Object; + class Definition extends Base {} + function _(this: any) { + const inst = Parent ? new Definition() : this; + inst.name = name; + return inst; + } + Object.defineProperty(_, "name", { value: name }); + Object.defineProperty(_, "prototype", { value: Definition.prototype }); + Object.defineProperty(Definition.prototype, "constructor", { value: _ }); + return _; +} +const Real: any = makeCtor("Real", Error); +makeCtor("Other"); +const error = new Real(); +const proto = Object.getPrototypeOf(error); +const parentProto = proto && Object.getPrototypeOf(proto); +console.log(JSON.stringify({ + error: error instanceof Error, + real: error instanceof Real, + // Pin the exact chain depth: error -> Real.prototype -> Error.prototype -> + // Object.prototype. A disjunctive `parentProto === Error.prototype || proto + // === Error.prototype` would accept a flattened (downgraded) chain too. + protoIsRealProto: proto === Real.prototype, + parentIsErrorProto: parentProto === Error.prototype, + protoNotErrorDirect: proto !== Error.prototype, + grandIsObjectProto: Object.getPrototypeOf(parentProto) === Object.prototype, +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"error":true,"real":true,"protoIsRealProto":true,"parentIsErrorProto":true,"protoNotErrorDirect":true,"grandIsObjectProto":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: dynamic builtin parent no downgrade" diff --git a/tests/test_error_subclass_arrow_field_init.sh b/tests/test_error_subclass_arrow_field_init.sh new file mode 100755 index 000000000..619b46c12 --- /dev/null +++ b/tests/test_error_subclass_arrow_field_init.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +SRC="$TMPDIR/error_subclass_arrow_field_init.ts" +BIN="$TMPDIR/error_subclass_arrow_field_init" + +cat > "$SRC" <<'TS' +class MyError extends Error { + items: string[] = []; + + add = (item: string) => { + this.items = [...this.items, item]; + }; + + constructor() { + super(); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +const error = new MyError(); +console.log( + typeof error.add, + Object.keys(error).filter((key) => key !== "name").sort().join(","), + error instanceof MyError, + error instanceof Error, +); +error.add("item"); +console.log(JSON.stringify(error.items)); +TS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$SRC" > "$TMPDIR/expected.txt" + +if [ -x "$ROOT/target/debug/perry" ]; then + PERRY="$ROOT/target/debug/perry" + export PERRY_RUNTIME_DIR="$ROOT/target/debug" +else + PERRY="$ROOT/target/release/perry" + export PERRY_RUNTIME_DIR="$ROOT/target/release" +fi + +"$PERRY" compile --no-cache --no-auto-optimize "$SRC" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + cat "$TMPDIR/compile.log" + exit 1 +} + +"$BIN" > "$TMPDIR/actual.txt" 2> "$TMPDIR/run.log" || { + cat "$TMPDIR/run.log" + exit 1 +} + +diff -u "$TMPDIR/expected.txt" "$TMPDIR/actual.txt" diff --git a/tests/test_export_enum_runtime_object.sh b/tests/test_export_enum_runtime_object.sh new file mode 100755 index 000000000..a83610c2a --- /dev/null +++ b/tests/test_export_enum_runtime_object.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/source.ts" <<'TS' +export enum Kind { + Object = "Object", + Readonly = "Readonly", +} + +export enum Color { + Red, + Green, +} +TS + +cat >"$TMPDIR/barrel.ts" <<'TS' +import * as z from "./source.js"; +export * from "./source.js"; +export { z }; +TS + +cat >"$TMPDIR/main.ts" <<'TS' +import { z, Kind, Color } from "./barrel.js"; + +const actual = { + directKindType: typeof Kind, + directKind: Kind.Object, + namespaceKindType: typeof z.Kind, + namespaceKind: z.Kind.Object, + directColorType: typeof Color, + directColor: Color.Green, + reverseColor: Color[1], + namespaceColorType: typeof z.Color, + namespaceColor: z.Color.Red, + keys: Object.keys(z.Color).sort(), +}; + +console.log(JSON.stringify(actual)); +TS + +"$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" \ + -o "$TMPDIR/main" >"$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$TMPDIR/main" >"$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +EXPECTED='{"directKindType":"object","directKind":"Object","namespaceKindType":"object","namespaceKind":"Object","directColorType":"object","directColor":1,"reverseColor":"Green","namespaceColorType":"object","namespaceColor":0,"keys":["0","1","Green","Red"]}' +ACTUAL="$(cat "$TMPDIR/run.log")" + +if [[ "$ACTUAL" != "$EXPECTED" ]]; then + echo "FAIL: output mismatch" + echo "expected: $EXPECTED" + echo "actual: $ACTUAL" + exit 1 +fi + +echo "PASS: export enum runtime object" diff --git a/tests/test_file_instanceof.sh b/tests/test_file_instanceof.sh new file mode 100755 index 000000000..01fcc8101 --- /dev/null +++ b/tests/test_file_instanceof.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const file = new File(["a"], "a.txt", { type: "text/plain" }); +const blob = new Blob(["a"], { type: "text/plain" }); +console.log(JSON.stringify({ + fileType: typeof File, + fileInstance: file instanceof File, + fileIsBlob: file instanceof Blob, + blobIsFile: blob instanceof File, + name: file.name, + size: file.size, + type: file.type, +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"fileType":"function","fileInstance":true,"fileIsBlob":true,"blobIsFile":false,"name":"a.txt","size":1,"type":"text/plain"} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: File instanceof" diff --git a/tests/test_forward_captured_let_box.sh b/tests/test_forward_captured_let_box.sh new file mode 100755 index 000000000..07b45267e --- /dev/null +++ b/tests/test_forward_captured_let_box.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +SRC="$TMPDIR/forward_captured_let_box.ts" +BIN="$TMPDIR/forward_captured_let_box" + +cat > "$SRC" <<'TS' +function makeGetter() { + const getter = () => value; + const value = { ok: true, nested: [1, "x"] }; + return getter; +} + +const getter = makeGetter(); +console.log(JSON.stringify(getter())); +TS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$SRC" > "$TMPDIR/expected.txt" + +if [ -x "$ROOT/target/debug/perry" ]; then + PERRY="$ROOT/target/debug/perry" + export PERRY_RUNTIME_DIR="$ROOT/target/debug" +else + PERRY="$ROOT/target/release/perry" + export PERRY_RUNTIME_DIR="$ROOT/target/release" +fi + +"$PERRY" compile --no-cache --no-auto-optimize "$SRC" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + cat "$TMPDIR/compile.log" + exit 1 +} + +"$BIN" > "$TMPDIR/actual.txt" 2> "$TMPDIR/run.log" || { + cat "$TMPDIR/run.log" + exit 1 +} + +diff -u "$TMPDIR/expected.txt" "$TMPDIR/actual.txt" diff --git a/tests/test_function_prototype_instanceof.sh b/tests/test_function_prototype_instanceof.sh new file mode 100755 index 000000000..2783fff04 --- /dev/null +++ b/tests/test_function_prototype_instanceof.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function Ctor() {} +const proto: any = { marker: true }; +Object.defineProperty(Ctor, "prototype", { value: proto }); +const value = Object.create(proto); +console.log(JSON.stringify({ + custom: value instanceof Ctor, + proto: Object.getPrototypeOf(value) === Ctor.prototype, +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"custom":true,"proto":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: function prototype instanceof" diff --git a/tests/test_imported_default_named_reexport.sh b/tests/test_imported_default_named_reexport.sh new file mode 100755 index 000000000..ce92e347c --- /dev/null +++ b/tests/test_imported_default_named_reexport.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/locales.js" <<'JS' +export const Code = { invalid_type: "invalid_type" }; + +const errorMap = (issue, ctx) => { + let message; + switch (issue.code) { + case Code.invalid_type: + message = `Expected ${issue.expected}, received ${issue.received}`; + break; + default: + message = ctx.defaultError; + } + return { message }; +}; + +export default errorMap; +JS + +cat >"$TMPDIR/errors.js" <<'JS' +import defaultErrorMap from "./locales.js"; +export { defaultErrorMap }; +JS + +cat >"$TMPDIR/index.js" <<'JS' +export * from "./errors.js"; +JS + +cat >"$TMPDIR/main.js" <<'JS' +import defaultMap from "./locales.js"; +import { defaultErrorMap } from "./index.js"; + +const issue = { code: "invalid_type", expected: "string", received: "number" }; +const ctx = { defaultError: "fallback" }; +console.log(JSON.stringify({ direct: defaultMap(issue, ctx), reexport: defaultErrorMap(issue, ctx) })); +JS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$TMPDIR/main.js" >"$TMPDIR/node.txt" +"$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.js" \ + -o "$TMPDIR/main" >"$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} +"$TMPDIR/main" >"$TMPDIR/perry.txt" 2>"$TMPDIR/run.log" || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/node.txt" "$TMPDIR/perry.txt" >"$TMPDIR/diff.log"; then + echo "FAIL: output mismatch" + sed 's/^/ /' "$TMPDIR/diff.log" + exit 1 +fi + +echo "PASS: imported default named re-export" diff --git a/tests/test_imported_error_new_target.sh b/tests/test_imported_error_new_target.sh new file mode 100755 index 000000000..8f0cac038 --- /dev/null +++ b/tests/test_imported_error_new_target.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/err.ts" <<'TS' +export class MyError extends Error { + issues: string[]; + constructor(issues: string[]) { + super("bad"); + const actualProto = new.target.prototype; + Object.setPrototypeOf(this, actualProto); + this.issues = issues; + } +} +TS + +cat > "$TMPDIR/main.ts" <<'TS' +import { MyError } from "./err"; + +const result = { + success: false, + get error() { + return new MyError(["issue"]); + }, +}; + +console.log("success", result.success); +const error = result.error; +console.log("issues", error.issues.length); +console.log("instanceof", error instanceof MyError); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +success false +issues 1 +instanceof true +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: imported Error subclass new.target" diff --git a/tests/test_imported_static_field_alias_method.sh b/tests/test_imported_static_field_alias_method.sh new file mode 100755 index 000000000..7cf85ebe3 --- /dev/null +++ b/tests/test_imported_static_field_alias_method.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/lib.ts" <<'TS' +class Holder { + static create = (value: string) => ({ value }); +} +const mapType = Holder.create; +export { Holder, mapType as map }; +export const z = { map: mapType }; +TS + +cat > "$TMPDIR/main.ts" <<'TS' +import { Holder, z } from "./lib"; + +function run(label: string, fn: () => unknown) { + try { + console.log(label, JSON.stringify(fn())); + } catch (e: any) { + console.log(`${label}.ERR`, e?.message); + } +} + +run("z.map", () => z.map("ok")); +run("Holder.create", () => Holder.create("ok")); +run("sameRef", () => z.map === Holder.create); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +z.map {"value":"ok"} +Holder.create {"value":"ok"} +sameRef true +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: imported static field alias method" diff --git a/tests/test_json_stringify_replacer_key_order.sh b/tests/test_json_stringify_replacer_key_order.sh new file mode 100755 index 000000000..38d62a519 --- /dev/null +++ b/tests/test_json_stringify_replacer_key_order.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/main.ts" <<'TS' +const formatted: any = { _errors: [] }; +formatted.b = { _errors: [] }; +formatted.b[0] = { _errors: ["x"] }; +console.log(JSON.stringify({ formatted }, (_key, value) => value)); +TS + +BIN="$TMPDIR/main" +COMPILE_OUT="$($PERRY compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$BIN" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} +OUT="$($BIN 2>&1)" || { + echo "FAIL: compiled program errored" + echo "$OUT" + exit 1 +} +EXPECTED='{"formatted":{"_errors":[],"b":{"0":{"_errors":["x"]},"_errors":[]}}}' +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: unexpected output" + echo "expected: $EXPECTED" + echo "actual: $OUT" + exit 1 +fi + +echo "PASS: JSON.stringify replacer key order" diff --git a/tests/test_map_set_subclass_constructor_iterable.sh b/tests/test_map_set_subclass_constructor_iterable.sh new file mode 100755 index 000000000..fd87a7b82 --- /dev/null +++ b/tests/test_map_set_subclass_constructor_iterable.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +class FixtureMap extends Map {} +class FixtureSet extends Set<{ id: number }> {} +class ChildMap extends FixtureMap {} +class ChildSet extends FixtureSet {} + +const mapInput = new FixtureMap([["a", { count: 1 }]]); +const copiedMap = new Map(mapInput); +const childMap = new ChildMap([["b", { count: 2 }]]); +const setValue = { id: 1 }; +const setInput = new FixtureSet([setValue]); +const copiedSet = new Set(setInput); +const childSetValue = { id: 2 }; +const childSet = new ChildSet([childSetValue]); + +console.log(JSON.stringify({ + mapInputIsMap: mapInput instanceof Map, + mapInputIsSubclass: mapInput instanceof FixtureMap, + mapInputSize: mapInput.size, + mapInputGet: mapInput.get("a"), + mapInputEntries: Array.from(mapInput.entries()), + copiedMapSize: copiedMap.size, + copiedMapGet: copiedMap.get("a"), + copiedMapEntries: Array.from(copiedMap.entries()), + childMapIsSubclass: childMap instanceof ChildMap, + childMapEntries: Array.from(childMap.entries()), + setInputIsSet: setInput instanceof Set, + setInputIsSubclass: setInput instanceof FixtureSet, + setInputSize: setInput.size, + setInputValues: Array.from(setInput.values()), + copiedSetSize: copiedSet.size, + copiedSetValues: Array.from(copiedSet.values()), + copiedSetHasOriginal: copiedSet.has(setValue), + childSetIsSubclass: childSet instanceof ChildSet, + childSetValues: Array.from(childSet.values()), + childSetHasOriginal: childSet.has(childSetValue), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"mapInputIsMap":true,"mapInputIsSubclass":true,"mapInputSize":1,"mapInputGet":{"count":1},"mapInputEntries":[["a",{"count":1}]],"copiedMapSize":1,"copiedMapGet":{"count":1},"copiedMapEntries":[["a",{"count":1}]],"childMapIsSubclass":true,"childMapEntries":[["b",{"count":2}]],"setInputIsSet":true,"setInputIsSubclass":true,"setInputSize":1,"setInputValues":[{"id":1}],"copiedSetSize":1,"copiedSetValues":[{"id":1}],"copiedSetHasOriginal":true,"childSetIsSubclass":true,"childSetValues":[{"id":2}],"childSetHasOriginal":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: Map/Set subclass constructor iterable" diff --git a/tests/test_missing_error_property_undefined.sh b/tests/test_missing_error_property_undefined.sh new file mode 100755 index 000000000..c1298dc34 --- /dev/null +++ b/tests/test_missing_error_property_undefined.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/main.js" <<'JS' +class FixtureError extends Error { + constructor() { + super("boom"); + this.name = "FixtureError"; + } +} + +const error = new FixtureError(); +const aggregate = new AggregateError([1, 2], "many"); +console.log(JSON.stringify({ + own: Object.prototype.hasOwnProperty.call(error, "errors"), + inValue: "errors" in error, + type: typeof error.errors, + undef: error.errors === undefined, + aggregateLength: aggregate.errors.length, + aggregateFirst: aggregate.errors[0], +})); +JS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$TMPDIR/main.js" >"$TMPDIR/node.txt" +"$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.js" \ + -o "$TMPDIR/main" >"$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} +"$TMPDIR/main" >"$TMPDIR/perry.txt" 2>"$TMPDIR/run.log" || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/node.txt" "$TMPDIR/perry.txt" >"$TMPDIR/diff.log"; then + echo "FAIL: output mismatch" + sed 's/^/ /' "$TMPDIR/diff.log" + exit 1 +fi + +echo "PASS: missing Error.errors property is undefined" diff --git a/tests/test_namespace_default_alias_call.sh b/tests/test_namespace_default_alias_call.sh new file mode 100755 index 000000000..939b0d669 --- /dev/null +++ b/tests/test_namespace_default_alias_call.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +mkdir -p "$TMPDIR/locales" +cat >"$TMPDIR/locales/es.ts" <<'TS' +function build() { + return () => "locale-es"; +} +export default function () { + return { localeError: build() }; +} +TS +cat >"$TMPDIR/locales/en.ts" <<'TS' +export default function () { + return { localeError: () => "locale-en" }; +} +TS +cat >"$TMPDIR/locales/index.ts" <<'TS' +export { default as en } from "./en.js"; +export { default as es } from "./es.js"; +TS +cat >"$TMPDIR/defaultVar.ts" <<'TS' +const defaultValue = () => "default-var"; +export default defaultValue; +TS +cat >"$TMPDIR/main.ts" <<'TS' +import defaultValue from "./defaultVar.js"; +import * as locales from "./locales/index.js"; +console.log(JSON.stringify({ defaultValue: defaultValue(), en: locales.en().localeError({}), es: locales.es().localeError({}) })); +TS + +BIN="$TMPDIR/main" +COMPILE_OUT="$($PERRY compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$BIN" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} +OUT="$($BIN 2>&1)" || { + echo "FAIL: compiled program errored" + echo "$OUT" + exit 1 +} +EXPECTED='{"defaultValue":"default-var","en":"locale-en","es":"locale-es"}' +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: unexpected output" + echo "expected: $EXPECTED" + echo "actual: $OUT" + exit 1 +fi + +echo "PASS: namespace default alias call" diff --git a/tests/test_namespace_homonymous_class_export.sh b/tests/test_namespace_homonymous_class_export.sh new file mode 100755 index 000000000..ab45f0c89 --- /dev/null +++ b/tests/test_namespace_homonymous_class_export.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/ranges.ts" <<'TS' +export const NUMBER_FORMAT_RANGES = { safeint: [-1, 1] }; +export function normalizeParams(value?: unknown) { + return value ?? {}; +} +TS +cat >"$TMPDIR/legacy.ts" <<'TS' +export class util { + static objectKeys(value: object) { + return Object.keys(value); + } +} +TS +cat >"$TMPDIR/checks.ts" <<'TS' +import * as util from "./ranges.js"; +import { util as legacyUtil } from "./legacy.js"; + +export function readRange() { + return { + keys: Object.keys(util).sort(), + range: util.NUMBER_FORMAT_RANGES.safeint, + normalized: util.normalizeParams(), + legacyType: typeof legacyUtil, + legacyKeys: legacyUtil.objectKeys({ a: 1, b: 2 }), + }; +} +TS +cat >"$TMPDIR/main.ts" <<'TS' +import { readRange } from "./checks.js"; +console.log(JSON.stringify(readRange())); +TS + +EXPECTED='{"keys":["NUMBER_FORMAT_RANGES","normalizeParams"],"range":[-1,1],"normalized":{},"legacyType":"function","legacyKeys":["a","b"]}' +COMPILE_OUT="$(PERRY_ALLOW_PERRY_FEATURES=1 PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" \ + "$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$TMPDIR/out" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} + +OUT="$($TMPDIR/out 2>&1)" || { + echo "FAIL: compiled binary errored" + echo "$OUT" + exit 1 +} + +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: expected $EXPECTED, got:" + echo "$OUT" + exit 1 +fi + +echo "PASS: namespace homonymous class export" diff --git a/tests/test_namespace_homonymous_var_function_exports.sh b/tests/test_namespace_homonymous_var_function_exports.sh new file mode 100755 index 000000000..def38ef68 --- /dev/null +++ b/tests/test_namespace_homonymous_var_function_exports.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/function-source.ts" <<'TS' +export function string() { + return "function-export"; +} +TS +cat >"$TMPDIR/var-source.ts" <<'TS' +const stringType = () => "var-export"; +export { stringType as string }; +TS +cat >"$TMPDIR/main.ts" <<'TS' +import * as current from "./function-source.js"; +import * as legacy from "./var-source.js"; + +const out = { + currentType: typeof current.string, + legacyType: typeof legacy.string, + currentCall: current.string(), + legacyCall: legacy.string(), +}; +console.log(JSON.stringify(out)); +TS + +EXPECTED='{"currentType":"function","legacyType":"function","currentCall":"function-export","legacyCall":"var-export"}' +COMPILE_OUT="$(PERRY_ALLOW_PERRY_FEATURES=1 PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" \ + "$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$TMPDIR/out" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} + +OUT="$($TMPDIR/out 2>&1)" || { + echo "FAIL: compiled binary errored" + echo "$OUT" + exit 1 +} + +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: expected $EXPECTED, got:" + echo "$OUT" + exit 1 +fi + +echo "PASS: namespace homonymous var/function exports" diff --git a/tests/test_namespace_reexport_binding.sh b/tests/test_namespace_reexport_binding.sh index f9b6b0f43..5eaf2f747 100755 --- a/tests/test_namespace_reexport_binding.sh +++ b/tests/test_namespace_reexport_binding.sh @@ -28,9 +28,12 @@ trap 'rm -rf "$TMPDIR"' EXIT cat >"$TMPDIR/sub.ts" <<'TS' export function object() { return "obj"; } TS +cat >"$TMPDIR/coerce.ts" <<'TS' +export function number() { return 42; } +TS cat >"$TMPDIR/external.ts" <<'TS' export * from "./sub.js"; -export const coerce = { number: () => 42 }; +export * as coerce from "./coerce.js"; TS cat >"$TMPDIR/barrel.ts" <<'TS' import * as z from "./external.js"; @@ -38,6 +41,11 @@ export * from "./external.js"; export { z }; export default z; TS +cat >"$TMPDIR/index.ts" <<'TS' +import z4 from "./barrel.js"; +export * from "./barrel.js"; +export default z4; +TS cat >"$TMPDIR/main.ts" <<'TS' import { z } from "./barrel.js"; if (typeof z !== "object") throw new Error(`typeof z: ${typeof z}`); @@ -45,6 +53,13 @@ if (typeof (z as any).object !== "function") throw new Error("z.object missing") if (typeof (z as any).coerce !== "object") throw new Error("z.coerce missing"); if ((z as any).coerce.number() !== 42) throw new Error("z.coerce.number() wrong"); if ((z as any).object() !== "obj") throw new Error("z.object() wrong"); +import { z as zViaExportAll } from "./index.js"; +if (typeof zViaExportAll !== "object") throw new Error(`typeof zViaExportAll: ${typeof zViaExportAll}`); +if ((zViaExportAll as any).coerce.number() !== 42) throw new Error("zViaExportAll.coerce.number() wrong"); +if ((zViaExportAll as any).object() !== "obj") throw new Error("zViaExportAll.object() wrong"); +import * as pkg from "./barrel.js"; +if (!Object.keys(pkg).includes("coerce")) throw new Error("pkg.coerce not enumerable"); +if ((pkg as any).coerce.number() !== 42) throw new Error("pkg.coerce.number() wrong"); console.log("OK"); TS diff --git a/tests/test_namespace_reexport_optional_arg.sh b/tests/test_namespace_reexport_optional_arg.sh new file mode 100755 index 000000000..638d27088 --- /dev/null +++ b/tests/test_namespace_reexport_optional_arg.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/core.ts" <<'TS' +export function _make(params?: any) { + return { check: "string_format", format: "lowercase", ...(!params ? {} : params) }; +} +TS +cat >"$TMPDIR/external.ts" <<'TS' +export { _make as make } from "./core.js"; +export function string() { + return { format: null, parse() {} }; +} +TS +cat >"$TMPDIR/main.ts" <<'TS' +import * as z from "./external.js"; +const schema = z.string(); +const check = z.make(); +console.log(JSON.stringify({ schemaFormat: schema.format, checkKeys: Object.keys(check), checkFormat: check.format, hasParse: "parse" in check })); +TS + +BIN="$TMPDIR/main" +COMPILE_OUT="$($PERRY compile --no-cache --no-auto-optimize "$TMPDIR/main.ts" -o "$BIN" 2>&1)" || { + echo "FAIL: perry compile errored" + echo "$COMPILE_OUT" + exit 1 +} +OUT="$($BIN 2>&1)" || { + echo "FAIL: compiled program errored" + echo "$OUT" + exit 1 +} +EXPECTED='{"schemaFormat":null,"checkKeys":["check","format"],"checkFormat":"lowercase","hasParse":false}' +if [[ "$OUT" != "$EXPECTED" ]]; then + echo "FAIL: unexpected output" + echo "expected: $EXPECTED" + echo "actual: $OUT" + exit 1 +fi + +echo "PASS: namespace re-export optional arg" diff --git a/tests/test_namespace_subobject_method_call.sh b/tests/test_namespace_subobject_method_call.sh new file mode 100755 index 000000000..e792ddb6a --- /dev/null +++ b/tests/test_namespace_subobject_method_call.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/external.js" <<'JS' +export const util = { + find(arr, checker) { + for (const item of arr) { + if (checker(item)) return item; + } + return undefined; + }, +}; +JS + +cat >"$TMPDIR/index.js" <<'JS' +import * as z from "./external.js"; +export * from "./external.js"; +export { z }; +export default z; +JS + +cat >"$TMPDIR/main.js" <<'JS' +import { z } from "./index.js"; + +console.log(JSON.stringify({ + type: typeof z.util.find, + found: z.util.find([1, 2, 3], (value) => value > 1), +})); +JS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$TMPDIR/main.js" >"$TMPDIR/node.txt" +"$PERRY" compile --no-cache --no-auto-optimize "$TMPDIR/main.js" \ + -o "$TMPDIR/main" >"$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} +"$TMPDIR/main" >"$TMPDIR/perry.txt" 2>"$TMPDIR/run.log" || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/node.txt" "$TMPDIR/perry.txt" >"$TMPDIR/diff.log"; then + echo "FAIL: output mismatch" + sed 's/^/ /' "$TMPDIR/diff.log" + exit 1 +fi + +echo "PASS: namespace subobject method call" diff --git a/tests/test_new_parameter_shadows_class.sh b/tests/test_new_parameter_shadows_class.sh new file mode 100755 index 000000000..95718410f --- /dev/null +++ b/tests/test_new_parameter_shadows_class.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +export abstract class Class { + constructor(..._args: any[]) {} +} + +function makeCtor(name: string, initFn: (inst: any, def: any) => void) { + function _(this: any, def: any) { + initFn(this, def); + return this; + } + Object.defineProperty(_, "name", { value: name }); + return _ as any; +} + +const Real = makeCtor("Real", (inst, def) => { + inst.tag = def.tag; +}); + +function factory(Class: any) { + return new Class({ tag: "ok" }); +} + +const value = factory(Real); +console.log(JSON.stringify({ + proto: Object.getPrototypeOf(value)?.constructor?.name, + tag: value.tag, + inst: value instanceof (Real as any), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"proto":"Real","tag":"ok","inst":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: new parameter shadows same-named class" diff --git a/tests/test_number_to_string_large_exponent.sh b/tests/test_number_to_string_large_exponent.sh new file mode 100755 index 000000000..5ae7c51e7 --- /dev/null +++ b/tests/test_number_to_string_large_exponent.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const n = 3.4028234663852886e38; +const box: any = { maximum: n }; +const wrapped = new Number(n); + +console.log(JSON.stringify([ + String(n), + n.toString(), + box.maximum.toString(), + `${box.maximum}`, + String(box.maximum), + wrapped.toString(), + String(1e21), + String(1e-7), +])); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +["3.4028234663852886e+38","3.4028234663852886e+38","3.4028234663852886e+38","3.4028234663852886e+38","3.4028234663852886e+38","3.4028234663852886e+38","1e+21","1e-7"] +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: number toString large exponent" diff --git a/tests/test_object_create_for_in_prototype.sh b/tests/test_object_create_for_in_prototype.sh new file mode 100755 index 000000000..33f2f621c --- /dev/null +++ b/tests/test_object_create_for_in_prototype.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/_perry_test_lib.sh" + +perry_run main.ts <<'TS' +const proto: any = { inherited: 2 }; +const input: any = Object.create(proto); +input.own = 1; + +const forInKeys: string[] = []; +for (const key in input) { + forInKeys.push(key); +} + +console.log(JSON.stringify({ + protoIdentity: Object.getPrototypeOf(input) === proto, + protoKeys: Object.keys(Object.getPrototypeOf(input)), + forInKeys, + keys: Object.keys(input), + spread: { ...input }, + assign: Object.assign({}, input), + ctorIsObject: input.constructor === Object, +})); +TS + +perry_expect '{"protoIdentity":true,"protoKeys":["inherited"],"forInKeys":["own","inherited"],"keys":["own"],"spread":{"own":1},"assign":{"own":1},"ctorIsObject":true}' +perry_pass "Object.create for-in prototype chain" diff --git a/tests/test_object_create_in_operator_prototype.sh b/tests/test_object_create_in_operator_prototype.sh new file mode 100755 index 000000000..4a9708129 --- /dev/null +++ b/tests/test_object_create_in_operator_prototype.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const input: any = Object.create({ inherited: "from-proto", undef: undefined }); +input.own = 1; + +console.log(JSON.stringify({ + inherited: input.inherited, + bracket: input["inherited"], + inheritedIn: "inherited" in input, + undefinedIn: "undef" in input, + missingIn: "missing" in input, + ownInherited: Object.prototype.hasOwnProperty.call(input, "inherited"), + own: input.own, + keys: Object.keys(input), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"inherited":"from-proto","bracket":"from-proto","inheritedIn":true,"undefinedIn":true,"missingIn":false,"ownInherited":false,"own":1,"keys":["own"]} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: Object.create in operator prototype chain" diff --git a/tests/test_object_create_inherited_constructor.sh b/tests/test_object_create_inherited_constructor.sh new file mode 100755 index 000000000..ec7516b31 --- /dev/null +++ b/tests/test_object_create_inherited_constructor.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const input: any = Object.create({ inherited: 2 }); +input.own = 1; +const ctor = input.constructor; +const prot = ctor.prototype; +console.log(JSON.stringify({ + ctorType: typeof ctor, + ctorName: ctor.name, + ctorIsObject: ctor === Object, + protIsObjectPrototype: prot === Object.prototype, + protHasOwnIsPrototypeOf: Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf"), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"ctorType":"function","ctorName":"Object","ctorIsObject":true,"protIsObjectPrototype":true,"protHasOwnIsPrototypeOf":true} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: Object.create inherited constructor" diff --git a/tests/test_object_prototype_method_call_fallback.sh b/tests/test_object_prototype_method_call_fallback.sh new file mode 100755 index 000000000..7c5631d54 --- /dev/null +++ b/tests/test_object_prototype_method_call_fallback.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.js" <<'JS' +function run(label, fn) { + try { + console.log(label, JSON.stringify(fn())); + } catch (e) { + console.log(label, e.name + ":" + e.message); + } +} + +run("objectToString", () => ({}).toString()); +run("objectToLocaleString", () => ({}).toLocaleString()); +run("objectValueOfObject", () => ({}).valueOf() instanceof Object); +run("arrayToString", () => [1, 2].toString()); +run("mapToString", () => new Map().toString()); +run("mapToLocaleString", () => new Map().toLocaleString()); +run("mapValueOfSelf", () => { + const m = new Map(); + return m.valueOf() === m; +}); +run("setToString", () => new Set().toString()); +run("setToLocaleString", () => new Set().toLocaleString()); +run("setValueOfSelf", () => { + const s = new Set(); + return s.valueOf() === s; +}); +run("ownToString", () => ({ toString() { return "own"; } }).toString()); +run("classToString", () => new (class { toString() { return "class"; } })().toString()); +JS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.js" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +objectToString "[object Object]" +objectToLocaleString "[object Object]" +objectValueOfObject true +arrayToString "1,2" +mapToString "[object Map]" +mapToLocaleString "[object Map]" +mapValueOfSelf true +setToString "[object Set]" +setToLocaleString "[object Set]" +setValueOfSelf true +ownToString "own" +classToString "class" +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: Object.prototype method call fallback" diff --git a/tests/test_own_to_string_method.sh b/tests/test_own_to_string_method.sh new file mode 100755 index 000000000..f20e0d285 --- /dev/null +++ b/tests/test_own_to_string_method.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const errorUtil: any = {}; +errorUtil.toString = (message: any) => typeof message === "string" ? message : message?.message; +const value = errorUtil.toString(undefined); +console.log(value, typeof value, value === undefined); +console.log(JSON.stringify({ message: value })); + +const builder: any = { + toUpperCase() { + return { parse: () => "schema-method" }; + }, + trim() { + return { parse: () => "trim-method" }; + }, +}; +console.log(builder.toUpperCase().parse()); +console.log(builder.trim().parse()); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +undefined undefined true +{} +schema-method +trim-method +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: own toString method call" diff --git a/tests/test_prototype_cycle_has_property.sh b/tests/test_prototype_cycle_has_property.sh new file mode 100644 index 000000000..b3a1d5a85 --- /dev/null +++ b/tests/test_prototype_cycle_has_property.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# The `in` operator walks the [[Prototype]] chain recursively. Two hazards: +# 1. A pathologically deep (non-cyclic) chain must not overflow the stack — +# a missing key returns false (bounded like V8's own chain walk). +# 2. `Object.setPrototypeOf` must reject a cycle with a TypeError, exactly +# like Node, so a cycle can never be built in the first place. +cat > "$TMPDIR/main.ts" <<'TS' +let deep: any = { bottomKey: 1 }; +for (let i = 0; i < 3000; i++) { + deep = Object.create(deep); +} +let cycleThrew = false; +try { + const a: any = {}; + const b: any = {}; + Object.setPrototypeOf(a, b); + Object.setPrototypeOf(b, a); // would form a 2-cycle -> must throw +} catch (e) { + cycleThrew = e instanceof TypeError; +} +console.log(JSON.stringify({ + missingOnDeep: "nope" in deep, // false, no stack overflow + shallowPresent: "x" in Object.create({ x: 1 }), // true + shallowMissing: "y" in Object.create({ x: 1 }), // false + cycleThrew, // true (matches Node) +})); +TS + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi +node "$TMPDIR/main.ts" > "$TMPDIR/expected.log" + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed (stack overflow on deep prototype chain?)" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch vs node" + exit 1 +fi + +echo "PASS: deep prototype chain has-property terminates and cycles throw" diff --git a/tests/test_reflect_set_frozen_array.sh b/tests/test_reflect_set_frozen_array.sh new file mode 100755 index 000000000..482144659 --- /dev/null +++ b/tests/test_reflect_set_frozen_array.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then PERRY="$REPO_ROOT/target/debug/perry"; fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/main.ts" <<'TS' +const array = Object.freeze(["a"]); +const tupleLike = Object.freeze(["x"] as [string]); +const object = Object.freeze({ id: 1 }); + +function check(value: boolean, label: string) { + if (!value) { + throw new Error(label); + } +} + +check(Reflect.set(array, "0", "b") === false, "frozen array index Reflect.set result"); +check(array[0] === "a", "frozen array index unchanged"); +check(Reflect.set(tupleLike, "0", "y") === false, "frozen tuple-like index Reflect.set result"); +check(tupleLike[0] === "x", "frozen tuple-like index unchanged"); +check(Reflect.set(object, "id", 2) === false, "frozen object property Reflect.set result"); +check(object.id === 1, "frozen object property unchanged"); +console.log("OK"); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +PERRY_DIR="$(cd "$(dirname "$PERRY")" && pwd)" +if [[ -f "$PERRY_DIR/libperry_runtime.a" && -f "$PERRY_DIR/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$PERRY_DIR" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} +if ! grep -q "^OK$" "$TMPDIR/run.log"; then + echo "FAIL: expected OK, got:" + cat "$TMPDIR/run.log" + exit 1 +fi +echo "PASS: Reflect.set reports false for frozen array indices" diff --git a/tests/test_string_computed_length.sh b/tests/test_string_computed_length.sh new file mode 100755 index 000000000..bc62e653f --- /dev/null +++ b/tests/test_string_computed_length.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +function read(value: any, key: any) { + return value[key]; +} + +const short = "abc"; +const long = "abcdefghijklmnopqrstuvwxyz"; +const lengthKey = "length"; +const indexKey = "1"; +const nonCanonicalIndexKey = "01"; + +console.log(JSON.stringify({ + shortLength: read(short, lengthKey), + longLength: read(long, lengthKey), + directLength: short["length"], + boxedLength: read(new String(short), lengthKey), + char: read(short, indexKey), + nonCanonical: String(read(short, nonCanonicalIndexKey)), +})); +TS + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +if [[ -f "$REPO_ROOT/target/debug/libperry_runtime.a" && -f "$REPO_ROOT/target/debug/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/debug" + COMPILE_ARGS+=(--no-auto-optimize) +elif [[ -f "$REPO_ROOT/target/release/libperry_runtime.a" && -f "$REPO_ROOT/target/release/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$REPO_ROOT/target/release" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +cat > "$TMPDIR/expected.log" <<'EOF_EXPECTED' +{"shortLength":3,"longLength":26,"directLength":3,"boxedLength":3,"char":"b","nonCanonical":"undefined"} +EOF_EXPECTED + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: string computed length" diff --git a/tests/test_switch_bigint_case.sh b/tests/test_switch_bigint_case.sh new file mode 100644 index 000000000..0ea41787c --- /dev/null +++ b/tests/test_switch_bigint_case.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/_perry_test_lib.sh" + +# A `switch` on a BigInt must match `case 1n` by value, not allocation +# identity — the same rule the Set/Map key and `===` paths already follow. +perry_run main.ts <<'TS' +function classify(x: bigint): string { + switch (x) { + case 0n: return "zero"; + case 1n: return "one"; + case 9007199254740993n: return "big"; + default: return "other"; + } +} +console.log(JSON.stringify({ + computed: classify(1n + 0n), + big: classify(9007199254740992n + 1n), + miss: classify(5n), + mixedType: (1n as any) === 1 ? "eqNumber" : "neqNumber", +})); +TS + +perry_expect_node +perry_pass "switch matches BigInt case by value" diff --git a/tests/test_unicode_regex_literal.sh b/tests/test_unicode_regex_literal.sh new file mode 100755 index 000000000..c881a0a5d --- /dev/null +++ b/tests/test_unicode_regex_literal.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const composed = "CAFE\u0301!".toLowerCase().normalize("NFC"); +const literal = /^café!$/; +const constructor = new RegExp("^café!$"); + +console.log(JSON.stringify({ + direct: literal.test("café!"), + composed, + composedCodepoints: [...composed].map((char) => char.codePointAt(0)), + literalSource: literal.source, + literalMatch: literal.test(composed), + constructorSource: constructor.source, + constructorMatch: constructor.test(composed), +})); +TS + +node "$TMPDIR/main.ts" > "$TMPDIR/expected.log" + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +PERRY_DIR="$(cd "$(dirname "$PERRY")" && pwd)" +if [[ -f "$PERRY_DIR/libperry_runtime.a" && -f "$PERRY_DIR/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$PERRY_DIR" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: Unicode regex literal" diff --git a/tests/test_url_href_normalization.sh b/tests/test_url_href_normalization.sh new file mode 100755 index 000000000..21298604d --- /dev/null +++ b/tests/test_url_href_normalization.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node binary not found" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.ts" <<'TS' +const url = new URL("HTTP://ExAmPle.com:80/./a/../b?X=1#f oo"); +console.log(JSON.stringify({ + href: url.href, + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + pathname: url.pathname, + hash: url.hash, +})); +TS + +node "$TMPDIR/main.ts" > "$TMPDIR/expected.log" + +BIN="$TMPDIR/out" +COMPILE_ARGS=(compile --no-cache) +PERRY_DIR="$(cd "$(dirname "$PERRY")" && pwd)" +if [[ -f "$PERRY_DIR/libperry_runtime.a" && -f "$PERRY_DIR/libperry_stdlib.a" ]]; then + export PERRY_RUNTIME_DIR="$PERRY_DIR" + COMPILE_ARGS+=(--no-auto-optimize) +fi + +"$PERRY" "${COMPILE_ARGS[@]}" "$TMPDIR/main.ts" -o "$BIN" > "$TMPDIR/compile.log" 2>&1 || { + echo "FAIL: compile failed" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -80 + exit 1 +} + +"$BIN" > "$TMPDIR/run.log" 2>&1 || { + echo "FAIL: program failed" + sed 's/^/ /' "$TMPDIR/run.log" | tail -80 + exit 1 +} + +if ! diff -u "$TMPDIR/expected.log" "$TMPDIR/run.log"; then + echo "FAIL: output mismatch" + exit 1 +fi + +echo "PASS: URL href normalization" diff --git a/tests/test_url_instanceof_reflection.sh b/tests/test_url_instanceof_reflection.sh new file mode 100755 index 000000000..1b6cee9ad --- /dev/null +++ b/tests/test_url_instanceof_reflection.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/_perry_test_lib.sh" + +perry_run main.ts <<'TS' +const url = new URL("https://example.com/path?q=1"); +const C = URL; + +console.log(JSON.stringify({ + direct: url instanceof URL, + dynamic: url instanceof C, + hasInstance: Function.prototype[Symbol.hasInstance].call(URL, url), + protoIs: Object.getPrototypeOf(url) === URL.prototype, + ctorName: (url as any).constructor?.name, + tag: (url as any)[Symbol.toStringTag], + objectTag: Object.prototype.toString.call(url), + protocol: url.protocol, +})); +TS + +perry_expect_node +perry_pass "URL instanceof reflection"