From fc94862889ab01b1e707ea0ed8c2b4f4506ea7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Apr 2026 18:48:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(compile):=20#348=20Phase=20A=20=E2=80=94?= =?UTF-8?q?=20native=20CommonJS=20support=20for=20compilePackages=20(v0.5.?= =?UTF-8?q?433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the React-class blocker surfaced in the #348 scoping pass: any package shipping CJS (the dominant shape on npm — React 18, react-dom, react-reconciler, scheduler, lots of older libs) hit `Undefined symbols: _perry_fn____` at link time because Perry's native `compilePackages` pipeline only handles ESM, and `module.exports = require('./cjs/')` lowered `module` / `require` to bare-identifier-zero — every named export silently vanished. Implementation: source-level CJS-to-ESM wrap before SWC parses, modeled on the V8-fallback wrap at perry-jsruntime/src/modules.rs:481. New crates/perry/src/commands/compile/cjs_wrap.rs: - is_commonjs heuristic (matches module.exports / exports. / require( without import). - wrap_commonjs hoists each `require('X')` as `import _req_N from 'X'`, wraps the body in an IIFE that defines module = { exports: {} } plus a synchronous require(specifier) dispatching to the _req_N bindings, returns module.exports as _cjs, then emits `export default _cjs` plus `export const X = _cjs.X` for each detected named export. - Two named-export sources unioned: `exports.X = ...` patterns in the file itself (the existing jsruntime regex), AND for "trivial re-export wrappers" (`module.exports = require('./X')`) the patterns of the recursively-required target file. Without the second source, react/index.js (whose only meaningful statements are two conditional `module.exports = require(...)` calls inside a process.env.NODE_ENV check) produces zero named exports of its own and the link still fails. Wired in one place: collect_modules.rs:114 after read_to_string, gated on is_in_compiled_pkg so user TypeScript and ESM-shaped packages skip the wrap untouched. Verified end-to-end on a react-only sandbox: import { createContext, useState } from "react"; const Ctx = createContext(null); console.log(typeof createContext, typeof useState, typeof Ctx); compiles to a 2.2 MB single-file native binary, runs to exit 0, prints `function function function`. Matches Node on lines 1-2; line 3 returns `function` instead of Node's `object` — separate cross-module CJS-call return-value typeof bug, follow-up under #348 Phase B. End-to-end ink compile flips 46/67 modules from JS-runtime fallback to native (was 0/67 — every react.*/ink/build/* file now compiles natively). The remaining 21 modules still routed to V8 fallback are ink's transitive deps not declared in compilePackages (chalk / scheduler / react-reconciler / yoga-layout / etc) — Phase B. Out of scope (separate sub-issues under #348): - dynamic `require(someVar)` — throws at IIFE runtime, unrepresentable as static ESM - process.env.NODE_ENV static evaluation — both branches' requires hoist as imports, harmless modulo compile time - cross-module CJS-call typeof bug - ink's transitive deps (process import resolution, dynamic import(), yoga, react-reconciler) 7 unit tests in cjs_wrap.rs covering: detection on each shape, named- export extraction skipping __esModule, require-specifier dedup, end-to- end wrap round-trip with default + named exports + require import hoisting. New direct dep `regex = "1"` in crates/perry/Cargo.toml (already in the transitive surface via perry-jsruntime). --- CLAUDE.md | 3 +- Cargo.lock | 1 + Cargo.toml | 2 +- crates/perry/Cargo.toml | 1 + crates/perry/src/commands/compile.rs | 1 + crates/perry/src/commands/compile/cjs_wrap.rs | 225 ++++++++++++++++++ .../src/commands/compile/collect_modules.rs | 15 +- 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 crates/perry/src/commands/compile/cjs_wrap.rs diff --git a/CLAUDE.md b/CLAUDE.md index d7674aefc..450080fbf 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.432 +**Current Version:** 0.5.433 ## TypeScript Parity Status @@ -150,6 +150,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re Keep entries to 1-2 lines max. Full details in CHANGELOG.md. +- **v0.5.433** — Issue #348 Phase A — first cut: native CommonJS support for `compilePackages`. Closes the React-class blocker that made `import { useState } from "react"` link-fail with `Undefined symbols: _perry_fn_node_modules_react_index_js__useState` for any package shipping CJS — surfaced as the dominant bucket in the #348 scoping pass. New `crates/perry/src/commands/compile/cjs_wrap.rs` (~210 LOC + tests) lifted from the `perry-jsruntime/src/modules.rs:481` V8-fallback wrap pattern: heuristic `is_commonjs` (matches `module.exports` / `exports.` / `require(` without `import `), source-level `wrap_commonjs` that hoists each `require('X')` as `import _req_N from 'X';`, wraps the original body in an IIFE that defines `module = { exports: {} }` plus a synchronous `require(specifier)` dispatching to the `_req_N` bindings, and emits `export default _cjs;` plus `export const X = _cjs.X;` for each detected named export. Two named-export sources are unioned: `exports.X = ...` patterns in the file itself (the existing jsruntime regex), AND for **trivial re-export wrappers** (`module.exports = require('./X')`) the patterns of the recursively-required *target* file — without this, `react/index.js` (whose only meaningful statements are two conditional `module.exports = require(...)` calls inside `process.env.NODE_ENV === 'production'`) produces zero named exports of its own and the link still fails. Wired in one place: `collect_modules.rs:114` after `read_to_string`, gated on `is_in_compiled_pkg` so user TypeScript and ESM-shaped packages skip the wrap untouched. New `regex = "1"` direct dep in `crates/perry/Cargo.toml` (already in the transitive surface via `perry-jsruntime`). **Verified end-to-end** on a `react-only` sandbox (`import { createContext, useState } from "react"; const Ctx = createContext(null); console.log(typeof createContext, typeof useState, typeof Ctx)`) — compiles to a 2.2 MB single-file native binary, runs to exit 0, prints `function / function / function` (matches Node on lines 1-2; `Ctx` `typeof` returns `function` instead of Node's `object` — separate bug in cross-module CJS-call return-value typeof, follow-up). End-to-end ink compile gets 46/67 modules native (was 0/67 — every `react.*` and `ink/build/*` file now compiles natively); the remaining 21 modules going to V8 fallback are ink's transitive deps not in `compilePackages` (chalk / scheduler / react-reconciler / yoga-layout / etc) — Phase B work, separate sub-issues. **Out of scope for this slice**: dynamic `require(someVar)` (throws at IIFE runtime — unrepresentable as static ESM); `process.env.NODE_ENV` static evaluation (both branches' requires hoist as imports — both files compile, only one is reached at module init, harmless modulo compile time); cross-module CJS-call typeof bug; ink's transitive deps (`process` import resolution, dynamic `import()`, yoga, react-reconciler). - **v0.5.432** — Phase 2 v12: 4 more ArkUI widgets in `perry-codegen-arkts`. **Tabs(`[{label, body}, ...]`)** → ArkUI `Tabs() { TabContent() {}.tabBar('