diff --git a/CLAUDE.md b/CLAUDE.md index 76534551a6..fbfff02673 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.1425 +**Current Version:** 0.5.1426 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index cf5bd2a358..a34a2d893d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1425" +version = "0.5.1426" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1425" +version = "0.5.1426" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1425" +version = "0.5.1426" [[package]] name = "perry-ui-tvos" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1425" +version = "0.5.1426" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index bfc61e1b6a..5b083490b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1425" +version = "0.5.1426" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7734-narrow-spread-bail-querystring-bridge.md b/changelog.d/7734-narrow-spread-bail-querystring-bridge.md new file mode 100644 index 0000000000..645d063a11 --- /dev/null +++ b/changelog.d/7734-narrow-spread-bail-querystring-bridge.md @@ -0,0 +1,32 @@ +Follow-up to #7726 (issue #7720). A 32-case A/B matrix over the node-core spread +surface — this tree vs. the same tree with the guard forced to `false`, both +against node 26.5.1 — found two calls that were correct before #7726 and +`undefined` after, plus two wrong-to-differently-wrong conversions. The matrix is +now 17 fixed / 14 same / 1 wrong-to-wrong / **0 regressed**. + +Completed the `node:querystring` runtime bridge. `nm_dispatch_querystring` +advertises `escape`/`unescape`/`stringify`/`encode`/`parse`/`decode` alongside +`unescapeBuffer`, but the stdlib function it calls +(`js_querystring_native_dispatch`) implemented only `unescapeBuffer` and returned +`undefined` for the rest. That made every INDIRECT form silently `undefined` long +before spread calls were routed there — `const e = qs.escape; e("a b")` and +`const d: any = qs; d.escape("a b")` both produced `undefined` while the +statically dispatched `qs.escape("a b")` was correct. The `js_querystring_*` +entry points all existed; only the bridge rows were missing (`encode`/`decode` +are Node's aliases for `stringify`/`parse`). + +Narrowed the #7726 sub-namespace rule to the DOTTED tags the runtime dispatcher +actually has a bucket for (`path.posix`, `path.win32`, `util.types`, +`crypto.subtle`/`webcrypto`, `punycode.ucs2`) instead of deriving it from +`NODE_BUILTIN_MODULES`. There is no `fs.promises` bucket, so the derived rule +diverted `dns.promises.lookup(...args)` into a silent `undefined` and +`import { promises } from "node:fs"; promises.readFile(...args)` into a +synchronous `TypeError: value is not a function` where a rejected promise used to +arrive. The slash sub-module tags are rejected too: the direct import +(`import fsp from "node:fs/promises"`) already reaches the generic tail without +the bail, measured identical on both arms. + +Known and deliberate: `events.listenerCount(...args)` still turns a bogus +`ERR_INVALID_ARG_TYPE` throw into `undefined` — `nm_dispatch_events` implements +only `init` and `EventEmitterAsyncResource`, so there is no arm to reach. Both +are wrong (node returns a count); completing that dispatcher is its own change. 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 25473409c4..284bdb219e 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -262,23 +262,36 @@ fn is_node_core(module: &str) -> bool { crate::ir::is_node_builtin_module(module.strip_prefix("node:").unwrap_or(module)) } +/// Sub-namespaces of a node-core module that the runtime by-name dispatcher has +/// a bucket for — the DOTTED tags in `nm_module_index` (perry-runtime), which +/// perry-codegen mirrors in `nm_install_symbol`. This is the third mirror and is +/// deliberately kept to the handful that matter. +/// +/// The list is an allowlist, not a derivation from `NODE_BUILTIN_MODULES`: +/// `fs/promises` and `dns/promises` are real node-core module names, but there +/// is no `fs.promises` / `dns.promises` dispatch bucket, so treating the +/// property as a namespace and routing `promises.readFile(...args)` to the +/// dynamic path yields `TypeError: value is not a function` — worse than the +/// positional fold it replaced. Measured, not assumed (#7720 follow-up). +pub(super) fn sub_namespace_has_dispatch_bucket(module: &str, sub: &str) -> bool { + matches!( + (module.strip_prefix("node:").unwrap_or(module), sub), + ("path", "posix" | "win32") + | ("util", "types") + | ("crypto", "subtle" | "webcrypto") + | ("punycode", "ucs2") + ) +} + /// 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? +/// (`import { posix } from "node:path"`) 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") - ) + export == "default" || sub_namespace_has_dispatch_bucket(module, export) } /// Does `name` denote a node-core module NAMESPACE in this scope — a @@ -292,12 +305,30 @@ fn is_submodule_export(module: &str, export: &str) -> bool { /// 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)) { + if is_node_core(module) + && is_top_level_module(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_some_and(|m| is_node_core(m) && is_top_level_module(m)) +} + +/// Reject the slash sub-module tags (`fs/promises`, `dns/promises`, +/// `assert/strict`). +/// +/// A NAMED import of one (`import { promises } from "node:fs"`) registers under +/// the slash tag, but its local does not read back as a dispatchable namespace +/// value — diverting `promises.readFile(...args)` turned a rejected promise into +/// a synchronous `TypeError: value is not a function`, which is worse than the +/// wrong error code it replaced. The DIRECT import +/// (`import fsp from "node:fs/promises"`) needs no help from the bail: it +/// already reaches the generic tail on its own (measured — identical HIR and +/// `ENOENT` output on both arms), so excluding the slash tags costs nothing. +fn is_top_level_module(module: &str) -> bool { + !module.strip_prefix("node:").unwrap_or(module).contains('/') } /// Is `recv` (a call's RECEIVER) a node-core module namespace, or a @@ -313,8 +344,25 @@ fn receiver_is_node_builtin_module(ctx: &LoweringContext, recv: &ast::Expr) -> b .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()), + // 3-level sub-namespace (`path.posix.join`, `util.types.isDate`): the + // ROOT must be a module namespace AND the property must be a + // bucket-backed sub-namespace. Recursing on the root alone claimed + // `fs.promises.readFile(...)` / `dns.promises.lookup(...)` too, which + // have no bucket. + ast::Expr::Member(inner) => { + let ast::Expr::Ident(root) = unwrap_ts_wrappers(inner.obj.as_ref()) else { + return false; + }; + let Some(sub) = super::static_call_prop_name(&inner.prop) else { + return false; + }; + let Some((module, export)) = ctx.lookup_native_module(root.sym.as_ref()) else { + return false; + }; + is_node_core(module) + && matches!(export, None | Some("default")) + && sub_namespace_has_dispatch_bucket(module, sub) + } // `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)), 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 index 331720ad6b..70ea4a9e3f 100644 --- 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 @@ -133,6 +133,67 @@ fn class_statics_keep_their_lowering() { ); } +#[test] +fn sub_namespace_allowlist_is_the_runtime_bucket_set() { + // NOT derived from `NODE_BUILTIN_MODULES`: `fs/promises` and `dns/promises` + // are real node-core module names, but the runtime by-name dispatcher has + // no `fs.promises` / `dns.promises` BUCKET. Re-deriving this list from the + // module names is the exact mistake that shipped, so pin it. + for (module, sub) in [ + ("path", "posix"), + ("path", "win32"), + ("util", "types"), + ("crypto", "subtle"), + ("punycode", "ucs2"), + ] { + assert!( + super::native_module::sub_namespace_has_dispatch_bucket(module, sub), + "{module}.{sub} should be diverted" + ); + } + for (module, sub) in [("fs", "promises"), ("dns", "promises"), ("stream", "web")] { + assert!( + !super::native_module::sub_namespace_has_dispatch_bucket(module, sub), + "{module}.{sub} has no dispatch bucket and must keep its lowering" + ); + } +} + +#[test] +fn bucketless_sub_namespaces_keep_their_lowering() { + // `dns.promises.lookup(...)` had a fast path (it threw + // `ERR_INVALID_ARG_TYPE`); diverting it produced a silent `undefined`. + // `import { promises } from 'node:fs'` registers under the `fs/promises` + // slash tag, and diverting it turned a rejected promise into a synchronous + // `TypeError: value is not a function`. Both must stay put. + // + // (`fs.promises.readFile(...args)` is deliberately absent: it already + // reached the generic tail before this change, so a `CallSpread` there is + // pre-existing and asserting on it would test nothing.) + for src in [ + "import dns from 'node:dns'; const a = ['localhost']; dns.promises.lookup(...a);", + "import { promises } from 'node:fs'; const a = ['/x','utf8']; promises.readFile(...a);", + ] { + let h = hir(src); + assert!( + !h.contains("CallSpread"), + "bucket-less sub-namespace was diverted: {src}" + ); + } +} + +#[test] +fn bucket_backed_sub_namespaces_are_diverted() { + // The dotted tags `nm_module_index` really has a bucket for. + for src in [ + "import path from 'node:path'; const a = ['/x','y']; console.log(path.posix.join(...a));", + "import path from 'node:path'; const a = ['/x','y']; console.log(path.win32.join(...a));", + "import util from 'node:util'; const a = [new Date()]; console.log(util.types.isDate(...a));", + ] { + assert!(declined_fast_path(src), "still folded positionally: {src}"); + } +} + #[test] fn non_module_spread_intrinsics_are_untouched() { // `Math` / `Object` / array receivers are not node-core modules, so their diff --git a/crates/perry-stdlib/src/querystring.rs b/crates/perry-stdlib/src/querystring.rs index ace744b458..e979386a95 100644 --- a/crates/perry-stdlib/src/querystring.rs +++ b/crates/perry-stdlib/src/querystring.rs @@ -228,6 +228,17 @@ pub unsafe extern "C" fn js_querystring_unescape_buffer( /// Runtime bridge for captured native-module callables such as /// `const f = querystring.unescapeBuffer; f(...)`. +/// +/// #7720 follow-up: this used to implement ONLY `unescapeBuffer` while the +/// runtime arm that calls it advertised the whole set +/// (`nm_dispatch_querystring` matches `"unescapeBuffer" | "unescape" | +/// "escape" | "stringify" | "encode" | "parse" | "decode"`). Every other name +/// fell to `_ => undefined`, so EVERY dynamic form silently produced +/// `undefined` — `const d: any = qs; d.escape("a b")`, `const e = qs.escape; +/// e("a b")`, and (once spread calls started routing here) `qs.escape(...args)`. +/// The `js_querystring_*` FFI entry points all existed already; only this +/// bridge was missing, which is why the statically-dispatched +/// `qs.escape("a b")` was always correct and every indirection was not. #[no_mangle] pub unsafe extern "C" fn js_querystring_native_dispatch( method: *const u8, @@ -256,6 +267,18 @@ pub unsafe extern "C" fn js_querystring_native_dispatch( f64::from_bits(JSValue::pointer(buf as *const u8).bits()) } } + "escape" => js_querystring_escape(arg(0)), + "unescape" => js_querystring_unescape(arg(0)), + // Node aliases `encode`→`stringify` and `decode`→`parse`. + "stringify" | "encode" => js_querystring_stringify(arg(0), arg(1), arg(2), arg(3)), + "parse" | "decode" => { + let obj = js_querystring_parse(arg(0), arg(1), arg(2), arg(3)); + if obj.is_null() { + undefined + } else { + f64::from_bits(JSValue::pointer(obj as *const u8).bits()) + } + } _ => undefined, } } diff --git a/test-parity/node-suite/querystring/aliases/dynamic-dispatch.ts b/test-parity/node-suite/querystring/aliases/dynamic-dispatch.ts new file mode 100644 index 0000000000..e1096255a6 --- /dev/null +++ b/test-parity/node-suite/querystring/aliases/dynamic-dispatch.ts @@ -0,0 +1,23 @@ +// #7720 follow-up: every INDIRECT form of a querystring method used to return +// `undefined`. `nm_dispatch_querystring` advertised the whole set +// (escape/unescape/stringify/encode/parse/decode) but the stdlib bridge it +// calls implemented only `unescapeBuffer`, so the statically-dispatched +// `qs.escape("a b")` was correct while a captured, type-erased or spread call +// of the same method silently produced `undefined`. +import qs from "node:querystring"; + +const dyn: any = qs; +const captured = qs.escape; +const args: [string] = ["a b"]; + +console.log("static:", qs.escape("a b")); +console.log("captured:", (captured as any)("a b")); +console.log("dynamic:", dyn.escape("a b")); +console.log("spread:", qs.escape(...args)); + +console.log("unescape dynamic:", dyn.unescape("a%20b")); +console.log("stringify dynamic:", dyn.stringify({ a: 1, b: "x y" })); +console.log("encode alias:", dyn.encode({ a: 1 })); +console.log("parse dynamic:", JSON.stringify(dyn.parse("a=1&b=2"))); +console.log("decode alias:", JSON.stringify(dyn.decode("a=1"))); +console.log("unknown method:", String(dyn.definitelyNotAMethod));