From f869baa9c5efa00cfba2a4ccb629c28327ea7db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 14:21:41 +0200 Subject: [PATCH] fix(codegen): route const rest exports through the closure spread call (#10197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ns.member(...spread)` had a fast path (#6475) for namespace members that are rest-parameter exports: bundle every argument into one array and call `perry_fn___` directly. For a `let`/`const`-bound export (`export const mergeAll = (...ctxs) => …`) that symbol is the export's zero-arg VALUE getter, not the function body, so the call returned the closure itself and the expression evaluated to the function. Effect's `Layer.mergeAll` does exactly `Context.mergeAll(...contexts)` through a namespace import, which is why every OpenCode command died in its bootstrap with `Cannot read properties of undefined (reading 'size')`. Mirror `try_lower_namespace_member_call`: when the member is in `imported_vars`, skip the fast path so the closure-callee path reads the value through the getter and applies the spread with `js_closure_call_apply_with_spread`. Function declarations keep the direct symbol call. Claude-Session: https://claude.ai/code/session_01As1fetJAqDFib4n7Wm5Suo --- .../10197-namespace-spread-call-var.md | 3 ++ crates/perry-codegen/src/expr/call_spread.rs | 19 ++++++++- .../tests/source_graph_export_regressions.rs | 2 + .../issue_10197.rs | 41 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 changelog.d/10197-namespace-spread-call-var.md create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10197.rs diff --git a/changelog.d/10197-namespace-spread-call-var.md b/changelog.d/10197-namespace-spread-call-var.md new file mode 100644 index 0000000000..faf4cbcc73 --- /dev/null +++ b/changelog.d/10197-namespace-spread-call-var.md @@ -0,0 +1,3 @@ +### Fixed + +- `ns.member(...spread)` on a `const`-bound rest-parameter export (`export const mergeAll = (...ctxs) => …`, e.g. effect's `Context.mergeAll(...contexts)` inside `Layer.mergeAll`) now calls the closure; the spread fast path treated the export's value getter as the function body and evaluated to the function instead (#10197). diff --git a/crates/perry-codegen/src/expr/call_spread.rs b/crates/perry-codegen/src/expr/call_spread.rs index eb219ff084..314b950d37 100644 --- a/crates/perry-codegen/src/expr/call_spread.rs +++ b/crates/perry-codegen/src/expr/call_spread.rs @@ -431,7 +431,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else { ctx.imported_func_has_rest.contains(property) }; - if ctx.namespace_imports.contains(ns_name) && has_rest && declared_count == 1 { + // #10197: a `let`/`const`-bound export (`export const + // mergeAll = (...ctxs) => …`) has no callable + // `perry_fn___` — that symbol is its zero-arg + // VALUE getter — so bundling the spread into it returns + // the closure instead of calling it (effect's + // `Context.mergeAll(...contexts)` in `Layer.mergeAll`). + // Mirror `try_lower_namespace_member_call`: vars take the + // closure-callee path below, which reads the value and + // applies the spread through + // `js_closure_call_apply_with_spread`. + let is_var = ctx + .imported_vars + .contains(&crate::namespace_member_var_key(ns_name, property)); + if ctx.namespace_imports.contains(ns_name) + && has_rest + && declared_count == 1 + && !is_var + { let source_prefix_opt = ctx .namespace_member_prefixes .get(&(ns_name.clone(), property.clone())) diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 12c85b80fe..c250983905 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -956,3 +956,5 @@ fn mixed_type_and_value_specifier_import_keeps_runtime_edge() { } #[path = "source_graph_export_regressions/issue_10160.rs"] mod issue_10160; +#[path = "source_graph_export_regressions/issue_10197.rs"] +mod issue_10197; diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10197.rs b/crates/perry/tests/source_graph_export_regressions/issue_10197.rs new file mode 100644 index 0000000000..c6a9a66112 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10197.rs @@ -0,0 +1,41 @@ +//! `ns.member(...spread)` on a `const`-bound rest-parameter export must call +//! the closure, not evaluate to it (#10197). + +use super::{compile_and_run, write}; + +fn lib(dir: &std::path::Path) { + write( + dir, + "lib.ts", + "export const count = (...xs: any[]) => xs.length\n\ + export const mergeAll = (...ctxs: any[]) => {\n\ + \x20 const m = new Map()\n\ + \x20 for (let i = 0; i < ctxs.length; i++) ctxs[i].mapUnsafe.forEach((v: any, k: any) => m.set(k, v))\n\ + \x20 return { mapUnsafe: m }\n\ + }\n\ + export function countFn(...xs: any[]) { return xs.length }\n\ + export const mk = (k: string) => ({ mapUnsafe: new Map([[k, k.length]]) })\n\ + export const fill = (n: number, f: (i: number) => any) => { const out = new Array(n); for (let i = 0; i < n; i++) out[i] = f(i); return out }\n", + ); +} + +#[test] +fn namespace_member_spread_call_invokes_const_rest_exports() { + let dir = tempfile::tempdir().unwrap(); + lib(dir.path()); + write( + dir.path(), + "main.ts", + "import * as Lib from \"./lib\"\n\ + import { fill, mk } from \"./lib\"\n\ + const other = fill(3, (i) => mk(\"k\".repeat(i + 1)))\n\ + console.log(Lib.count(...other), Lib.count(0, ...other), Lib.countFn(...other), Lib.countFn(0, ...other))\n\ + console.log(Lib.mergeAll(...other).mapUnsafe.size, (Lib as any)[\"mergeAll\"](...other).mapUnsafe.size)\n\ + const inner = (context: any) => Lib.mergeAll(...(context as any))\n\ + console.log(inner(other).mapUnsafe.size, Lib.mk(...([\"zz\"] as [string])).mapUnsafe.size)\n", + ); + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "3 4 3 4\n3 3\n3 1\n" + ); +}