From a763dffe0dcc8b44eec47a8ea1a1e7e99053a14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 20:41:22 +0200 Subject: [PATCH 1/3] fix(hir): route node-core module spread calls through the variadic dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path.join(...parts)` threw `TypeError [ERR_INVALID_ARG_TYPE]` while the identical non-spread call succeeded (#7720). Every native-module fast path in `lower_call` consumes its arguments POSITIONALLY, and a spread argument is lowered as one expression holding the whole array. `path.join(...parts)` therefore reached the `args.len() == 1` arm as `PathNormalize()`; the same fold made `util.format(...args)` inspect its array instead of formatting it and `fs.existsSync(...args)` test an array for existence. Decline the whole fast-path chain when the callee is a node-core module namespace method (or a named export of one) and any argument is spread. The fall-through tail then builds an `Expr::CallSpread` over the namespace member — the lowering the value-read form (`const j = path.join; j(...parts)`) already takes, which materializes the argument array and dispatches through `js_native_call_method` -> `dispatch_native_module_method`. That dispatcher is variadic by construction, so it gets both the valid case and Node's `ERR_INVALID_ARG_TYPE` for an invalid one right. Generalizes the per-module bail #6668 added for `crypto`. Scoped to node-core modules: an ext/npm native module (mysql2, redis, node-forge) has no by-name runtime dispatcher behind its codegen-wired rows, so declining its fast path would trade a wrong answer for no answer. Native CLASS statics (`Buffer.concat`, `URL.parse`) are excluded for the same reason. Tests: `native_module_spread_tests.rs` asserts the verdict in both directions — a spread call is diverted, a non-spread call still gets `PathJoin` — because both a fixed and a fully-disabled fast path produce correct output. Behaviour is byte-compared against node in three new node-suite fixtures. --- crates/perry-hir/src/lower/expr_call/mod.rs | 261 ++++++++++-------- .../src/lower/expr_call/native_module.rs | 96 +++++++ .../expr_call/native_module_spread_tests.rs | 145 ++++++++++ test-parity/node-suite/path/join/spread.ts | 30 ++ test-parity/node-suite/path/resolve/spread.ts | 15 + test-parity/node-suite/util/format/spread.ts | 15 + 6 files changed, 440 insertions(+), 122 deletions(-) create mode 100644 crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs create mode 100644 test-parity/node-suite/path/join/spread.ts create mode 100644 test-parity/node-suite/path/resolve/spread.ts create mode 100644 test-parity/node-suite/util/format/spread.ts diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index ef206536d1..486c01c8c9 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -33,6 +33,7 @@ mod module_class_static; mod module_static; mod name_fold; mod native_module; +mod native_module_spread_tests; mod nested_namespace; mod object_static; mod os; @@ -452,128 +453,144 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result return Ok(e), - Err(a) => a, - }; - - // Nested 3-level Member dispatch: process.hrtime.bigint(), - // crypto.subtle.(), util.types.(), and - // path.posix/win32.(). - args = match try_process_hrtime_bigint(expr, args) { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_process_memory_usage_rss(expr, args) { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_web_crypto_subtle(ctx, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_util_types_namespace(ctx, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_dns_promises_namespace(ctx, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_punycode_ucs2_namespace(ctx, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - args = match try_path_subnamespace(ctx, expr, args) { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // TextEncoder/TextDecoder direct methods need to win before - // native-instance dispatch, because node:util named constructors - // can leave native tags on the local binding. - args = match try_textencoder_decoder(ctx, call, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // module.Class.staticMethod() and process.std{in,out} dispatch. - args = match try_module_class_static(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Native module method calls (process/tty/os/Buffer/Uint8Array/Object/Symbol/Array/net). - args = match try_native_module_methods(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Static class method + native-instance method dispatch. - args = match try_static_method_and_instance(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // `.exports.(...)` for WebAssembly JS API. - args = match try_wasm_instance_exports(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // fs/path/JSON/Math/Number/String/crypto/os/Buffer/cp/net/AbortSignal/Date/URL static methods. - args = match try_module_static_methods(ctx, call, expr, args, has_spread)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // URL/URLSearchParams/Date instance methods + WeakRef/FinalizationRegistry. - args = match try_url_date_weakref_instance(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Array method calls on local-variable receivers. - args = match try_local_array_methods(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Array methods on imported variables (ExternFuncRef receivers). - args = match try_imported_array_methods(ctx, call, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Array methods on inline array literals. - args = match try_inline_array_methods(ctx, call, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Array-only methods on arbitrary expressions. - args = match try_array_only_methods(ctx, call, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Regex .test()/.exec() + String .match(regex). - args = match try_regex_string_methods(ctx, call, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; - - // Global builtins (parseInt/parseFloat/Number/String/isNaN/isFinite/...). - args = match try_global_builtins(ctx, call, expr, args)? { - Ok(e) => return Ok(e), - Err(a) => a, - }; + // #7720: a spread argument (`path.join(...parts)`) makes every + // native fast path below WRONG, not merely unoptimized: each one + // consumes `args` positionally, so the spread operand is folded in + // as a single argument holding the whole array. `path.join` then + // threw `ERR_INVALID_ARG_TYPE` on it, `util.format` inspected it + // instead of formatting, `fs.existsSync` tested an array for + // existence. Decline the whole chain for a node-core module call + // and let the fall-through tail build an `Expr::CallSpread` over + // the namespace member — the variadic runtime dispatch the + // value-read form (`const j = path.join; j(...parts)`) already + // uses. See `native_module::is_node_builtin_module_call` for why + // this is scoped to node-core (and not ext/npm native modules). + let node_builtin_spread_call = + has_spread && native_module::is_node_builtin_module_call(ctx, expr); + if !node_builtin_spread_call { + // node-forge deeply-nested namespace calls + // (`forge.pki.rsa.generateKeyPair()`, `forge.pki.createCertificate()`, + // `forge.md.sha256.create()`). Must run before the generic + // namespace / `module.Class.staticMethod` dispatch, which would + // otherwise claim the 2-level `forge.pki.createCertificate()` shape + // and gate its `forge.pki` object-read as unimplemented. + args = match native_module::try_node_forge_namespace(ctx, expr, args) { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Nested 3-level Member dispatch: process.hrtime.bigint(), + // crypto.subtle.(), util.types.(), and + // path.posix/win32.(). + args = match try_process_hrtime_bigint(expr, args) { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_process_memory_usage_rss(expr, args) { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_web_crypto_subtle(ctx, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_util_types_namespace(ctx, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_dns_promises_namespace(ctx, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_punycode_ucs2_namespace(ctx, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + args = match try_path_subnamespace(ctx, expr, args) { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // TextEncoder/TextDecoder direct methods need to win before + // native-instance dispatch, because node:util named constructors + // can leave native tags on the local binding. + args = match try_textencoder_decoder(ctx, call, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // module.Class.staticMethod() and process.std{in,out} dispatch. + args = match try_module_class_static(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Native module method calls (process/tty/os/Buffer/Uint8Array/Object/Symbol/Array/net). + args = match try_native_module_methods(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Static class method + native-instance method dispatch. + args = match try_static_method_and_instance(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // `.exports.(...)` for WebAssembly JS API. + args = match try_wasm_instance_exports(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // fs/path/JSON/Math/Number/String/crypto/os/Buffer/cp/net/AbortSignal/Date/URL static methods. + args = match try_module_static_methods(ctx, call, expr, args, has_spread)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // URL/URLSearchParams/Date instance methods + WeakRef/FinalizationRegistry. + args = match try_url_date_weakref_instance(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Array method calls on local-variable receivers. + args = match try_local_array_methods(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Array methods on imported variables (ExternFuncRef receivers). + args = match try_imported_array_methods(ctx, call, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Array methods on inline array literals. + args = match try_inline_array_methods(ctx, call, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Array-only methods on arbitrary expressions. + args = match try_array_only_methods(ctx, call, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Regex .test()/.exec() + String .match(regex). + args = match try_regex_string_methods(ctx, call, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + + // Global builtins (parseInt/parseFloat/Number/String/isNaN/isFinite/...). + args = match try_global_builtins(ctx, call, expr, args)? { + Ok(e) => return Ok(e), + Err(a) => a, + }; + } // --------------------------------------------------------------- // Fall-through tail: lower the callee, fill in default arguments diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index bbb019570d..25473409c4 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -258,6 +258,102 @@ fn native_module_member_path( } } +fn is_node_core(module: &str) -> bool { + crate::ir::is_node_builtin_module(module.strip_prefix("node:").unwrap_or(module)) +} + +/// Is the named export `export` of node-core `module` itself a NAMESPACE +/// (`import { posix } from "node:path"`, `import { promises } from "node:fs"`) +/// rather than a class or function value? +/// +/// The distinction decides whether `.(...)` is a module call. +/// `Buffer.concat(...)` / `URL.parse(...)` are class statics reached through a +/// different lowering family, and their by-name runtime dispatch does not cover +/// the same surface, so they stay on their existing path. +fn is_submodule_export(module: &str, export: &str) -> bool { + let module = module.strip_prefix("node:").unwrap_or(module); + export == "default" + || crate::ir::is_node_builtin_module(&format!("{module}/{export}")) + // Sub-namespaces that are properties rather than sub-modules. + || matches!( + (module, export), + ("crypto", "subtle" | "webcrypto") | ("punycode", "ucs2") | ("path", "posix" | "win32") + ) +} + +/// Does `name` denote a node-core module NAMESPACE in this scope — a +/// namespace/default import (`import path from "node:path"`), a sub-namespace +/// export of one (`import { posix } from "node:path"`), or a `require()` alias? +/// +/// Deliberately node-core ONLY: an ext/npm native module (`mysql2`, `redis`, +/// `node-forge`) is served by codegen-wired `NativeMethodCall` rows with no +/// by-name runtime dispatcher behind them, so declining its fast path would +/// trade a wrong answer for no answer. Every name this returns `true` for +/// resolves through `dispatch_native_module_method` in the runtime. +fn name_is_node_builtin_namespace(ctx: &LoweringContext, name: &str) -> bool { + if let Some((module, export)) = ctx.lookup_native_module(name) { + if is_node_core(module) && export.is_none_or(|e| is_submodule_export(module, e)) { + return true; + } + } + ctx.lookup_builtin_module_alias(name) + .is_some_and(is_node_core) +} + +/// Is `recv` (a call's RECEIVER) a node-core module namespace, or a +/// sub-namespace of one (`path.posix`, `crypto.subtle`, `util.types`, +/// `fs.promises`)? +fn receiver_is_node_builtin_module(ctx: &LoweringContext, recv: &ast::Expr) -> bool { + match unwrap_ts_wrappers(recv) { + ast::Expr::Ident(ident) => { + let name = ident.sym.as_ref(); + name_is_node_builtin_namespace(ctx, name) + // #1750: `const w = path.win32; w.join(...)`. + || ctx + .lookup_subns_path_alias(name) + .is_some_and(|(root, _)| name_is_node_builtin_namespace(ctx, root)) + } + // 3-level sub-namespace: recurse to the root identifier. + ast::Expr::Member(inner) => receiver_is_node_builtin_module(ctx, inner.obj.as_ref()), + // `require("node:path").join(...)` — the inline-require shape. + other => require_literal_native_module(ctx, other) + .is_some_and(|m| crate::ir::is_node_builtin_module(&m)), + } +} + +/// #7720: is this call a node-core native-module call — `ns.method(...)`, +/// `ns.sub.method(...)`, or a named import `method(...)`? +/// +/// Every native fast path below consumes its arguments POSITIONALLY, so a +/// spread operand (`path.join(...parts)`) is folded in as one argument holding +/// the whole array: `path.join` saw a single non-string and threw +/// `ERR_INVALID_ARG_TYPE`, `util.format` inspected the array instead of +/// formatting it, `fs.existsSync` tested an array for existence. `lower_call` +/// uses this to decline the entire fast-path chain for a spread call, leaving +/// the generic tail to build an `Expr::CallSpread` over the namespace member — +/// the same lowering the value-read form (`const j = path.join; j(...parts)`) +/// already takes, which materializes the args array and dispatches through +/// `js_native_call_method` → `dispatch_native_module_method`. That dispatcher +/// is variadic by construction, so it gets both the valid case and Node's +/// `ERR_INVALID_ARG_TYPE` for an invalid one right. +/// +/// Generalizes the per-module bails #6668 added for `crypto` (those stay: they +/// also cover the bare `crypto` GLOBAL receiver, which is not an import and so +/// is invisible here). Native CLASS statics (`Buffer.concat(...list)`, +/// `URL.parse(...)`) are deliberately NOT included — see `is_submodule_export`. +pub(super) fn is_node_builtin_module_call(ctx: &LoweringContext, callee: &ast::Expr) -> bool { + match unwrap_ts_wrappers(callee) { + // `ns.method(...)` / `ns.sub.method(...)`. + ast::Expr::Member(member) => receiver_is_node_builtin_module(ctx, member.obj.as_ref()), + // A named export of a node-core module called directly: + // `import { join } from "node:path"; join(...parts)`. + ast::Expr::Ident(ident) => ctx + .lookup_native_module(ident.sym.as_ref()) + .is_some_and(|(module, export)| is_node_core(module) && export.is_some()), + _ => false, + } +} + /// node-forge sub-namespace flattening. Unlike the single-level `ns.method()` /// shape the other arms match, forge's API is deeply nested: /// `forge.pki.rsa.generateKeyPair(...)`, `forge.pki.createCertificate()`, diff --git a/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs b/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs new file mode 100644 index 0000000000..331720ad6b --- /dev/null +++ b/crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs @@ -0,0 +1,145 @@ +//! Verdict tests for the #7720 spread bail in `lower_call`. +//! +//! These assert **which lowering a call got**, not what it computes. A +//! behaviour-only test would be a weak gate here in both directions: +//! +//! * the broken lowering `path.join(...parts)` → `PathNormalize()` +//! throws `ERR_INVALID_ARG_TYPE`, and so does the CORRECT lowering when the +//! array holds a non-string — which is why +//! `node-suite/path/join/type-errors-extra.ts` has spread-called +//! `path.join` since long before the bug was fixed and stayed green +//! throughout (CLAUDE.md's fourth way a gate can be unable to fail: the +//! gate ran, its subject never did); +//! * a regression that stopped applying the fast path *everywhere* — not +//! just for spread calls — would also produce correct output, since the +//! generic dispatch is a correct fallback. `join_without_spread_*` is the +//! other half of the ratchet: it fails if the fast path stops firing. +//! +//! Byte-for-byte behaviour against node lives in +//! `test-parity/node-suite/path/{join,resolve}/spread.ts` and +//! `test-parity/node-suite/util/format/spread.ts`. + +#![cfg(test)] + +use crate::Module; +use perry_diagnostics::SourceCache; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = perry_parser::parse_typescript_with_cache( + &src, + "native_module_spread.ts", + &mut cache, + ) + .expect("parse should succeed"); + crate::lower_module(&parsed.module, "test", "native_module_spread.ts") + .expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +fn hir(src: &str) -> String { + format!("{:?}", lower(src)) +} + +/// The generic tail's shape: a `CallSpread` whose callee is the namespace +/// member, which codegen dispatches through the variadic runtime by-name path. +fn declined_fast_path(src: &str) -> bool { + hir(src).contains("CallSpread") +} + +// ── the reported repro (#7720) and its sibling call forms ────────────────── + +#[test] +fn join_with_spread_declines_the_static_fast_path() { + let h = hir(r#" + import path from 'node:path'; + const parts = ['/tmp/x', 'project.json']; + console.log(path.join(...parts)); + "#); + assert!(h.contains("CallSpread"), "expected CallSpread, got: {h}"); + // The bug: the spread operand folded into the one-argument arm. + assert!( + !h.contains("PathNormalize"), + "still folded positionally: {h}" + ); +} + +#[test] +fn join_without_spread_keeps_the_static_fast_path() { + // The other half of the ratchet — the bail must be spread-only. + let h = hir(r#" + import path from 'node:path'; + console.log(path.join('/tmp/x', 'project.json')); + "#); + assert!(h.contains("PathJoin"), "fast path stopped firing: {h}"); + assert!( + !h.contains("CallSpread"), + "non-spread call was diverted: {h}" + ); +} + +#[test] +fn spread_bail_covers_every_path_receiver_form() { + // Default import, namespace import, require alias, named import, named + // sub-namespace import, and the 3-level sub-namespace member. + for src in [ + "import path from 'node:path'; const p = ['a','b']; console.log(path.join(...p));", + "import * as path from 'node:path'; const p = ['a','b']; console.log(path.join(...p));", + "const path = require('node:path'); const p = ['a','b']; console.log(path.join(...p));", + "import { join } from 'node:path'; const p = ['a','b']; console.log(join(...p));", + "import { posix } from 'node:path'; const p = ['a','b']; console.log(posix.join(...p));", + "import path from 'node:path'; const p = ['a','b']; console.log(path.win32.join(...p));", + ] { + assert!(declined_fast_path(src), "still folded positionally: {src}"); + } +} + +#[test] +fn spread_bail_is_not_path_specific() { + // `util.format` inspected the array instead of formatting it; `fs.existsSync` + // tested an array for existence. Same positional fold, same fix. + for src in [ + "import util from 'node:util'; const a = ['%s', 'x']; console.log(util.format(...a));", + "import fs from 'node:fs'; const a = ['/tmp']; console.log(fs.existsSync(...a));", + "import os from 'node:os'; const a: string[] = []; console.log(os.homedir(...a));", + ] { + assert!(declined_fast_path(src), "still folded positionally: {src}"); + } +} + +// ── what the bail deliberately does NOT claim ────────────────────────────── + +#[test] +fn class_statics_keep_their_lowering() { + // `Buffer` is a CLASS export of node:buffer, not a namespace. Its statics + // are a different lowering family whose by-name runtime dispatch does not + // cover the same surface, so they stay where they are (see + // `native_module::is_submodule_export`). + let h = hir(r#" + import { Buffer } from 'node:buffer'; + const list = [Buffer.from('a'), Buffer.from('b')]; + console.log(Buffer.concat(...[list]).toString()); + "#); + assert!( + !h.contains("CallSpread"), + "class static was diverted to the generic tail: {h}" + ); +} + +#[test] +fn non_module_spread_intrinsics_are_untouched() { + // `Math` / `Object` / array receivers are not node-core modules, so their + // spread-aware fast paths keep firing. + let h = hir("const xs = [3, 1, 2]; console.log(Math.min(...xs));"); + assert!( + h.contains("MathMinSpread"), + "Math.min spread lost its fast path: {h}" + ); +} diff --git a/test-parity/node-suite/path/join/spread.ts b/test-parity/node-suite/path/join/spread.ts new file mode 100644 index 0000000000..91b87d7ebf --- /dev/null +++ b/test-parity/node-suite/path/join/spread.ts @@ -0,0 +1,30 @@ +// #7720: spread invocation with VALID string segments. `type-errors-extra.ts` +// already spread-calls `path.join`, but only with segments Node rejects — so it +// stayed green while the spread operand was being folded in as one array +// argument (both lowerings threw ERR_INVALID_ARG_TYPE). These are the cases +// that tell the two apart. +import path from "node:path"; +import { join, posix, win32 } from "node:path"; + +const parts = ["/tmp/x", "project.json"]; + +console.log("spread:", path.join(...parts)); +console.log("mixed:", path.join("/base", ...parts)); +console.log("trailing:", path.join(...parts, "extra")); +console.log("single:", path.join(...["/tmp/x"])); +console.log("empty:", path.join(...([] as string[]))); +console.log("normalizing:", path.join(...["/foo", "bar", "..", "baz", "."])); + +console.log("named import:", join(...parts)); +console.log("posix:", posix.join(...parts)); +console.log("win32:", win32.join(...parts)); +console.log("subns member:", path.posix.join(...parts)); + +const alias = path.win32; +console.log("subns alias:", alias.join(...parts)); + +const value = path.join; +console.log("value read:", value(...parts)); + +const nested = [["/a", "b"], ["/c", "d"]]; +console.log("in a loop:", nested.map((seg) => path.join(...seg)).join("|")); diff --git a/test-parity/node-suite/path/resolve/spread.ts b/test-parity/node-suite/path/resolve/spread.ts new file mode 100644 index 0000000000..a7c4c2e484 --- /dev/null +++ b/test-parity/node-suite/path/resolve/spread.ts @@ -0,0 +1,15 @@ +// #7720: `path.resolve(...segments)` — the reset-on-absolute sibling of +// `join`'s spread case. Every result is anchored on an absolute first segment +// so the output does not depend on the process cwd. +import path from "node:path"; +import { resolve } from "node:path"; + +const parts = ["/tmp/x", "project.json"]; + +console.log("spread:", path.resolve(...parts)); +console.log("mixed:", path.resolve("/base", ...parts)); +console.log("reset on absolute:", path.resolve("/base", ...["rel", "/abs", "tail"])); +console.log("single:", path.resolve(...["/tmp/x"])); +console.log("dotdot:", path.resolve(...["/foo/bar", "..", "baz"])); +console.log("named import:", resolve(...parts)); +console.log("posix:", path.posix.resolve(...parts)); diff --git a/test-parity/node-suite/util/format/spread.ts b/test-parity/node-suite/util/format/spread.ts new file mode 100644 index 0000000000..9df7511a45 --- /dev/null +++ b/test-parity/node-suite/util/format/spread.ts @@ -0,0 +1,15 @@ +// #7720: `util.format(...args)` — the spread operand used to be folded in as a +// single array argument, so format inspected the array instead of consuming it +// as the format string plus its substitutions. +import util from "node:util"; +import { format } from "node:util"; + +const args = ["%s is %d", "x", 7]; + +console.log("namespace:", util.format(...args)); +console.log("named import:", format(...args)); +console.log("mixed:", util.format("prefix %s", ...["y"])); +console.log("trailing extra:", util.format(...args, "tail")); +console.log("single:", util.format(...["plain"])); +console.log("no args:", JSON.stringify(util.format(...([] as string[])))); +console.log("objects:", util.format(...["%j", { a: 1 }])); From ff0907b4176854632d728e1e4be499d2a8d30833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 20:42:48 +0200 Subject: [PATCH 2/3] docs(changelog): fragment for #7726 (node-core module spread calls) --- .../7726-native-module-spread-calls.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 changelog.d/7726-native-module-spread-calls.md diff --git a/changelog.d/7726-native-module-spread-calls.md b/changelog.d/7726-native-module-spread-calls.md new file mode 100644 index 0000000000..f182d0d2da --- /dev/null +++ b/changelog.d/7726-native-module-spread-calls.md @@ -0,0 +1,39 @@ +Fixed spread calls on node-core module namespaces, which passed the spread +operand as ONE argument holding the whole array instead of expanding it +(#7720). `path.join(...parts)` threw `TypeError [ERR_INVALID_ARG_TYPE]` where +the identical non-spread call succeeded; the same positional fold made +`util.format(...args)` inspect its array instead of formatting it and +`fs.existsSync(...args)` test an array for existence. + +Every native-module fast path in `lower_call` consumes its arguments +positionally, so `path.join(...parts)` reached the one-argument arm as +`PathNormalize()`. When any argument is spread and the callee is a +node-core module namespace method (or a named export of one), `lower_call` now +declines the whole fast-path chain; the fall-through tail builds an +`Expr::CallSpread` over the namespace member — the lowering the value-read form +(`const j = path.join; j(...parts)`) already took — which materialises the +argument array and dispatches through `js_native_call_method` → +`dispatch_native_module_method`. That dispatcher is variadic by construction, so +it gets both the valid case and Node's `ERR_INVALID_ARG_TYPE` (with its `code`) +for an invalid one right. Generalises the per-module bail #6668 added for +`crypto`, whose two guards stay because they also cover the bare `crypto` +GLOBAL receiver. + +Deliberately scoped to node-core modules: an ext/npm native module (mysql2, +redis, node-forge) has no by-name runtime dispatcher behind its codegen-wired +`NativeMethodCall` rows, so declining its fast path would trade a wrong answer +for no answer. Native class statics (`Buffer.concat`, `URL.parse`) are excluded +for the same reason — `Buffer.concat` is already broken through the dynamic +path for the plain non-spread call — and both exclusions are asserted by tests. + +`crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs` asserts the +verdict in both directions: a spread call is diverted, and a non-spread +`path.join('a','b')` still lowers to `PathJoin`. The second half matters because +the generic dispatch is a correct fallback, so a regression that disabled the +fast path everywhere would still print the right answer. It is also why +`node-suite/path/join/type-errors-extra.ts` has spread-called `path.join` since +long before this fix and stayed green throughout — it only spreads segments Node +rejects, so both the broken and the correct lowering threw. Behaviour is +byte-compared against node in new `node-suite/path/join/spread.ts`, +`node-suite/path/resolve/spread.ts` and `node-suite/util/format/spread.ts`; +`--suite node-suite --module path` is 94/94. From c175383d7c86d5b3c0b9340f09396ffd57d90f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 21:12:03 +0200 Subject: [PATCH 3/3] chore: bump version to 0.5.1421 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5c50cda45..58ab9bab18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1420 +**Current Version:** 0.5.1421 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index eb8203a8e9..f450ec7d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1420" +version = "0.5.1421" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1420" +version = "0.5.1421" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1420" +version = "0.5.1421" [[package]] name = "perry-ui-tvos" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1420" +version = "0.5.1421" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index fdff82de8d..db8820c978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1420" +version = "0.5.1421" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"