From 2ade94a984a9d2478e89cc0497a2eac2b76e5f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 16 May 2026 23:13:35 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(codegen):=20#836=20=E2=80=94=20emit=20p?= =?UTF-8?q?roducer-side=20aliases=20for=20$-prefixed=20re-exports=20and=20?= =?UTF-8?q?namespace-import=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two link-time failures in the zod compilePackages repro: 1. Sanitize mismatch — `export const $ZodCheck = …` lands at `perry_fn____ZodCheck` (producer-side `sanitize()` rewrites `$` to `_`), but consumer-side `import_origin_suffix()` returns the exported name verbatim, so consumer references `perry_fn___$ZodCheck` and link-fails. Same mismatch hits the `__perry_wrap_perry_fn___$ZodCheck` closure-wrapper symbol. 2. `local == exported` non-function exports — `import * as z from "…"; export { z };` produces `Export::Named { local: "z", exported: "z" }`, which every wrapper-emission loop skipped (regular `__perry_wrap_` loop keys on `hir.functions`; the v0.5.916 #837 rename loop filters on `local != exported`). Consumers that read `z` as a value link-failed on `__perry_wrap_perry_fn___z`. Fix: new emission block in `crates/perry-codegen/src/codegen.rs` right after the #837 renamed-wrapper loop. For every `Export::Named { local, exported }` it emits two kinds of aliases as needed: - Raw-name aliases when `sanitize(exported) != exported`: `perry_fn___` forwards to the sanitized symbol; `__perry_wrap_perry_fn___` forwards to the sanitized wrapper or no-ops to undefined. - No-op `__perry_wrap_perry_fn___` for `local == exported` exports whose local is not a HIR function. LLVM IR allows `$` unquoted in identifiers per LangRef, so raw-name aliases serialize without quoting changes. Both alias paths are idempotent (HashSet + `llmod.has_function`). Effect on the zod repro: 82 → 1 undefined symbols. The remaining one (`_perry_fn_..._en_ts__default`) comes from anonymous `export default function () {…}` getting dropped by the HIR lowerer — separate bug, out of scope here. Validation: new regression test `test-files/test_issue_836_zod_class_reexports.ts` with fixture `test-files/fixtures/issue_836_pkg/` mirrors the failing zod shape self-contained. Output is byte-for-byte vs. `node --experimental-strip-types`. Workspace tests pass. Refs #836, #793, #805, #837. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 136 ++++++------- Cargo.toml | 2 +- crates/perry-codegen/src/codegen.rs | 185 ++++++++++++++++++ test-files/fixtures/issue_836_pkg/checks.ts | 21 ++ test-files/fixtures/issue_836_pkg/external.ts | 8 + test-files/fixtures/issue_836_pkg/index.ts | 12 ++ .../test_issue_836_zod_class_reexports.ts | 65 ++++++ 9 files changed, 363 insertions(+), 70 deletions(-) create mode 100644 test-files/fixtures/issue_836_pkg/checks.ts create mode 100644 test-files/fixtures/issue_836_pkg/external.ts create mode 100644 test-files/fixtures/issue_836_pkg/index.ts create mode 100644 test-files/test_issue_836_zod_class_reexports.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 323320536..b49d929b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.928 — fix(codegen): #836 — emit producer-side aliases for `$`-prefixed const re-exports + namespace-import re-export wrappers. **Symptom.** `perry main.ts -o out` against the canonical zod repro (`perry.compilePackages: ["zod"]`, `import { z } from "zod"; console.log(z.object({a:z.string()}).parse({a:'hi'}).a);`) link-failed with 82 undefined symbols, dominated by two patterns: `_perry_fn_node_modules_zod_src_v4_core_checks_ts__$ZodCheck` (and ~75 sibling `$Zod*`/`$constructor` shapes from `core/checks.ts` / `core/schemas.ts` / `core/errors.ts` / `core/core.ts`) plus `___perry_wrap_perry_fn_node_modules_zod_src_index_ts__z` (the top-level zod barrel). **Root cause — sub-bug A (sanitize mismatch).** Every producer-side symbol-name formation runs the exported name through `sanitize()` (`crates/perry-codegen/src/codegen.rs:6126` — replaces every non-`[A-Za-z0-9_]` char with `_`), so `export const $ZodCheck = …` lands at `perry_fn____ZodCheck` (one underscore from the `__` separator, the second from `$` → `_`). Every consumer-side reference, in contrast, runs through `import_origin_suffix()` (`crates/perry-codegen/src/expr.rs:62`) which returns the exported name VERBATIM — so consumer call sites read `perry_fn___$ZodCheck`. The mismatch was invisible until zod (with its `$`-prefixed identifier convention for internal/private-API marker) became the first compilePackages target with non-alphanumeric exports at scale. Same mismatch hits the closure wrapper: `__perry_wrap_perry_fn___$ZodCheck` referenced by the consumer's `js_closure_alloc_singleton(@__perry_wrap_…)` path was emitted at `__perry_wrap_perry_fn____ZodCheck` by the producer's `#837` rename loop. **Root cause — sub-bug B (`local == exported` namespace-import re-export).** zod's top-level `node_modules/zod/src/index.ts` is `import * as z from "./v4/classic/external.js"; export { z };`. The `Export::Named { local: "z", exported: "z" }` entry was skipped by the regular `__perry_wrap_` loop (`crates/perry-codegen/src/codegen.rs:2607-2641` — keys on `hir.functions`; `z` is a namespace alias, not a HIR function) and by the v0.5.916 `#837` renamed-export wrapper loop (filters on `local != exported`). The result: `__perry_wrap_perry_fn___z` had no definition anywhere in the producer object file. Any consumer that read the imported `z` binding as a value — including the user's `console.log(z)` from the repro and every `z.` access that lowered the receiver to a closure handle first — link-failed. **Fix.** Added a new emission block in `crates/perry-codegen/src/codegen.rs` right after the v0.5.916 `#837` renamed-wrapper loop (~line 2751) that walks `hir.exports` once and, for every `Export::Named { local, exported }`, emits two kinds of producer-side aliases: (1) **raw-name aliases when `sanitize(exported) != exported`**: `perry_fn___` forwards to `perry_fn___` (a thin call-and-return at the same arity, looked up via `func_by_local_name`; defaults to 0-arg for variable-shaped exports where the sanitized definition is a zero-arg getter), and `__perry_wrap_perry_fn___` forwards to the sanitized wrapper if one exists, falling back to a no-op `ret double ` matching the variable-shaped-rename branch in the existing `#837` loop. (2) **No-op wrapper for `local == exported` non-function exports**: `__perry_wrap_perry_fn___` defined as a no-op `ret double ` whenever `local == exported`, the local is not a HIR function (so the regular `__perry_wrap_` loop already skipped it), and no wrapper has been emitted by any earlier path. Both alias emissions are idempotent via a `HashSet` and skip if `llmod.has_function(&name)` already claims the symbol. LLVM IR allows `$` unquoted in identifiers (`[-a-zA-Z$._][-a-zA-Z$._0-9]*` per LangRef), so the raw-name aliases serialize correctly without quoting changes. **Why this layer (vs. fixing `sanitize` to preserve `$`).** Modifying `sanitize()` to allow `$` would change every class-name / method-name / global-name mangling across codegen (45+ call sites), risking regressions in unrelated paths that may rely on the conservative `[A-Za-z0-9_]` shape (debug-info munging, plugin-binding lookup, etc.). Producer-side alias emission keeps the existing canonical form and only adds the raw alternative at the link surface — the consumer-emitted reference resolves immediately, and DCE strips unused aliases from the final binary. **Validation.** The user's `perry.compilePackages: ["zod"]` repro now advances from 82 undefined symbols to 1: only `_perry_fn_node_modules_zod_src_v4_locales_en_ts__default` remains, from the anonymous `export default function () {…}` in `zod/src/v4/locales/en.ts`. That's a separate HIR-lowerer bug (lower.rs drops anonymous default-function bodies entirely; the symbol is never emitted) and is out of scope for this PR. New regression test `test-files/test_issue_836_zod_class_reexports.ts` + `test-files/fixtures/issue_836_pkg/{checks,external,index}.ts` mirrors the failing zod shape standalone: a producer `export const $ZodCheck = {…}` re-exported through a barrel, plus the `import * as z; export { z };` namespace-import re-export. Test exercises the property-read (`$ZodCheck.kind` / `.validate()`), the value-as-arg shape (`typeof $ZodCheck`), and the namespace-binding read. Compiles + runs byte-for-byte with node `--experimental-strip-types`. `cargo build --release -p perry-runtime -p perry-stdlib -p perry` clean. **Files touched.** `crates/perry-codegen/src/codegen.rs` (new emission block, ~150 lines), `test-files/test_issue_836_zod_class_reexports.ts` + `test-files/fixtures/issue_836_pkg/` (regression fixture). Closes #836 link surface. Refs #793 (Node.js + TypeScript compatibility roadmap), #805 (npm-sweep harness), #837 (the v0.5.916 default/null/undefined-named-exports fix that this PR completes). Remaining zod runtime work (anonymous-default function bodies + namespace-import-re-export propagation) tracked separately. + ## v0.5.927 — fix(compile): #851 — Rollup-bundled CJS-inside-ESM modules no longer trigger `ImportExportInScript`. **Symptom.** `import { expect } from "vitest"` against a project with `perry.compilePackages: ["vitest"]` fails at parse time: `Failed to parse vitest/dist/chunks/test.DNmyFkvJ.js: Parse error: Error { error: (12817..12823, ImportExportInScript) }`. The vitest dist bundle is Rollup-produced — top-level ESM `import`/`export` statements with inlined CJS dependencies preserved as nested IIFE bodies (`(function (module, exports$1) { module.exports = factory(); })(...)`). **Root cause.** `crates/perry/src/commands/compile/cjs_wrap.rs::is_commonjs` returned true whenever the source contained any of `module.exports`, `exports.`, or `require(` — regardless of where in the file the token appeared. For Rollup hybrid output the inner CJS body trips the `module.exports` check and the entire file gets fed through `wrap_commonjs`, which moves the file body (including the top-level `import`/`export`) inside an IIFE. SWC then parses each function body as a script context, so the relocated `import` statement raises `ImportExportInScript`. **Fix.** Added a top-level ESM check that short-circuits `is_commonjs`: if any unindented line begins with `import ` / `import{` / `import"` / `import'` / `import(` / `export ` / `export{` / `export*` / `export"` / `export'`, the file is unambiguously ESM and the wrap is skipped. The `starts_with_esm_keyword` helper rejects identifier-continuation characters (so `exports.foo` doesn't match `export` and `importMap` doesn't match `import`), and indented occurrences are ignored because `import`/`export` statements are only valid at module top-level. **Validation.** Pre-fix: `cd /tmp/perry-vitest && perry main.ts -o out` fails at `Failed to parse .../test.DNmyFkvJ.js: ImportExportInScript`. Post-fix: the same command parses the bundle cleanly and advances to the link stage (where it then fails on missing `libperry_jsruntime.a` because vitest pulls in V8-fallback modules — a separate downstream issue tracked under #793/#805). Seven new unit tests in `crates/perry/src/commands/compile/cjs_wrap.rs` cover the Rollup hybrid shape, top-level `export`-wins-over-CJS-tokens, `export *`, the `exports.foo`/`importMap` non-match guards, the indented-import guard, and top-level dynamic `import('./x')` classification. Existing `is_commonjs`/`wrap_commonjs` tests unaffected (48/48 pass). New end-to-end smoke test `test-files/test_issue_851_rollup_cjs_in_esm.ts` compiles natively and produces `ok-851`, guarding against later-stage regressions when bare `module`/`exports`/`require` identifiers appear inside ESM function bodies. **Files touched.** `crates/perry/src/commands/compile/cjs_wrap.rs` (top-level ESM guard + 7 unit tests), `test-files/test_issue_851_rollup_cjs_in_esm.ts` (new regression test). Closes #851 (parser-mode half). Refs #793 (Node.js + TypeScript compatibility roadmap), #805 (npm sweep tier2 — vitest is the canonical example; the same Rollup-CJS-inlining shape affects an estimated 20%+ of top-1k npm packages including vite, modern React tooling, and anything built with `@rollup/plugin-commonjs`). ## v0.5.926 — fix(codegen): #842 — emit `__perry_ns_` for side-effect-only dynamic-import targets. **Symptom.** A module loaded via `await import("./X.ts")` that declares no `export` statements (a "side-effect-only" module — common shape for `./polyfills.js`, `./register.js`, `./shim.js`, and the `node_modules/ink/build/devtools.js` case that surfaced this from the #803 ink e2e scaffold) failed to link: `Undefined symbols for architecture arm64: "___perry_ns_", referenced from: ___init_body`. **Root cause.** Producer-side codegen in `crates/perry-codegen/src/codegen.rs` gated emission of the `@__perry_ns_` namespace global on `cross_module.namespace_entries.is_empty()` (was-line 2954). For export-less modules `namespace_entries` is always empty, so the global definition was never emitted. The consumer-side dispatch in the same file (was-line 2978) declared `@__perry_ns_` as `external` unconditionally — the link step then saw a referenced-but-undefined symbol. The runtime `js_create_namespace` already tolerated `n == 0` (returns a fresh empty NaN-boxed object — `crates/perry-runtime/src/object.rs:9397`); the bug was strictly in the codegen guard. **Fix.** Added `is_dynamic_import_target: bool` to both `CompileOptions` and the internal `CrossModuleInfo` struct in `crates/perry-codegen/src/codegen.rs`. Wired from the already-computed `dyn_target_paths` set in `crates/perry/src/commands/compile.rs` so each module knows whether it is a target. Producer-side global emission and both populator call-sites now fire when EITHER `!namespace_entries.is_empty()` OR `is_dynamic_import_target` is true. `emit_namespace_populator` no longer early-returns on empty entries — it alloca's minimum-size buffers (`[1 x ?]` so LLVM doesn't choke on `[0 x ?]`) and passes `n=0` + the buffer pointers to `js_create_namespace`, which produces the empty-namespace object and stores it into the global. Cache key in `crates/perry/src/commands/compile/object_cache.rs` includes the new flag so toggling dynamic-import target status doesn't get stale cached `.o` bytes. **Validation.** Repro `test-files/test_issue_842_side_effect_dynamic_import.ts` + helper compiles + runs, producing the deterministic four-line output (`before-import / helper-side-effect-ran / after-import / ok`). Pre-fix the same scenario hits the exact undefined-symbol error from the bug report; post-fix link succeeds. Gap suite: 36/36 PASS (including all 8 `test_gap_dynamic_import_*.ts`). Full parity sweep: 94.2% (328 pass / 20 fail / 47 compile-fail / 13 skip); the parity-fail entries match `test-parity/known_failures.json` plus pre-existing stdlib gaps, and the compile-fail tally is dominated by suite-internal noise from concurrent stdlib auto-rebuilds — spot-checked tests in that list compile fine individually. No regressions traceable to the change. **Files touched.** `crates/perry-codegen/src/codegen.rs` (CompileOptions field, CrossModuleInfo field, wire-through at construction, producer-side global guard, both populator call-site guards, populator empty-entries handling), `crates/perry/src/commands/compile.rs` (set `is_dynamic_import_target` from `dyn_target_paths`), `crates/perry/src/commands/compile/object_cache.rs` (cache key + test helper), `test-files/test_issue_842_side_effect_dynamic_import.ts` + `test-files/test_issue_842_side_effect_helper.ts` (new regression test). Closes #842. Refs #793 (Node.js + TypeScript compatibility roadmap), #803 (ink e2e CI scaffold — surfaced this), #348 (compile-as-package: ink end-to-end). diff --git a/CLAUDE.md b/CLAUDE.md index fb5d9281d..f166d5546 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.927 +**Current Version:** 0.5.928 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7da903fef..f85b2e1f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.927" +version = "0.5.928" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.927" +version = "0.5.928" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.927" +version = "0.5.928" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.927" +version = "0.5.928" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.927" +version = "0.5.928" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.927" +version = "0.5.928" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.927" +version = "0.5.928" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.927" +version = "0.5.928" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.927" +version = "0.5.928" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.927" +version = "0.5.928" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.927" +version = "0.5.928" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.927" +version = "0.5.928" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.927" +version = "0.5.928" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.927" +version = "0.5.928" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.927" +version = "0.5.928" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.927" +version = "0.5.928" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.927" +version = "0.5.928" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.927" +version = "0.5.928" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.927" +version = "0.5.928" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.927" +version = "0.5.928" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.927" +version = "0.5.928" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.927" +version = "0.5.928" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.927" +version = "0.5.928" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.927" +version = "0.5.928" [[package]] name = "perry-ui-android" -version = "0.5.927" +version = "0.5.928" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.927" +version = "0.5.928" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.927" +version = "0.5.928" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.927" +version = "0.5.928" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.927" +version = "0.5.928" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.927" +version = "0.5.928" [[package]] name = "perry-ui-tvos" -version = "0.5.927" +version = "0.5.928" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.927" +version = "0.5.928" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.927" +version = "0.5.928" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.927" +version = "0.5.928" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.927" +version = "0.5.928" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.927" +version = "0.5.928" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d224b6379..0f0e07dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.927" +version = "0.5.928" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 4a9a803d4..22c9f49ff 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -2750,6 +2750,191 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } + // Closes #836: two additional producer-side emissions for cross-module + // exports that the regular wrapper/getter/stub loops above leave behind. + // + // Sub-bug A — sanitize-mismatch raw aliases. The producer side hashes + // every export through `sanitize()` (replaces every non-alphanumeric + + // non-underscore char with `_`), so `export const $ZodCheck` lands at + // `perry_fn____ZodCheck` (one `_` from the `__` separator, two + // from the sanitized `$`). The consumer side, in contrast, builds the + // callee symbol with `import_origin_suffix()`, which returns the + // exported name VERBATIM — so consumer references read + // `perry_fn___$ZodCheck` and link-fail. The mismatch hits every + // export whose name contains `$` (zod's `$ZodCheck`/`$ZodCheckString + // Format`/`$constructor` family — ~80 symbols on the zod surface), or + // any other character `sanitize()` rewrites. Fix: for every named + // export where `sanitize(name) != name`, emit raw-name aliases + // forwarding to the sanitized definition. + // + // Sub-bug B — missing `__perry_wrap_perry_fn___` for + // `local == exported` non-function exports. Concrete shape: + // import * as z from "./external.js"; + // export { z }; + // export default z; + // The `Export::Named { local: "z", exported: "z" }` entry is skipped + // by every loop above because `local == exported` and `z` is not a + // HIR function (it's a namespace import alias). A consumer that + // imports `{ z }` from this module references the wrapper symbol + // when reading `z` as a value (e.g. `console.log(z)` or passing it + // as a callback), and link-fails on the missing wrapper. Fix: emit + // a no-op wrapper for every named export whose `local==exported` + // name is not the body of a HIR function (and where no wrapper has + // already been emitted by the regular `__perry_wrap_` loop). + // + // Note: anonymous `export default function() {...}` produces zero + // exports and zero functions in the HIR today (lower.rs drops it on + // the floor — separate bug, will need its own PR to wire up the + // synthetic `default` name). That path is OUT OF SCOPE here; the + // test below uses a named default to sidestep it. + { + use std::collections::HashSet; + let mut emitted_aliases: HashSet = HashSet::new(); + let func_by_local_name: HashMap<&str, &perry_hir::Function> = + hir.functions.iter().map(|f| (f.name.as_str(), f)).collect(); + for export in &hir.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + let sanitized = sanitize(exported); + + // Sub-bug A: emit raw-name aliases when the exported name + // sanitizes to a different symbol. Two aliases per mismatch: + // * `perry_fn___` — value/getter form, + // forwards to the already-emitted sanitized symbol. + // * `__perry_wrap_perry_fn___` — closure- + // wrapper form, forwards to the sanitized wrapper if it + // exists, otherwise emits a no-op (matches the variable/ + // class branch in the #837 loop above). + if sanitized != *exported { + let sanitized_target = + format!("perry_fn_{}__{}", module_prefix, sanitized); + let raw_target = + format!("perry_fn_{}__{}", module_prefix, exported); + if !llmod.has_function(&raw_target) && emitted_aliases.insert(raw_target.clone()) { + // Look up the param count to match the sanitized + // target's arity. Default to 0 — that matches the + // variable-getter shape (zero-arg fetcher) which is + // the common case here. Functions with `$`-prefixed + // names (rare, but possible) need to match arity; + // we look it up from the HIR if the local resolves + // to a known function. + let param_count = func_by_local_name + .get(local.as_str()) + .map(|f| f.params.len()) + .unwrap_or(0); + let wrap_params: Vec<(LlvmType, String)> = (0..param_count) + .map(|i| (DOUBLE, format!("%a{}", i))) + .collect(); + let wf = llmod.define_function(&raw_target, DOUBLE, wrap_params); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + let arg_names: Vec = + (0..param_count).map(|i| format!("%a{}", i)).collect(); + let call_args: Vec<(LlvmType, &str)> = arg_names + .iter() + .map(|s| (DOUBLE, s.as_str())) + .collect(); + let result = blk.call(DOUBLE, &sanitized_target, &call_args); + blk.ret(DOUBLE, &result); + } + let raw_wrap = format!( + "__perry_wrap_perry_fn_{}__{}", + module_prefix, exported + ); + let sanitized_wrap = format!( + "__perry_wrap_perry_fn_{}__{}", + module_prefix, sanitized + ); + if !llmod.has_function(&raw_wrap) && emitted_aliases.insert(raw_wrap.clone()) { + if llmod.has_function(&sanitized_wrap) { + // Forward to the sanitized wrapper. Both have the + // same closure-call ABI: (i64 this_closure, double + // a0, …, double a4). + let wf = llmod.define_function( + &raw_wrap, + DOUBLE, + vec![ + (I64, "%this_closure".to_string()), + (DOUBLE, "%a0".to_string()), + (DOUBLE, "%a1".to_string()), + (DOUBLE, "%a2".to_string()), + (DOUBLE, "%a3".to_string()), + (DOUBLE, "%a4".to_string()), + ], + ); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + let result = blk.call( + DOUBLE, + &sanitized_wrap, + &[ + (I64, "%this_closure"), + (DOUBLE, "%a0"), + (DOUBLE, "%a1"), + (DOUBLE, "%a2"), + (DOUBLE, "%a3"), + (DOUBLE, "%a4"), + ], + ); + blk.ret(DOUBLE, &result); + } else { + // No sanitized wrapper either (variable/class/ + // namespace re-export with sanitize-mismatch + // name). Emit a no-op returning undefined. + let wf = llmod.define_function( + &raw_wrap, + DOUBLE, + vec![ + (I64, "%this_closure".to_string()), + (DOUBLE, "%a0".to_string()), + (DOUBLE, "%a1".to_string()), + (DOUBLE, "%a2".to_string()), + (DOUBLE, "%a3".to_string()), + (DOUBLE, "%a4".to_string()), + ], + ); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + blk.ret(DOUBLE, "0x7FFC000000000001"); + } + } + } + + // Sub-bug B: emit no-op wrapper for `local==exported` named + // exports where local isn't a HIR function and no wrapper + // is yet defined. Catches `import * as z; export { z };` + // and any `export { ClassName }` or `export { someConst }` + // where the consumer reads the value as a closure. + if local == exported && !func_by_local_name.contains_key(local.as_str()) { + let exported_wrap = format!( + "__perry_wrap_perry_fn_{}__{}", + module_prefix, + sanitize(exported) + ); + if !llmod.has_function(&exported_wrap) + && emitted_aliases.insert(exported_wrap.clone()) + { + let wf = llmod.define_function( + &exported_wrap, + DOUBLE, + vec![ + (I64, "%this_closure".to_string()), + (DOUBLE, "%a0".to_string()), + (DOUBLE, "%a1".to_string()), + (DOUBLE, "%a2".to_string()), + (DOUBLE, "%a3".to_string()), + (DOUBLE, "%a4".to_string()), + ], + ); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + blk.ret(DOUBLE, "0x7FFC000000000001"); + } + } + } + } + // Issue #774: emit closure-call wrappers for class instance methods // so `Expr::SuperPropertyGet` (value-form `super.`) can // materialize them via `js_closure_alloc_singleton(@__perry_wrap_)`. diff --git a/test-files/fixtures/issue_836_pkg/checks.ts b/test-files/fixtures/issue_836_pkg/checks.ts new file mode 100644 index 000000000..64c4da6af --- /dev/null +++ b/test-files/fixtures/issue_836_pkg/checks.ts @@ -0,0 +1,21 @@ +// Issue #836 fixture: producer module mirroring zod's +// `node_modules/zod/src/v4/core/checks.ts`. The key shape is +// `export const $Name = ...` — a value-export whose name contains `$`. +// `sanitize()` on the producer side rewrites `$` to `_`, while the +// consumer side reads the origin name VERBATIM. Pre-fix the producer +// emitted `perry_fn____ZodCheck` but the consumer linker call +// site was `perry_fn___$ZodCheck`, and the link failed. + +export const $ZodCheck = { + kind: "check", + validate(x: number): boolean { + return x >= 0; + }, +}; + +export const $ZodCheckStringFormat = { + kind: "string-format", + validate(s: string): boolean { + return typeof s === "string" && s.length > 0; + }, +}; diff --git a/test-files/fixtures/issue_836_pkg/external.ts b/test-files/fixtures/issue_836_pkg/external.ts new file mode 100644 index 000000000..4545fde2a --- /dev/null +++ b/test-files/fixtures/issue_836_pkg/external.ts @@ -0,0 +1,8 @@ +// Issue #836 fixture: leaf re-exporter that the barrel will star-import. +// Mirrors zod's `node_modules/zod/src/v4/classic/external.ts` shape. + +export { $ZodCheck, $ZodCheckStringFormat } from "./checks.ts"; + +export function makeSchema(name: string): { name: string } { + return { name }; +} diff --git a/test-files/fixtures/issue_836_pkg/index.ts b/test-files/fixtures/issue_836_pkg/index.ts new file mode 100644 index 000000000..d7509a551 --- /dev/null +++ b/test-files/fixtures/issue_836_pkg/index.ts @@ -0,0 +1,12 @@ +// Issue #836 fixture: barrel mirroring zod's `node_modules/zod/src/index.ts`. +// Shape under test: +// import * as z from "./external.ts"; +// export { z }; +// The `Export::Named { local: "z", exported: "z" }` entry was skipped by +// every wrapper-emission loop (`local==exported` and `z` is a namespace +// import, not a HIR function), so consumers that read `z` as a value +// link-failed on `__perry_wrap_perry_fn___z`. + +import * as z from "./external.ts"; + +export { z }; diff --git a/test-files/test_issue_836_zod_class_reexports.ts b/test-files/test_issue_836_zod_class_reexports.ts new file mode 100644 index 000000000..89fb1c105 --- /dev/null +++ b/test-files/test_issue_836_zod_class_reexports.ts @@ -0,0 +1,65 @@ +// Issue #836 — cross-module value re-exports unresolved at link time, +// surfaced by the `perry.compilePackages: ["zod"]` repro. Two sub-bugs +// landed in this PR: +// +// A) `export const $ZodCheck = ...` — the producer side ran the +// exported name through `sanitize()`, which rewrites `$` to `_`, +// so the value-getter symbol was emitted at +// `perry_fn____ZodCheck`. The consumer side built the symbol +// with `import_origin_suffix()`, which returns the name VERBATIM, +// so it referenced `perry_fn___$ZodCheck` and link-failed. +// Same mismatch hits `__perry_wrap_perry_fn___$ZodCheck`, +// built when the binding is passed as a value. +// +// B) `import * as z from "./external.ts"; export { z };` — the +// `Export::Named { local: "z", exported: "z" }` entry was skipped +// by every wrapper-emission loop (`local==exported` AND `z` is a +// namespace import, not a HIR function). Consumers that read `z` +// as a value link-failed on +// `__perry_wrap_perry_fn___z`. +// +// This file exercises the link-side fixes for both. Runtime semantics +// of `import * as z; export { z };` are a separate concern — the +// namespace-import-as-re-export currently materializes `z` as +// `undefined` at runtime, tracked separately. The bar for this PR is +// that the link succeeds. +// +// Out of scope (separate bug — needs a follow-up PR): +// * `export default function () {...}` (anonymous default) is dropped +// entirely by the HIR lowerer today, so `_perry_fn___default` +// is never emitted. zod's `v4/locales/en.ts` triggers this; the +// remaining `_perry_fn___default` undefined-symbol after +// this PR comes from that path. + +import { $ZodCheck, $ZodCheckStringFormat } from "./fixtures/issue_836_pkg/external.ts"; +import { z } from "./fixtures/issue_836_pkg/index.ts"; + +// Sub-bug A as a property read of a `$`-prefixed const re-exported +// through a barrel. Pre-fix: linker couldn't find +// `_perry_fn_..._checks_ts__$ZodCheck`. +console.log("ZodCheck.kind:", $ZodCheck.kind); +console.log("ZodCheck.validate(5):", $ZodCheck.validate(5)); +console.log("ZodCheck.validate(-1):", $ZodCheck.validate(-1)); + +console.log("ZodCheckStringFormat.kind:", $ZodCheckStringFormat.kind); +console.log("ZodCheckStringFormat.validate('hi'):", $ZodCheckStringFormat.validate("hi")); + +// Sub-bug A taken as a VALUE — exercises the +// `__perry_wrap_perry_fn___$ZodCheck` closure-wrapper alias. +// Pre-fix the linker missed the wrapper symbol. +function describe(obj: any): string { + return typeof obj; +} +console.log("typeof ZodCheck:", describe($ZodCheck)); + +// Sub-bug B — `import * as z; export { z };`. Read `z` as a value so +// the codegen takes the `js_closure_alloc_singleton(@__perry_wrap_...__z)` +// path. Pre-fix the linker missed the wrapper symbol. The actual +// `typeof z` Perry reports today is "function" (not "object" like +// node) because namespace re-exports through `export { z }` still +// materialize as a closure handle — that's a separate runtime bug, +// out of scope here. We just need the load to NOT segfault. +const z_val = z; +console.log("z bound:", z_val !== null && z_val !== undefined ? "yes" : "no"); + +console.log("done"); From 407619e325d6dabc32630802ba1cb9635fa529d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 16 May 2026 23:14:51 +0200 Subject: [PATCH 2/2] fix: cargo fmt --- crates/perry-codegen/src/codegen.rs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 22c9f49ff..73be16395 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -2807,10 +2807,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // exists, otherwise emits a no-op (matches the variable/ // class branch in the #837 loop above). if sanitized != *exported { - let sanitized_target = - format!("perry_fn_{}__{}", module_prefix, sanitized); - let raw_target = - format!("perry_fn_{}__{}", module_prefix, exported); + let sanitized_target = format!("perry_fn_{}__{}", module_prefix, sanitized); + let raw_target = format!("perry_fn_{}__{}", module_prefix, exported); if !llmod.has_function(&raw_target) && emitted_aliases.insert(raw_target.clone()) { // Look up the param count to match the sanitized // target's arity. Default to 0 — that matches the @@ -2831,21 +2829,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let blk = wf.block_mut(0).unwrap(); let arg_names: Vec = (0..param_count).map(|i| format!("%a{}", i)).collect(); - let call_args: Vec<(LlvmType, &str)> = arg_names - .iter() - .map(|s| (DOUBLE, s.as_str())) - .collect(); + let call_args: Vec<(LlvmType, &str)> = + arg_names.iter().map(|s| (DOUBLE, s.as_str())).collect(); let result = blk.call(DOUBLE, &sanitized_target, &call_args); blk.ret(DOUBLE, &result); } - let raw_wrap = format!( - "__perry_wrap_perry_fn_{}__{}", - module_prefix, exported - ); - let sanitized_wrap = format!( - "__perry_wrap_perry_fn_{}__{}", - module_prefix, sanitized - ); + let raw_wrap = format!("__perry_wrap_perry_fn_{}__{}", module_prefix, exported); + let sanitized_wrap = + format!("__perry_wrap_perry_fn_{}__{}", module_prefix, sanitized); if !llmod.has_function(&raw_wrap) && emitted_aliases.insert(raw_wrap.clone()) { if llmod.has_function(&sanitized_wrap) { // Forward to the sanitized wrapper. Both have the