diff --git a/CHANGELOG.md b/CHANGELOG.md index 35190dc66..323320536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## 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). ## v0.5.925 — fix(driver): #837 — failed-module init stubs now use the `__init` symbol name the rest of the codegen actually references, so a non-entry module that fails to lower no longer poisons the link. **Symptom (#837 followup).** v0.5.916's 1dd9f471 closed the original report (the `__perry_wrap_perry_fn___default` wrapper-symbol gap for `export default function`), but the issue stayed open because the uuid v4 repro from the bug body still failed at link — same shape, different missing symbol. With the wrapper fix in place, `perry main.ts -o out` (where `main.ts` is `import { v4 } from "uuid"; console.log(v4());` and `package.json` lists `uuid` in `perry.compilePackages`) advances past the codegen-time module sweep, prints `⚠ 1 module(s) failed to compile — linking with empty stubs` for `node_modules/uuid/dist/sha1.js` (codegen still bails on `Uint8Array.of(...)` with 20 args — a `Call callee shape not supported (PropertyGet) with 20 args` from `crates/perry-codegen/src/lower_call.rs::3226` when `args.len() > 16` and the receiver is a `GlobalGet(_)` — that's a separate codegen limit, filed below as the remaining v4 runtime blocker), then the link still dies with `Undefined symbols for architecture arm64: "_node_modules_uuid_dist_sha1_js__init", referenced from: _main in main_ts.o, _node_modules_uuid_dist_v5_js__init in node_modules_uuid_dist_v5_js.o`. The driver's `linking with empty stubs` reassurance was a lie — the stub object did get emitted, but under the wrong symbol name. **Root cause.** `crates/perry/src/commands/compile.rs:5448-5454` (added in f679c502, "v0.5.3: driver hard-fails when entry module fails to compile") generated each failed-module stub as `_perry_init_` (`format!("_perry_init_{}", sanitized)`), then handed it to `perry_codegen::stubs::generate_stub_object` which dutifully emitted `define double @_perry_init_() { ret double 0x7FFC000000000001 }`. But the actual cross-module module-init naming convention — emitted by codegen for every module since the `__init` rename and used by every caller — is `__init` (`crates/perry-codegen/src/codegen.rs:4668`: `let init_name = format!("{}__init", module_prefix);`, called by the entry module's main and by every consumer's `__init_body` via `blk.call_void(&format!("{}__init", dep_prefix), &[])` at codegen.rs:4702). The two naming conventions never converged after the rename. Empty-stub generation has been writing to a name no caller references for the lifetime of the rename — the bug only surfaced now because `compilePackages` is the first feature that routinely puts non-entry npm modules through a codegen-failure path with downstream module imports (the entry module itself fails the hard-fail check higher up; standalone single-file builds never reach this stub path). For uuid the v5.js module imports sha1.js's default export and references `node_modules_uuid_dist_sha1_js__init` in its own `__init_body` wrapper — sha1.js's `_perry_init_node_modules_uuid_dist_sha1_js` stub was sitting right there in `_perry_failed_stubs.o`, defining a symbol nothing in the link graph asks for. **Fix.** One-line format-string change at `crates/perry/src/commands/compile.rs:5452` — `format!("_perry_init_{}", sanitized)` → `format!("{}__init", sanitized)`. The stub's IR shape (`define double @() { ret double 0x7FFC000000000001 }` — the existing nullary-fn arm in `crates/perry-codegen/src/stubs.rs:67-73`) is left as-is: callers declare and call `void __init()`, the stub defines `double __init()`, but at the Mach-O / ELF / COFF link layer symbols are resolved by name only — return-type mismatch doesn't break the link, and on AArch64 the caller's no-arg call sequence + the stub's immediate `ret` (no save/restore, no inspection of xmm0/d0) is benign at runtime. Comment block expanded to document the rename history + why the bug went undetected this long. Two user-facing `eprintln!` strings in the entry-module-failure abort message (compile.rs:5039 and :5048) updated to reference the new naming so the diagnostic stays accurate (`_perry_init_*` → `__init`). **Validation.** `mkdir /tmp/perry-uuid && cd /tmp/perry-uuid && cat > package.json <<'JSON' {"name":"d","private":true,"type":"module","dependencies":{"uuid":"*"},"perry":{"compilePackages":["uuid"]}}\nJSON\nnpm install && cat > main.ts <<'TS' import { v4 } from "uuid"; console.log(v4());\nTS && perry main.ts -o out` — pre-fix: link fails with `Undefined symbols: _node_modules_uuid_dist_sha1_js__init`; post-fix: link succeeds (`Wrote executable: out, Binary size: 2.2MB`), `nm _perry_failed_stubs.o` shows `T _node_modules_uuid_dist_sha1_js__init` (the actually-referenced symbol). Substituting `console.log(v4({ random: new Uint8Array(16).fill(7) }))` produces a well-formed UUID `07070707-0707-4707-8707-070707070707` byte-shape — proves the link → runtime → uuid v4 path is intact for explicit-bytes invocations. Same fix unblocks any compile-packages program with one or more non-entry npm modules that hit a codegen limit (currently a long tail — `Uint8Array.of` >16 args is one; `applyMixins` and others tracked under #793/#805 are next). **Remaining for the original v4 invocation specifically.** `v4()` (no args) hits two downstream gaps that fall outside the link scope of #837: (1) perry returns `0`/falsy for `crypto.randomUUID` instead of either a working function or a missing property (so v4.js's early-return-via-randomUUID branch is skipped — `console.log(typeof crypto.randomUUID)` → `number`, `crypto.randomUUID` → `0`), and (2) `import rng from "uuid/dist/rng.js"; rng()` returns `undefined` when invoked through a compilePackages module boundary even though an inlined copy of the same `function rng() { return crypto.getRandomValues(rnds8); }` works correctly — looks like a cross-module function-call lowering bug on the compilePackages path. Both filed as separate follow-up issues; neither is on the link-side critical path that #837 was tracking. **Files touched.** `crates/perry/src/commands/compile.rs` (one format-string fix at :5452, two diagnostic message updates at :5039 and :5048, expanded comment block at :5440-5466), `Cargo.toml` (workspace version bump), `CLAUDE.md` (version line). Closes #837. Refs #793, #805 (the broader npm-sweep compatibility roadmap that surfaced this; sha1.js's 20-arg call and the rng/crypto runtime gaps remain on that tracker). diff --git a/CLAUDE.md b/CLAUDE.md index afcc6cc1f..fb5d9281d 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.926 +**Current Version:** 0.5.927 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index b85a657c0..7da903fef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.926" +version = "0.5.927" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.926" +version = "0.5.927" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.926" +version = "0.5.927" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.926" +version = "0.5.927" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.926" +version = "0.5.927" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.926" +version = "0.5.927" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.926" +version = "0.5.927" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.926" +version = "0.5.927" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.926" +version = "0.5.927" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.926" +version = "0.5.927" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.926" +version = "0.5.927" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.926" +version = "0.5.927" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.926" +version = "0.5.927" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.926" +version = "0.5.927" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.926" +version = "0.5.927" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.926" +version = "0.5.927" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.926" +version = "0.5.927" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.926" +version = "0.5.927" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.926" +version = "0.5.927" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.926" +version = "0.5.927" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.926" +version = "0.5.927" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.926" +version = "0.5.927" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.926" +version = "0.5.927" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.926" +version = "0.5.927" [[package]] name = "perry-ui-android" -version = "0.5.926" +version = "0.5.927" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.926" +version = "0.5.927" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.926" +version = "0.5.927" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.926" +version = "0.5.927" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.926" +version = "0.5.927" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.926" +version = "0.5.927" [[package]] name = "perry-ui-tvos" -version = "0.5.926" +version = "0.5.927" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.926" +version = "0.5.927" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.926" +version = "0.5.927" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.926" +version = "0.5.927" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.926" +version = "0.5.927" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.926" +version = "0.5.927" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 2dedf250b..d224b6379 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.926" +version = "0.5.927" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry/src/commands/compile/cjs_wrap.rs b/crates/perry/src/commands/compile/cjs_wrap.rs index 98a25ceca..04ba541e4 100644 --- a/crates/perry/src/commands/compile/cjs_wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap.rs @@ -46,12 +46,82 @@ use std::path::Path; /// `require(` — i.e., an ESM file that *also* contains those tokens. Real /// hybrid cases are rare and would need a `"type": "module"` package.json /// override, which is the next refinement if this trips a real package. +/// +/// Issue #851: Rollup-bundled output (the `vitest/dist/chunks/*.js` shape) +/// has top-level ESM `import`/`export` statements AND inlined CJS bodies +/// (`module.exports = factory()`) deep inside nested IIFE helpers. Such +/// files are unambiguously ESM — the inner CJS tokens are just identifiers +/// inside function bodies. If we wrap them as CJS, the wrap moves the +/// top-level `import`/`export` *inside* the IIFE body and SWC errors with +/// `ImportExportInScript`. The guard below short-circuits the wrap when a +/// top-level `import`/`export` statement is detected. pub(super) fn is_commonjs(source: &str) -> bool { + // ESM-at-the-top wins: a top-level `import`/`export` makes this an + // ES module regardless of CJS patterns appearing deeper in the file. + if has_top_level_esm(source) { + return false; + } source.contains("module.exports") || source.contains("exports.") || (source.contains("require(") && !source.contains("import ")) } +/// Returns true if `source` contains an unindented `import ` / `import{` / +/// `import"` / `import'` / `export ` / `export{` / `export*` / `export"` / +/// `export'` / `export=` (TS) statement on any line — a strong signal that +/// this file is an ES module regardless of any `module.exports`-style +/// content deeper in nested function bodies. Lines starting with leading +/// whitespace are treated as nested and ignored, because `import` / +/// `export` statements MUST be at module-top-level in ECMAScript. Comment +/// and string-literal contexts are not stripped — a `// import ` line is +/// already excluded by the leading-whitespace filter when indented; an +/// inline `/* import x */` followed by a real statement still triggers a +/// match on the real statement line. Worst case is a false positive on a +/// pathological file where the only top-level `import`/`export` lives +/// inside a multi-line string literal at column 0; we accept that risk +/// since the alternative is `ImportExportInScript` on real Rollup output. +fn has_top_level_esm(source: &str) -> bool { + for raw_line in source.lines() { + // Skip indented lines — `import`/`export` statements are only + // valid at module top-level, so any indented occurrence is + // either inside a function body, a comment, or a string. + if raw_line.starts_with(' ') || raw_line.starts_with('\t') { + continue; + } + let line = raw_line.trim_start(); + if starts_with_esm_keyword(line, "import") || starts_with_esm_keyword(line, "export") { + return true; + } + } + false +} + +/// Returns true if `line` starts with `keyword` followed by a character +/// that can legally begin an `import`/`export` statement's continuation: +/// space, `{`, `*` (export only), `"`, `'`, or `(` (dynamic import). We +/// reject identifier-continuation characters (a-z, A-Z, 0-9, `_`, `$`) so +/// e.g. `exports.foo = …` does NOT match `export`, and `importMap = …` +/// does NOT match `import`. +fn starts_with_esm_keyword(line: &str, keyword: &str) -> bool { + if let Some(rest) = line.strip_prefix(keyword) { + match rest.chars().next() { + None => false, + Some(c) => { + // Reject identifier-continuation: this is a different word + // (`exports`, `importMap`, etc.), not the keyword. + if c.is_alphanumeric() || c == '_' || c == '$' { + return false; + } + // Whitespace, `{`, `*`, `"`, `'`, `(` all legally follow + // `import` or `export` — accept. + matches!(c, ' ' | '\t' | '{' | '*' | '"' | '\'' | '(') + } + } + } else { + false + } +} + /// JS reserved words that cannot be used as binding identifiers (e.g. /// in `const X = ...`). Used by `extract_exports_from_source` to skip /// CJS-style `module.exports.X = ...` patterns where `X` is a keyword — @@ -1401,6 +1471,84 @@ mod tests { assert!(!is_commonjs("import x from 'foo'; export const y = 1;")); } + #[test] + fn issue_851_rollup_hybrid_esm_with_inner_cjs_is_esm() { + // Rollup-bundled output (vitest's `dist/chunks/*.js` shape): + // top-level ESM `import` + inlined CJS body in a nested IIFE. + // Such files MUST be treated as ESM — wrapping them moves the + // `import` inside the IIFE and SWC errors `ImportExportInScript`. + let src = r#"import { foo } from 'bar'; +function helper() { + (function (module, exports$1) { + module.exports = factory(); + })(this, function() { return {}; }); +} +export const baz = helper(); +"#; + assert!( + !is_commonjs(src), + "rollup hybrid ESM/CJS file must be classified as ESM" + ); + } + + #[test] + fn issue_851_top_level_export_wins_over_cjs_tokens() { + // Even with `module.exports` and `exports.` patterns inside + // function bodies, a top-level `export` makes this ESM. + let src = r#"export { x } from './x'; +function inner() { + module.exports = 1; + exports.foo = 2; +} +"#; + assert!(!is_commonjs(src)); + } + + #[test] + fn issue_851_export_star_is_esm() { + // `export *` is a valid top-level ESM form. + let src = "export * from './re';\nfunction inner() { module.exports = 1; }\n"; + assert!(!is_commonjs(src)); + } + + #[test] + fn issue_851_does_not_match_exports_dot_as_export_keyword() { + // Make sure `exports.foo = …` at the top level is NOT mistakenly + // matched as `export` (the keyword check must reject identifier + // continuation `s`). + let src = "exports.foo = 1;\n"; + assert!(is_commonjs(src)); + } + + #[test] + fn issue_851_does_not_match_importmap_identifier() { + // `importMap = …` is a plain identifier write, not an import + // statement; it must not flip ESM detection. + let src = "var importMap = {};\nmodule.exports = importMap;\n"; + assert!(is_commonjs(src)); + } + + #[test] + fn issue_851_indented_import_is_ignored() { + // An `import` keyword inside a function body (indented) must + // not classify the file as ESM. + let src = r#"function inner() { + import('./x'); // dynamic import inside a function — not top-level +} +module.exports = inner; +"#; + assert!(is_commonjs(src)); + } + + #[test] + fn issue_851_top_level_dynamic_import_counts_as_esm() { + // A bare `import('./x')` at column 0 is a top-level + // (dynamic-import) expression — only valid in module scope. + // Treating it as ESM is the safe call. + let src = "import('./x');\nmodule.exports = 1;\n"; + assert!(!is_commonjs(src)); + } + #[test] fn extracts_named_exports() { let src = "exports.foo = 1; exports.bar = function() {}; exports.__esModule = true;"; diff --git a/test-files/test_issue_851_rollup_cjs_in_esm.ts b/test-files/test_issue_851_rollup_cjs_in_esm.ts new file mode 100644 index 000000000..fea43bc7f --- /dev/null +++ b/test-files/test_issue_851_rollup_cjs_in_esm.ts @@ -0,0 +1,50 @@ +// Issue #851 — vitest's `dist/chunks/test.*.js` is a Rollup-bundled hybrid: +// top-level ESM `import`/`export` statements coexist with inlined CJS bodies +// (`module.exports = factory()`) inside nested helper IIFEs. Pre-fix, Perry's +// `cjs_wrap::is_commonjs` heuristic matched the `module.exports` token and +// wrapped the entire source in a CJS IIFE — which moved the top-level +// `import` *inside* the wrap and made SWC reject the file with +// `ImportExportInScript`. The fix in `cjs_wrap.rs` now treats any file with +// a top-level `import`/`export` statement as ESM and skips the wrap, so the +// CJS-looking tokens inside nested function bodies just lower as ordinary +// identifiers. +// +// This fixture is a minimal ESM-with-CJS-identifiers-inside-a-function shape +// that compiles natively. We don't need the full vitest scenario for the +// parser-mode test — the heuristic is exercised by unit tests in +// `crates/perry/src/commands/compile/cjs_wrap.rs`. This end-to-end test +// guards against a regression where the SWC parser, the HIR lower, or +// later passes would mis-handle bare `module`/`exports`/`require` reads +// inside a function body when the surrounding file is unambiguously ESM. +// +// Note: this fixture verifies the parser path only. A full `vitest` +// compile-package compile may still fail at later pipeline stages (link, +// codegen of obscure CJS shapes); those are tracked separately. +// +// Expected stdout: +// ok-851 + +export function fakeRollupHelper(): string { + // `module` / `exports` / `require` are not in scope here — Perry should + // simply treat them as unknown identifiers and not attempt to wrap the + // file as CJS. The pattern below mimics what Rollup emits when it inlines + // a CJS dep into an ESM bundle (the names are referenced but never + // executed because `hasRequiredDep` short-circuits the function). + let hasRequiredDep = true; + let dep: string = "fallback"; + if (!hasRequiredDep) { + // unreachable — present only to keep the CJS-shaped tokens around so + // the parser sees them as references, not just spelling in a string. + const module: { exports: string } = { exports: "x" }; + const exports = module.exports; + dep = exports; + } + return dep; +} + +const v = fakeRollupHelper(); +if (v === "fallback") { + console.log("ok-851"); +} else { + console.log("BAD: v is " + v); +}