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.929 — fix(hir): anonymous `export default function () { ... }` lowering — emit the `__default` symbol so zod/vitest under `perry.compilePackages` link. **Symptom.** The `perry.compilePackages: ["zod"]` repro left over from #836 (`import { z } from "zod"; console.log(z.object({a:z.string()}).parse({a:'hi'}).a);`) still link-failed with `Undefined symbols: _perry_fn_node_modules_zod_src_v4_locales_en_ts__default`. The producer module `zod/src/v4/locales/en.ts` exports an anonymous default function: `export default function () { return { ... }; }`. vitest fails with the same shape, surfacing as `___perry_wrap_perry_fn_node_modules_vitest_dist_index_js__default` — the rename wrapper added in #837, missing because the underlying `perry_fn_..._default` was never emitted. **Root cause.** `crates/perry-hir/src/lower.rs` handled `ast::ModuleDecl::ExportDefaultDecl(ast::DefaultDecl::Fn(fn_expr))` by checking `fn_expr.ident` and, when present, pushing an `Export::Named` entry with no function lowering (an old TODO). The `fn_expr.ident == None` branch (the anonymous case) was a no-op — the function body was dropped entirely, no HIR `Function` was created, and codegen never emitted `perry_fn_<src>__default`. The `__perry_wrap_perry_fn_<src>__default` rename wrapper from #837 then had nothing to point at. The named-default form has its own separate bug (function body also dropped) that's left untouched here per the issue's scope-narrow directive. **Fix.** When `fn_expr.ident == None` and the function has a body, synthesize an `ast::FnDecl` with ident `default`, run it through the same `lower_fn_decl` → `is_exported = true` → `func_defaults` + `exported_functions` + `Export::Named { local: "default", exported: "default" }` flow that `ExportDecl::Fn` uses. The synthesized name `default` flows straight through `sanitize()` unchanged (it's `[a-z]+`), so the LLVM symbol is `perry_fn_<src>__default` — exactly what consumers ask for. Since `f.name == exported_name == "default"`, the alias-wrapper loop at `crates/perry-codegen/src/codegen.rs:2259` is a no-op (no indirection needed), and the undefined-stub fallback at line 2310 skips emission because `llmod.has_function("perry_fn_<src>__default")` is true. **Why this layer (vs. routing through `lower_fn_expr`).** `lower_fn_expr` returns `Expr::Closure` — a runtime closure value, not a top-level function. Top-level emission as `perry_fn_<src>__default` is what the consumer's `ExternFuncRef { name: "default" }` resolves against (`crates/perry-codegen/src/lower_call.rs:779-1054`), so the function must live in `module.functions` with `is_exported = true`. Synthesizing the `FnDecl` and reusing `lower_fn_decl` keeps every downstream invariant (default-param resolution, native-instance return-type registration, arguments-param synth, etc.) without reimplementing them at this site. **Out of scope.** Named-default form (`export default function foo() {...}`) still drops the function body — separate bug, separate PR. The remaining zod runtime undefined output from `z.object(...).parse(...).a` is also separate and tracks back to other lowering paths. **Validation.** The minimal repro (producer: `export default function () { return 42; }`; consumer: `import x from "./producer.ts"; console.log(x())`) now compiles and prints `42`, matching node `--experimental-strip-types` byte-for-byte. The zod link-error from #836's closeout is gone — `perry main.ts -o out` succeeds. `cargo build --release -p perry-runtime -p perry-stdlib -p perry` clean. New regression test `test-files/test_issue_anonymous_default_export.ts` + `test-files/test_issue_anonymous_default_export_pkg/{producer,consumer}.ts`. **Files touched.** `crates/perry-hir/src/lower.rs` (~60 lines in the `ExportDefaultDecl::Fn` branch), `test-files/test_issue_anonymous_default_export.ts` + `test-files/test_issue_anonymous_default_export_pkg/` (regression fixture). Refs #793 (Node.js + TypeScript compatibility roadmap), #836 (zod compilePackages closeout), #837 (default/null/undefined-named-exports wrapper).

## 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`).
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.928
**Current Version:** 0.5.929


## TypeScript Parity Status
Expand Down
Loading
Loading