Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<src>___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_<src>__$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_<src>__$ZodCheck` referenced by the consumer's `js_closure_alloc_singleton(@__perry_wrap_…)` path was emitted at `__perry_wrap_perry_fn_<src>___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_<fn>` 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_<index_ts>__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.<member>` 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_<src>__<raw_exported>` forwards to `perry_fn_<src>__<sanitize(exported)>` (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_<src>__<raw_exported>` forwards to the sanitized wrapper if one exists, falling back to a no-op `ret double <undefined>` 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_<src>__<exported>` defined as a no-op `ret double <undefined>` whenever `local == exported`, the local is not a HIR function (so the regular `__perry_wrap_<fn>` 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_<prefix>` 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_<target_prefix>", referenced from: _<consumer>__init_body`. **Root cause.** Producer-side codegen in `crates/perry-codegen/src/codegen.rs` gated emission of the `@__perry_ns_<prefix>` 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_<target_prefix>` 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).
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading