From c38422659112038b858c3232693c332646974875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 07:35:10 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(resolve):=20#665=20=E2=80=94=20compile?= =?UTF-8?q?Packages=20overrides=20NATIVE=5FMODULES=20routing=20(v0.5.895)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-site override (resolve.rs::resolve_import + collect_modules.rs) so a package listed in `perry.compilePackages` is compiled from its node_modules source instead of routed to the built-in Rust FFI binding. Unblocks users of packages whose native binding is incomplete (e.g. `rate-limiter-flexible`, where `perry-ext-ratelimit` only wires `consume` through codegen and the codegen-declared `js_ratelimit_create(I64)` doesn't match the real two-arg `js_ratelimit_new(points, duration_secs)` impl). Default behavior unchanged: stock projects without `compilePackages` go through the exact same path. Opt-in and package-scoped. Refs #665. Renumbered from v0.5.892 → v0.5.895 (parallel PRs #734/#736/#737 landed on main). See CHANGELOG.md for full root-cause notes. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 134 +++++++++--------- Cargo.toml | 2 +- .../src/commands/compile/collect_modules.rs | 13 ++ crates/perry/src/commands/compile/resolve.rs | 11 +- 6 files changed, 93 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de65895ab3..f48997e168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.895 — feat(resolve): #665 — `perry.compilePackages` overrides the built-in NATIVE_MODULES routing on a per-package basis. **Symptom.** A user who adds `"perry": { "compilePackages": ["rate-limiter-flexible"] }` to `package.json` — explicitly opting into "compile this npm package from its `node_modules` source rather than route to your built-in FFI binding" — was silently ignored for any package listed in `perry-api-manifest::NATIVE_MODULES`. With `PERRY_DEBUG_CJS_WRAP=1` the cjs_wrap pass logged **zero** invocations for `rate-limiter-flexible` despite the opt-in. The import resolver at `crates/perry/src/commands/compile/resolve.rs:806` short-circuited unconditionally on `is_native_module(...)`, returning the import to the stdlib FFI dispatch path. For `rate-limiter-flexible` specifically that path is broken: codegen at `crates/perry-codegen/src/runtime_decls.rs:2213-2214` declares a single-arg `js_ratelimit_create` returning a 0-stub, but the real implementation at `crates/perry-ext-ratelimit/src/lib.rs:39` is two-arg `js_ratelimit_new(points, duration_secs)` — name and arity both mismatch — and only `consume` is wired through codegen beyond that. So users hit `typeof limiter.consume === "undefined"` and a `TypeError: value is not a function` at the first request, with no obvious way out. **Root cause.** Two parallel sites cache the "is this import native" decision and both ignored `compilePackages`. (1) `resolve::resolve_import` early-returns `None` (which the rest of the pipeline treats as "stdlib FFI path") whenever `perry_hir::is_native_module(import_source)` matches. (2) `collect_modules::process_imports` reads `import.is_native` from the HIR (set during lowering based on the same NATIVE_MODULES table), and any import flagged as native skips the file-resolution and cjs_wrap path entirely. Neither check consulted `ctx.compile_packages` — so the opt-in was structurally unable to win. **Fix.** Targeted opt-in override at both sites, scoped to packages the user has explicitly named in `perry.compilePackages`. (a) `resolve.rs::resolve_import`: parse the package name out of the import source via the existing `parse_package_specifier`, then `is_native_module && !compile_packages.contains(pkg)` is the new short-circuit condition. Bare `rate-limiter-flexible` and any subpath under it (`rate-limiter-flexible/lib/RateLimiterMemory`) both fall through to file resolution when the opt-in is present. (b) `collect_modules.rs`: after the existing `package_aliases` rewrite, when `import.is_native` is true and the package is in `ctx.compile_packages`, flip `import.is_native = false` so the subsequent module-kind dispatch routes through file resolution + cjs_wrap + native codegen instead of stdlib FFI. The two changes are intentionally redundant — the resolver fix governs the path-resolution side of `is_native_module`, the collect_modules fix governs the per-import HIR-cached `is_native` flag — and they need to agree or one side will silently win. Default behavior unchanged: a stock project without `compilePackages` (or with `compilePackages` not listing a given native-bound package) goes through the exact same path as before. The native binding remains the recommended default for packages where it's complete; the override is for users who know their built-in binding is incomplete and have a working `node_modules` copy. **Validation.** All 152 `perry` crate tests pass (`cargo test --release -p perry`); 37 cjs_wrap unit tests pass unchanged; full workspace `cargo test --release --workspace --exclude perry-ui-{ios,tvos,watchos,visionos,android,windows,gtk4}` returns exit 0. Build clean. **Scope.** This commit only lands the routing opt-in. Three follow-up gaps tracked in subsequent commits: (1) codegen side-map consistency for default-imported classes (v0.5.896); (2) `setTimeout(fn, delay, ...args)` trailing args (v0.5.897); (3) HIR's `is_native_module` honoring `compilePackages` so `instance.method` doesn't lower as a zero-arg FFI getter (v0.5.898). **Out of scope.** No new tests/release/packages fixture for rate-limiter-flexible — added once the full chain runs end-to-end (v0.5.898 ships that). **Version-bump note.** Renumbered from the original v0.5.892 because #734 (v0.5.892 linker), #736 (v0.5.893 HarmonyOS Chart + TreeView), and #737 (v0.5.894 dependabot batch) all landed on main while this PR was in review. + ## v0.5.894 — deps: batch dependabot roll-up (#724, #725, #726, #728, #730, #731, #723). Cargo crates: `scraper` 0.19.1 → 0.27.0 (perry-ext-cheerio + perry-stdlib `bundled-cheerio` feature), `gstreamer` 0.23.7 → 0.25.2 (perry-ui-gtk4 — `perry/media` playbin streaming audio backend, #351), `bcrypt` 0.15 → 0.17 in perry-stdlib `bundled-bcrypt` feature (aligns with perry-ext-bcrypt which was already pinned at 0.17, so the workspace now resolves a single bcrypt version end-to-end). GitHub Actions: `actions/download-artifact` v4 → v8, `actions/setup-node` v4 → v6, `actions/cache` v4 → v5 across `benchmark.yml`, `release-packages.yml`, `simctl-tests.yml`, `test.yml` (the historical reference to `actions/cache@v4` in test.yml line 110's comment is intentionally left as-is — it documents the v0.5.387 removal). NPM test fixture: `drizzle-orm` ^0.36.0 → ^0.45.2 in `tests/release/packages/drizzle-sqlite/` (the prior dependabot PR #723 failed security-audit on a transient advisory-db hit; main's subsequent run against the unchanged Cargo.lock passed, confirming the failure was stale). **Not bumped (real breakage):** `toml` 0.8.23 → 1.1.2+spec-1.1.0 (PR #727) — major version with breaking parser changes that fail `well_known_bindings.toml` (7 cargo tests + 31 compile-smoke regressions); needs a migration commit, not a drop-in. `deno_core` 0.311.0 → 0.400.0 (PR #729) — 89-patch jump with API-breaking changes that fail parity + compile-smoke. **Validation.** Six green PRs reviewed (each carrying its own full CI run: lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, harmonyos-smoke, doc-tests). Files edited match exactly what each dependabot branch proposed; the only delta is that all seven roll into a single commit + version bump per the maintainer's batched-deps preference. The two failing PRs (#727, #729) were closed via comment on the PRs explaining they need follow-up migration work — see the linked issues for the actual breakage details. **Version-bump note.** Renumbered to v0.5.894 because #734 landed as v0.5.892 and #736 landed as v0.5.893 on main while this batch was in review. ## v0.5.893 — feat(perry-codegen-arkts): #669 Chart + #670 TreeView land HarmonyOS + fix(perry-stdlib): readline test serialization — last two widgets in the perry/ui parity matrix. Both previously fell through to `// unsupported perry/ui widget` because ArkUI has no native chart or tree-outline primitive; they're hand-rolled here using ArkUI's Canvas + List composition layers. **Chart (#669).** `Chart(kind, w, h)` lowers to a per-instance `Canvas(this.__chart__ctx)` backed by `private __chart__settings: RenderingContextSettings` + `private __chart__ctx: CanvasRenderingContext2D` fields on the @Component. The mutator stream (`chartAddDataPoint`, `chartClearData`, `chartSetTitle`, `chartReload`) is folded at codegen time via four new `Mutation` variants — `ChartAddDataPoint(label, value)`, `ChartClearData`, `ChartSetTitle(title)`, `ChartReload` — picked up by `collect_mutations_in_expr` alongside the existing #408 pre-walk. `fold_chart_mutations` reduces the entries to a `(points, title)` pair (clear wipes earlier points; last title wins), which `emit_chart` bakes inline into the `.onReady()` draw closure as a literal `const data: Array<{label, value}>` plus `const title: string` constants. Three draw-body branches: kind=0 (line) emits `ctx.beginPath()` + `moveTo`/`lineTo` over the data + `ctx.arc()` dot markers; kind=1 (bar) emits `ctx.fillRect()` per data point with a baseline `ctx.stroke()` axis; kind=2 (pie) emits `ctx.arc(cx, cy, radius, startAngle, startAngle + sweep)` + `ctx.fill()` per slice plus a left-side legend column. Title bar (when non-empty) emits at the top of every kind. Math + color palette (`#4287F5` primary, `#1B5FB8` line-dot accent, 8-color pie palette) copied 1:1 from `crates/perry-ui-android/template/.../PerryBridge.kt::PerryChartView` so HarmonyOS renders match the Android impl byte-for-byte at the pixel level. Width/height args from `Chart(kind, w, h)` flow through to `.width(N).height(N)` modifiers (zero = use intrinsic); the draw closure reads `cw` / `ch` from `ctx.width` / `ctx.height` when sizes are 0 so the chart scales to its container. **TreeView (#670).** `TreeView(rootNode, onSelect)` lowers to an ArkUI `List({space: 0})` whose `ForEach(this.__tree__flatten(), row => ListItem() { … })` renders one indented row per visible node. The node graph is reconstructed at codegen time by chasing the `rootNode` LocalGet back through `bindings` to its `TreeNode(id, label)` constructor + accumulated `treeNodeAddChild` mutations (new `Mutation::TreeAddChild(child_expr)`), recursed depth-first by `build_tree_node` with a 16-hop binding-cycle guard. The resulting `TreeViewNode` tree is rendered into a `private __tree__nodes: Array<{id, label, children}>` constant via `render_tree_data_literal`. Two `@State` fields per tree: `__tree__expanded: Set` (empty on init — only the root is visible at startup, exactly matching the Android impl's collapsed-default) and `__tree__selectedId: string` (initially empty). A `__tree__flatten()` method walks the static node graph and emits the visible-only rows shaped `{id, label, depth, hasChildren, expanded}` for the ForEach. Each ListItem renders a chevron `Text` (`▾`/`▸`/blank for leaves) + a label `Text` with `.padding({left: row.depth * 16, top: 4, bottom: 4})`. `.onClick()` flips the row's id in the expanded set (via `new Set(this.__tree__expanded); next.add()/delete(); this.__tree__expanded = next` so ArkUI's structural equality detects the change) and invokes the user `onSelect` closure with `row.id` via `perryEntry.invokeCallback1(, row.id)`. The flat-list-with-indent pattern was chosen over a native outline component because ArkUI's `List` doesn't ship one — same architectural call the Android impl made. **Threading.** Both emitters need to register class-level state (the Canvas's `CanvasRenderingContext2D` field for Chart, the @State fields + flatten method for TreeView). Rather than add four new `&mut Vec<…>` params to every recursive `emit_widget` / `emit_stack` / etc. signature, the new state is bundled into a single `HarvestExtras { chart_instances, tree_view_instances }` struct passed as one `&mut HarvestExtras` param threaded through the existing call chain alongside `text_slots` and `lazy_sources`. The bundle pattern means adding any future widget that needs class-level state is a one-line addition to the struct rather than 11 signature changes. **wrap_index_page** gains a `chart_and_tree_glue(extras)` call that emits the chart `private` ctx-field declarations, the tree `private` nodes-data + `@State` expanded/selected pairs, and the `__tree__flatten()` method bodies as new template slots `{chart_decls}` / `{tree_view_decls}` / `{tree_view_methods}` in the generated `@Component struct Index`. **is_widget_factory** also gains `Chart`, `TreeView`, and `TreeNode` so the modifier-mutator pre-walk doesn't false-positive these new factories as missed mutators. **Tests.** Six new unit tests in `crates/perry-codegen-arkts/src/lib.rs::tests`: (1) `chart_bar_with_data_points_emits_canvas_and_draw_calls` — drives the full data-fold + Canvas widget + private ctx fields + `.width(200).height(150)` + `fillRect` + title `fillText`; (2) `chart_line_kind_emits_stroke_path` — kind=0 emits `ctx.lineTo()` + `ctx.stroke()` + `ctx.arc()` dot markers; (3) `chart_pie_kind_emits_arc_fill_and_legend` — kind=2 emits `ctx.arc(cx, cy, radius, …)` + `closePath` + `fill`; (4) `chart_clear_data_resets_points` — `chartClearData` between two `chartAddDataPoint` calls drops the pre-clear point and keeps only the post-clear point; (5) `treeview_static_graph_emits_list_foreach_and_state` — full graph round-trip including the recursive node literal `{ id: 'root', label: 'Root', children: [{ id: 'c1', label: 'Child 1', children: [] }] }`, both `@State` fields, the flatten helper, and the `perryEntry.invokeCallback1(0, row.id)` onSelect dispatch; (6) `treeview_depth_padding_uses_row_depth_field` — verifies the `.padding({ left: row.depth * 16, … })` shape so the v1 indent constant is locked in by a test. All 109 perry-codegen-arkts unit tests pass (was 103); both phase2_full_app_smoke integration tests still pass. **Bundled fix — perry-stdlib readline test flake.** Pre-existing race in `readline::tests` surfaced as a CI failure on this PR (`has_active_reflects_state` flake): all readline tests share global state (`PENDING_LINES`/`PENDING_DATA` mutexes, `EOF_REACHED`/`RAW_MODE` atomics, thread_local callback cells) but cargo test's default parallel scheduler runs them concurrently. `has_active_reflects_state` pushes "x" into PENDING_LINES then asserts has_active==1; if `injected_line_drains_via_test_helper` runs concurrently its `reset()` clears PENDING_LINES in the window between push + assert and the test fails. Fix: added `static TEST_LOCK: Mutex<()>` in the readline tests module; `reset()` now acquires it + returns the guard, and each state-touching test binds `let _g = reset();` to hold the lock for the test's duration. Pure-function tests (`parse_keypress_*`) don't call `reset()` and stay parallel-safe. Bundled here rather than as a separate PR because the failure first showed up on this PR's CI and blocking merge on a flake unrelated to the feature work would have been bad form. **Out of scope (v1.1 follow-ups).** Runtime-driven Chart data updates (`chartAddDataPoint` called from inside a closure on user tap): the @State surface exists but a NAPI drain bridge `drainChartUpdate` to pump new points into the canvas isn't wired up yet — the static fold matches the existing `comboboxAddItem` limitation. `treeViewExpandAll` / `treeViewCollapseAll` / `treeViewGetSelectedId` mutators called from closures: same shape — the @State for expanded/selected exists, the drain bridge does not. Conditional-mutation folding for chart data (an `if` around `chartAddDataPoint` would render in both branches today since chart mutations bypass `fold_child_mutations`' if-grouping); chart data is rarely conditionally added, and the comment-on-skip path in the unrecognized-mutator branch already surfaces the gap if a user hits it. **Validation.** Both issues' acceptance criteria pass: #669's "Canvas + 2dcontext draw calls" + "per-instance ArkTS state array via the #408 mutator pre-walk" + "clear/setTitle/reload re-trigger the draw closure" + new test — all met. #670's "List + ForEach over flat tree" + "node-graph in ArkTS state" + "expand/collapse via isExpanded toggle" + "getSelectedId reads from state" + new test — all met. `cargo test -p perry-codegen-arkts` 0 failed; `cargo build -p perry-codegen-arkts` clean with only the pre-existing ViewBuilder dead-code warning. Closes #669, closes #670. **Version-bump note.** Renumbered from v0.5.892 to v0.5.893 after #734's v0.5.892 (Windows WinHTTP + `default` undefined-symbol linker fix) landed on main while this PR was in CI. Standard parallel-PR collision-avoidance pattern (see v0.5.870/v0.5.872/v0.5.873/v0.5.875 changelog entries for the same convention). diff --git a/CLAUDE.md b/CLAUDE.md index 01f724796a..2066c5854a 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.894 +**Current Version:** 0.5.895 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index abc8519556..5c9d8779a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4636,7 +4636,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "base64", @@ -4691,14 +4691,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.894" +version = "0.5.895" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "log", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-hir", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-hir", @@ -4728,7 +4728,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-dispatch", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-hir", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "base64", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-hir", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.894" +version = "0.5.895" dependencies = [ "serde", "serde_json", @@ -4776,7 +4776,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.894" +version = "0.5.895" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4787,7 +4787,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "clap", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.894" +version = "0.5.895" dependencies = [ "argon2", "perry-ffi", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "reqwest", @@ -4819,7 +4819,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.894" +version = "0.5.895" dependencies = [ "bcrypt", "perry-ffi", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "rusqlite", @@ -4835,7 +4835,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "scraper", @@ -4843,14 +4843,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.894" +version = "0.5.895" dependencies = [ "chrono", "cron", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.894" +version = "0.5.895" dependencies = [ "chrono", "perry-ffi", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "rust_decimal", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "serde_json", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4891,21 +4891,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.894" +version = "0.5.895" dependencies = [ "bytes", "http-body-util", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lazy_static", "perry-ffi", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -4942,7 +4942,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.894" +version = "0.5.895" dependencies = [ "bytes", "http-body-util", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lazy_static", "perry-ffi", @@ -4971,7 +4971,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.894" +version = "0.5.895" dependencies = [ "base64", "jsonwebtoken", @@ -4982,7 +4982,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lru", "perry-ffi", @@ -4990,7 +4990,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.894" +version = "0.5.895" dependencies = [ "chrono", "perry-ffi", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.894" +version = "0.5.895" dependencies = [ "bson", "futures-util", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.894" +version = "0.5.895" dependencies = [ "chrono", "perry-ffi", @@ -5020,7 +5020,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.894" +version = "0.5.895" dependencies = [ "nanoid", "perry-ffi", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "rustls", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lettre", "perry-ffi", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "sqlx", @@ -5059,7 +5059,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.894" +version = "0.5.895" dependencies = [ "governor", "perry-ffi", @@ -5067,7 +5067,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.894" +version = "0.5.895" dependencies = [ "base64", "image", @@ -5076,14 +5076,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.894" +version = "0.5.895" dependencies = [ "lazy_static", "perry-ffi", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "uuid", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.894" +version = "0.5.895" dependencies = [ "perry-ffi", "regex", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.894" +version = "0.5.895" dependencies = [ "futures-util", "lazy_static", @@ -5120,7 +5120,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.894" +version = "0.5.895" dependencies = [ "flate2", "perry-ffi", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.894" +version = "0.5.895" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-api-manifest", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "deno_core", @@ -5170,7 +5170,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-diagnostics", @@ -5182,7 +5182,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "base64", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.894" +version = "0.5.895" dependencies = [ "aes", "aes-gcm", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "perry-hir", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.894" +version = "0.5.895" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5292,11 +5292,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.894" +version = "0.5.895" [[package]] name = "perry-ui-android" -version = "0.5.894" +version = "0.5.895" dependencies = [ "itoa", "jni", @@ -5311,7 +5311,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.894" +version = "0.5.895" dependencies = [ "rand 0.8.6", "serde", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.894" +version = "0.5.895" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5340,7 +5340,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.894" +version = "0.5.895" dependencies = [ "block2", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.894" +version = "0.5.895" dependencies = [ "block2", "libc", @@ -5373,11 +5373,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.894" +version = "0.5.895" [[package]] name = "perry-ui-tvos" -version = "0.5.894" +version = "0.5.895" dependencies = [ "block2", "libc", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.894" +version = "0.5.895" dependencies = [ "block2", "libc", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.894" +version = "0.5.895" dependencies = [ "block2", "libc", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.894" +version = "0.5.895" dependencies = [ "libc", "perry-runtime", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.894" +version = "0.5.895" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index a8014311fa..ca496cd656 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.894" +version = "0.5.895" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 90e56b17c0..4cb6216bf2 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -236,6 +236,19 @@ pub(super) fn collect_modules( import.is_native = perry_hir::is_native_module(&import.source); } + // Refs #665: an opt-in via `perry.compilePackages` overrides the + // built-in native binding. HIR lowering set `is_native` based on the + // NATIVE_MODULES manifest alone; downgrade it here so the import + // falls through to file resolution (cjs_wrap + native codegen) and + // the user's `node_modules` copy wins. Mirrors the parallel check in + // `resolve::resolve_import`. + if import.is_native { + let (import_pkg_name, _) = super::resolve::parse_package_specifier(&import.source); + if ctx.compile_packages.contains(&import_pkg_name) { + import.is_native = false; + } + } + if import.is_native { import.module_kind = ModuleKind::NativeRust; if import.source == "perry/ui" { diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index c3c5508e5f..292c77e30a 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -802,8 +802,15 @@ pub(super) fn resolve_import( compile_packages: &HashSet, compile_package_dirs: &HashMap, ) -> Option<(PathBuf, ModuleKind)> { - // Check if it's a native Rust stdlib module - if perry_hir::is_native_module(import_source) { + // Check if it's a native Rust stdlib module. Refs #665: when the user has + // explicitly opted the package into `perry.compilePackages`, they want + // their `node_modules` copy compiled from source (cjs_wrap + native + // codegen), not the built-in Rust FFI binding — which for some packages + // (e.g. `rate-limiter-flexible`'s `perry-ext-ratelimit`) is incomplete. + // The opt-in is package-scoped: bare `rate-limiter-flexible` and any + // subpath under it both fall through to file resolution. + let (native_check_pkg, _) = parse_package_specifier(import_source); + if perry_hir::is_native_module(import_source) && !compile_packages.contains(&native_check_pkg) { return None; // Native modules are handled by stdlib, not file imports } From e7b4fd0dd56450fbd297849f6c7359b8dbd42f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 07:37:50 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(codegen):=20#665=20=E2=80=94=20default-?= =?UTF-8?q?import=20collision=20no=20longer=20mints=20phantom=20cross-clas?= =?UTF-8?q?s=20method=20symbols=20(v0.5.896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two different classes are both default-imported in the same file, the `class_ids` / `imported_class_prefix` / `imported_class_source_name` side maps in `compile_module` overwrote each other (last-writer-wins) while `class_table` kept the first stub (first-writer-wins). The mismatch produced method symbols mangled with FIRST stub's method list + LAST writer's class name + LAST writer's source prefix — names the linker can't resolve since Phase F only declares each ic's REAL methods. Flip the three side-map `.insert()` calls to `.entry().or_insert()` so all four maps agree on first-writer-wins. Fixes the `@perry_method_..._RateLimiterRes_js__RateLimiterRes__delete` / `__get` / `__set` / `__block` undefined-value errors that surfaced in rate-limiter-flexible's 5 leaf files once v0.5.895's routing opt-in let them reach codegen. Refs #665. See CHANGELOG.md for full root-cause walkthrough. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 134 ++++++++++++++-------------- Cargo.toml | 2 +- crates/perry-codegen/src/codegen.rs | 30 ++++++- 5 files changed, 98 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f48997e168..32091d81de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.896 — fix(codegen): #665 — multiple default-imported classes in the same file no longer mint phantom cross-class method symbols. **Symptom.** With v0.5.895's routing opt-in active and `perry.compilePackages: ["rate-limiter-flexible"]` set, compiling `import { RateLimiterMemory } from "rate-limiter-flexible"` failed with 5 leaf files emitting LLVM IR that referenced undefined symbols: `RateLimiterMemory.js` → `@perry_method_..._RateLimiterRes_js__RateLimiterRes__delete`; `RateLimiterMemcache.js` / `RateLimiterRedis.js` → `..._RateLimiterRes__get`; `RateLimiterCluster.js` → `..._RateLimiterRes__block`; `RateLimiterStoreAbstract.js` → `..._RateLimiterRes__delete`. None of these `RateLimiterRes__` symbols exist — `RateLimiterRes.js` only declares `_getDecoratedProperties`, `toString`, `toJSON`, and 4 getter/setter pairs; it has no `delete` / `get` / `set` / `block` method. The clang `-c` invocation for each of those 5 files failed with `error: use of undefined value '@perry_method_..._RateLimiterRes__delete'` and the build stopped before linking. **Root cause.** In `crates/perry-codegen/src/codegen.rs`'s `compile_module`, three side maps for imported classes (`class_ids`, `imported_class_prefix`, `imported_class_source_name`) used `.insert()` — last-writer-wins — while the parallel `class_table` used `.entry().or_insert()` — first-writer-wins. The inconsistency surfaces when two different classes are both imported via the default-export bridge in the same compilation unit: `compile.rs:3155` pushes a SECOND `ImportedClass` entry per default-imported class with `local_alias = Some("default")`. In a file that does `const RateLimiterRes = require("./RateLimiterRes")` AND `const MemoryStorage = require("./component/MemoryStorage/MemoryStorage")`, both entries land in `opts.imported_classes` with `local_alias = Some("default")` → both derive `effective_name = "default"`. `class_table["default"]` keeps the FIRST stub (MemoryStorage's, with `[incrby, set, get, delete]`); `class_ids["default"]` and `imported_class_prefix["default"]` and `imported_class_source_name["default"]` all take the LAST writer's values (RateLimiterRes's class id 16, RateLimiterRes_js source prefix, canonical name "RateLimiterRes"). When the method-registry builder iterates `class_table.values()` for `c.name == "default"`, it builds method symbols by combining the kept stub's method list (MemoryStorage's) with the side maps' canonical name + prefix (RateLimiterRes's): `method_names[("default", "set")] = "perry_method_..._RateLimiterRes_js__RateLimiterRes__set"`. Phase F (which iterates `opts.imported_classes` directly) only sees RateLimiterRes's real method list and never declares `RateLimiterRes__set`. The dispatch tower in `lower_call.rs:1899-1911` then emits a call to the undeclared symbol. **Fix.** Three sites in `crates/perry-codegen/src/codegen.rs` flipped from `.insert()` to `.entry().or_insert()`: (1) `class_ids` registration for `effective_name`; (2) `imported_class_prefix` registration; (3) `imported_class_source_name` registration. With these flipped, all four maps agree first-writer-wins, and `method_names[("default", "set")]` resolves to `MemoryStorage__set` (real symbol) rather than `RateLimiterRes__set` (phantom). **Why this hadn't surfaced before.** Requires two specific conditions: two or more different classes default-imported in the same compilation unit AND `this..()` shape firing the dispatch tower. Most user code with two default-imports doesn't also chain method calls through dynamic receivers; most files with that pattern only have one default-import. rate-limiter-flexible's leaf files hit both conditions simultaneously. **Validation.** rate-limiter-flexible: all 22 leaf .js files compile to .o cleanly under `perry compile`, including the 5 that previously failed. 152/0/0 `perry` crate tests pass; full workspace exit 0. **Scope.** Fixes only the side-map consistency bug. **Out of scope.** Not changing the `local_alias=Some("default")` synthesis at `compile.rs:3155`; that synthesis exists for the wrapper-emission loop's name-lookup contract, and the codegen-side consistency fix is narrower. + ## v0.5.895 — feat(resolve): #665 — `perry.compilePackages` overrides the built-in NATIVE_MODULES routing on a per-package basis. **Symptom.** A user who adds `"perry": { "compilePackages": ["rate-limiter-flexible"] }` to `package.json` — explicitly opting into "compile this npm package from its `node_modules` source rather than route to your built-in FFI binding" — was silently ignored for any package listed in `perry-api-manifest::NATIVE_MODULES`. With `PERRY_DEBUG_CJS_WRAP=1` the cjs_wrap pass logged **zero** invocations for `rate-limiter-flexible` despite the opt-in. The import resolver at `crates/perry/src/commands/compile/resolve.rs:806` short-circuited unconditionally on `is_native_module(...)`, returning the import to the stdlib FFI dispatch path. For `rate-limiter-flexible` specifically that path is broken: codegen at `crates/perry-codegen/src/runtime_decls.rs:2213-2214` declares a single-arg `js_ratelimit_create` returning a 0-stub, but the real implementation at `crates/perry-ext-ratelimit/src/lib.rs:39` is two-arg `js_ratelimit_new(points, duration_secs)` — name and arity both mismatch — and only `consume` is wired through codegen beyond that. So users hit `typeof limiter.consume === "undefined"` and a `TypeError: value is not a function` at the first request, with no obvious way out. **Root cause.** Two parallel sites cache the "is this import native" decision and both ignored `compilePackages`. (1) `resolve::resolve_import` early-returns `None` (which the rest of the pipeline treats as "stdlib FFI path") whenever `perry_hir::is_native_module(import_source)` matches. (2) `collect_modules::process_imports` reads `import.is_native` from the HIR (set during lowering based on the same NATIVE_MODULES table), and any import flagged as native skips the file-resolution and cjs_wrap path entirely. Neither check consulted `ctx.compile_packages` — so the opt-in was structurally unable to win. **Fix.** Targeted opt-in override at both sites, scoped to packages the user has explicitly named in `perry.compilePackages`. (a) `resolve.rs::resolve_import`: parse the package name out of the import source via the existing `parse_package_specifier`, then `is_native_module && !compile_packages.contains(pkg)` is the new short-circuit condition. Bare `rate-limiter-flexible` and any subpath under it (`rate-limiter-flexible/lib/RateLimiterMemory`) both fall through to file resolution when the opt-in is present. (b) `collect_modules.rs`: after the existing `package_aliases` rewrite, when `import.is_native` is true and the package is in `ctx.compile_packages`, flip `import.is_native = false` so the subsequent module-kind dispatch routes through file resolution + cjs_wrap + native codegen instead of stdlib FFI. The two changes are intentionally redundant — the resolver fix governs the path-resolution side of `is_native_module`, the collect_modules fix governs the per-import HIR-cached `is_native` flag — and they need to agree or one side will silently win. Default behavior unchanged: a stock project without `compilePackages` (or with `compilePackages` not listing a given native-bound package) goes through the exact same path as before. The native binding remains the recommended default for packages where it's complete; the override is for users who know their built-in binding is incomplete and have a working `node_modules` copy. **Validation.** All 152 `perry` crate tests pass (`cargo test --release -p perry`); 37 cjs_wrap unit tests pass unchanged; full workspace `cargo test --release --workspace --exclude perry-ui-{ios,tvos,watchos,visionos,android,windows,gtk4}` returns exit 0. Build clean. **Scope.** This commit only lands the routing opt-in. Three follow-up gaps tracked in subsequent commits: (1) codegen side-map consistency for default-imported classes (v0.5.896); (2) `setTimeout(fn, delay, ...args)` trailing args (v0.5.897); (3) HIR's `is_native_module` honoring `compilePackages` so `instance.method` doesn't lower as a zero-arg FFI getter (v0.5.898). **Out of scope.** No new tests/release/packages fixture for rate-limiter-flexible — added once the full chain runs end-to-end (v0.5.898 ships that). **Version-bump note.** Renumbered from the original v0.5.892 because #734 (v0.5.892 linker), #736 (v0.5.893 HarmonyOS Chart + TreeView), and #737 (v0.5.894 dependabot batch) all landed on main while this PR was in review. ## v0.5.894 — deps: batch dependabot roll-up (#724, #725, #726, #728, #730, #731, #723). Cargo crates: `scraper` 0.19.1 → 0.27.0 (perry-ext-cheerio + perry-stdlib `bundled-cheerio` feature), `gstreamer` 0.23.7 → 0.25.2 (perry-ui-gtk4 — `perry/media` playbin streaming audio backend, #351), `bcrypt` 0.15 → 0.17 in perry-stdlib `bundled-bcrypt` feature (aligns with perry-ext-bcrypt which was already pinned at 0.17, so the workspace now resolves a single bcrypt version end-to-end). GitHub Actions: `actions/download-artifact` v4 → v8, `actions/setup-node` v4 → v6, `actions/cache` v4 → v5 across `benchmark.yml`, `release-packages.yml`, `simctl-tests.yml`, `test.yml` (the historical reference to `actions/cache@v4` in test.yml line 110's comment is intentionally left as-is — it documents the v0.5.387 removal). NPM test fixture: `drizzle-orm` ^0.36.0 → ^0.45.2 in `tests/release/packages/drizzle-sqlite/` (the prior dependabot PR #723 failed security-audit on a transient advisory-db hit; main's subsequent run against the unchanged Cargo.lock passed, confirming the failure was stale). **Not bumped (real breakage):** `toml` 0.8.23 → 1.1.2+spec-1.1.0 (PR #727) — major version with breaking parser changes that fail `well_known_bindings.toml` (7 cargo tests + 31 compile-smoke regressions); needs a migration commit, not a drop-in. `deno_core` 0.311.0 → 0.400.0 (PR #729) — 89-patch jump with API-breaking changes that fail parity + compile-smoke. **Validation.** Six green PRs reviewed (each carrying its own full CI run: lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, harmonyos-smoke, doc-tests). Files edited match exactly what each dependabot branch proposed; the only delta is that all seven roll into a single commit + version bump per the maintainer's batched-deps preference. The two failing PRs (#727, #729) were closed via comment on the PRs explaining they need follow-up migration work — see the linked issues for the actual breakage details. **Version-bump note.** Renumbered to v0.5.894 because #734 landed as v0.5.892 and #736 landed as v0.5.893 on main while this batch was in review. diff --git a/CLAUDE.md b/CLAUDE.md index 2066c5854a..84c8e40d6b 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.895 +**Current Version:** 0.5.896 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 5c9d8779a0..dddce3aeb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4636,7 +4636,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "base64", @@ -4691,14 +4691,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.895" +version = "0.5.896" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "log", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-hir", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-hir", @@ -4728,7 +4728,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-dispatch", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-hir", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "base64", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-hir", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.895" +version = "0.5.896" dependencies = [ "serde", "serde_json", @@ -4776,7 +4776,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.895" +version = "0.5.896" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4787,7 +4787,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "clap", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.895" +version = "0.5.896" dependencies = [ "argon2", "perry-ffi", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "reqwest", @@ -4819,7 +4819,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.895" +version = "0.5.896" dependencies = [ "bcrypt", "perry-ffi", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "rusqlite", @@ -4835,7 +4835,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "scraper", @@ -4843,14 +4843,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.895" +version = "0.5.896" dependencies = [ "chrono", "cron", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.895" +version = "0.5.896" dependencies = [ "chrono", "perry-ffi", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "rust_decimal", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "serde_json", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4891,21 +4891,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.895" +version = "0.5.896" dependencies = [ "bytes", "http-body-util", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lazy_static", "perry-ffi", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -4942,7 +4942,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.895" +version = "0.5.896" dependencies = [ "bytes", "http-body-util", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lazy_static", "perry-ffi", @@ -4971,7 +4971,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.895" +version = "0.5.896" dependencies = [ "base64", "jsonwebtoken", @@ -4982,7 +4982,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lru", "perry-ffi", @@ -4990,7 +4990,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.895" +version = "0.5.896" dependencies = [ "chrono", "perry-ffi", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.895" +version = "0.5.896" dependencies = [ "bson", "futures-util", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.895" +version = "0.5.896" dependencies = [ "chrono", "perry-ffi", @@ -5020,7 +5020,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.895" +version = "0.5.896" dependencies = [ "nanoid", "perry-ffi", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "rustls", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lettre", "perry-ffi", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "sqlx", @@ -5059,7 +5059,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.895" +version = "0.5.896" dependencies = [ "governor", "perry-ffi", @@ -5067,7 +5067,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.895" +version = "0.5.896" dependencies = [ "base64", "image", @@ -5076,14 +5076,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.895" +version = "0.5.896" dependencies = [ "lazy_static", "perry-ffi", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "uuid", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.895" +version = "0.5.896" dependencies = [ "perry-ffi", "regex", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.895" +version = "0.5.896" dependencies = [ "futures-util", "lazy_static", @@ -5120,7 +5120,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.895" +version = "0.5.896" dependencies = [ "flate2", "perry-ffi", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.895" +version = "0.5.896" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-api-manifest", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "deno_core", @@ -5170,7 +5170,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-diagnostics", @@ -5182,7 +5182,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "base64", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.895" +version = "0.5.896" dependencies = [ "aes", "aes-gcm", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "perry-hir", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.895" +version = "0.5.896" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5292,11 +5292,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.895" +version = "0.5.896" [[package]] name = "perry-ui-android" -version = "0.5.895" +version = "0.5.896" dependencies = [ "itoa", "jni", @@ -5311,7 +5311,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.895" +version = "0.5.896" dependencies = [ "rand 0.8.6", "serde", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.895" +version = "0.5.896" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5340,7 +5340,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.895" +version = "0.5.896" dependencies = [ "block2", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.895" +version = "0.5.896" dependencies = [ "block2", "libc", @@ -5373,11 +5373,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.895" +version = "0.5.896" [[package]] name = "perry-ui-tvos" -version = "0.5.895" +version = "0.5.896" dependencies = [ "block2", "libc", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.895" +version = "0.5.896" dependencies = [ "block2", "libc", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.895" +version = "0.5.896" dependencies = [ "block2", "libc", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.895" +version = "0.5.896" dependencies = [ "libc", "perry-runtime", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.895" +version = "0.5.896" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index ca496cd656..011c9bc5f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.895" +version = "0.5.896" 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 7938a486ca..d1970eb11e 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -534,7 +534,18 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } // Assign a class id for dispatch / instanceof. - class_ids.insert(effective_name.to_string(), class_id); + // + // Refs #665: `or_insert` (first-writer-wins) instead of `insert` + // (last-writer-wins). When two different classes are both + // default-imported in the same file, both register under + // `effective_name = "default"`. `class_table.entry().or_insert()` + // below already keeps the first stub for that key; the side maps + // must agree, otherwise the method registry builds symbols mixing + // the FIRST writer's methods with the LAST writer's prefix + + // canonical name, producing fnames the linker can't resolve. + class_ids + .entry(effective_name.to_string()) + .or_insert(class_id); // Also register the canonical name if aliased. if ic.local_alias.is_some() && !class_ids.contains_key(&ic.name) { class_ids.insert(ic.name.clone(), class_id); @@ -708,9 +719,22 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> if hir.classes.iter().any(|c| c.name == *effective_name) { continue; } - imported_class_prefix.insert(effective_name.to_string(), ic.source_prefix.clone()); + // Refs #665: first-writer-wins to match `class_table`'s + // `.or_insert()` semantics (see the class-id loop above). When two + // different classes are both default-imported, both register under + // `effective_name = "default"`; using `.insert()` would let the + // LAST writer's source_prefix / canonical name win, while + // `class_table["default"]` keeps the FIRST writer's stub. The + // method-registry builder reads both, and the mismatch produces + // method symbols mangled under the wrong class — the linker can't + // resolve them and the build fails with "undefined value". + imported_class_prefix + .entry(effective_name.to_string()) + .or_insert_with(|| ic.source_prefix.clone()); if effective_name != ic.name { - imported_class_source_name.insert(effective_name.to_string(), ic.name.clone()); + imported_class_source_name + .entry(effective_name.to_string()) + .or_insert_with(|| ic.name.clone()); } } for stub in &imported_class_stubs { From 4f82212a94c8d9c48adf67b813bef073b33d5113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 07:44:00 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(codegen,runtime):=20#665=20=E2=80=94=20?= =?UTF-8?q?setTimeout(fn,=20delay,=20...args)=20forwards=20trailing=20args?= =?UTF-8?q?=20(v0.5.897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing `"setTimeout" if args.len() == 2` arm only handled the 2-arg shape; 3+ arg call sites (e.g. `setTimeout(resolve, delay, res)` inside Promise executors per ECMA-262 §27.5.4.1) fell through to the generic ExternFuncRef fallthrough and emitted `call @setTimeout(...)` against a symbol nothing in stdlib defines. Discovering call site: `RateLimiterMemory.consume()`'s Promise executor in rate-limiter-flexible. Adds: - `CallbackTimer::args: Vec` (empty for 2-arg shape — back-compat). - `js_set_timeout_callback_args(callback, delay_ms, args_ptr, n_args)` runtime entry that copies the buffer in. - `js_callback_timer_tick` dispatch via `match args.len()` to `js_closure_call0..js_closure_call9`, clamping at 9 trailing args. - Codegen `"setTimeout" if args.len() >= 3` arm: alloca a stack buffer of doubles for the trailing args, GEP for the base, call the new runtime entry. Refs #665. See CHANGELOG.md for full root-cause + scope notes. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 134 +++++++++++----------- Cargo.toml | 2 +- crates/perry-codegen/src/lower_call.rs | 36 ++++++ crates/perry-codegen/src/runtime_decls.rs | 9 ++ crates/perry-runtime/src/timer.rs | 100 +++++++++++++++- 7 files changed, 213 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32091d81de..264263e619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.897 — fix(codegen,runtime): #665 — `setTimeout(fn, delay, ...args)` with trailing args no longer leaves a bare `_setTimeout` symbol at link time; trailing args are now forwarded to the callback. **Symptom.** With v0.5.895 (resolver opt-in) and v0.5.896 (default-import codegen collision) both applied, the rate-limiter-flexible compile reached the link stage cleanly, then failed with `Undefined symbols for architecture arm64: "_setTimeout", referenced from: _perry_closure_node_modules_rate_limiter_flexible_lib_RateLimiterMemory_js__1`. Discovering call site: `RateLimiterMemory.consume()` does `setTimeout(resolve, delay, res)` inside its Promise executor (per ECMA-262 §27.5.4.1, the trailing arg is forwarded to `resolve` so the Promise resolves with `res`). **Root cause.** `crates/perry-codegen/src/lower_call.rs:742` had a single `"setTimeout" if args.len() == 2` arm routing the 2-arg shape to `js_set_timeout_callback(callback, delay)`. Any other arity (including the spec-defined `setTimeout(fn, delay, ...args)` shape) fell through to the generic ExternFuncRef fallthrough, which emits `call double @setTimeout(...)` against a symbol nothing in stdlib defines. clang prefixes `_` on macOS → linker looks for `_setTimeout` → no such symbol. The 2-arg-only restriction had been the gate since the codegen arm was first written; until now, every real-world setTimeout call site Perry had been tested against used exactly 2 args. **Fix.** Three coordinated changes. (1) `crates/perry-runtime/src/timer.rs`: extend `CallbackTimer` with `args: Vec` (NaN-boxed JSValues), defaulting to empty for the 2-arg shape (back-compat). Add new runtime entry `js_set_timeout_callback_args(callback: i64, delay_ms: f64, args_ptr: *const f64, n_args: i32) -> i64` that copies the buffer into the timer record before returning. `js_callback_timer_tick` dispatches via `match timer.args.len()` to `js_closure_call0`..`js_closure_call8`, clamping at 9 (covers any realistic trailing-args count). (2) `crates/perry-codegen/src/runtime_decls.rs`: declare the new symbol `js_set_timeout_callback_args(I64, DOUBLE, PTR, I32) -> I64`. (3) `crates/perry-codegen/src/lower_call.rs`: add a second `"setTimeout" if args.len() >= 3` arm. Allocate a stack buffer of `n = args.len() - 2` doubles via `alloca_entry_array`, store each lowered trailing arg into its slot, GEP for the base pointer, and call `js_set_timeout_callback_args`. The existing 2-arg arm is untouched. **Validation.** rate-limiter-flexible now compiles, links, AND runs end-to-end. Standalone probe `setTimeout((a,b)=>log("fired:",a,b), 100, "hello", 42)` prints `fired: hello 42` byte-equal to Bun. Workspace tests green. **Scope.** Closes the link-stage blocker. **Not in scope.** Calling `await limiter.consume(...)` end-to-end through Promise resolution — bound-method dispatch via CLASS_VTABLE_REGISTRY works for drizzle/hono so should work here. Support for >9 trailing args to setTimeout (would need a variadic-dispatcher closure-call helper); real-world JS rarely exceeds 1-2 trailing args. **Why `_setTimeout` not `setTimeout`.** The macOS Mach-O ABI prefixes C-extern symbol names with `_` (`__USER_LABEL_PREFIX__`); the linker error reports the prefixed form. + ## v0.5.896 — fix(codegen): #665 — multiple default-imported classes in the same file no longer mint phantom cross-class method symbols. **Symptom.** With v0.5.895's routing opt-in active and `perry.compilePackages: ["rate-limiter-flexible"]` set, compiling `import { RateLimiterMemory } from "rate-limiter-flexible"` failed with 5 leaf files emitting LLVM IR that referenced undefined symbols: `RateLimiterMemory.js` → `@perry_method_..._RateLimiterRes_js__RateLimiterRes__delete`; `RateLimiterMemcache.js` / `RateLimiterRedis.js` → `..._RateLimiterRes__get`; `RateLimiterCluster.js` → `..._RateLimiterRes__block`; `RateLimiterStoreAbstract.js` → `..._RateLimiterRes__delete`. None of these `RateLimiterRes__` symbols exist — `RateLimiterRes.js` only declares `_getDecoratedProperties`, `toString`, `toJSON`, and 4 getter/setter pairs; it has no `delete` / `get` / `set` / `block` method. The clang `-c` invocation for each of those 5 files failed with `error: use of undefined value '@perry_method_..._RateLimiterRes__delete'` and the build stopped before linking. **Root cause.** In `crates/perry-codegen/src/codegen.rs`'s `compile_module`, three side maps for imported classes (`class_ids`, `imported_class_prefix`, `imported_class_source_name`) used `.insert()` — last-writer-wins — while the parallel `class_table` used `.entry().or_insert()` — first-writer-wins. The inconsistency surfaces when two different classes are both imported via the default-export bridge in the same compilation unit: `compile.rs:3155` pushes a SECOND `ImportedClass` entry per default-imported class with `local_alias = Some("default")`. In a file that does `const RateLimiterRes = require("./RateLimiterRes")` AND `const MemoryStorage = require("./component/MemoryStorage/MemoryStorage")`, both entries land in `opts.imported_classes` with `local_alias = Some("default")` → both derive `effective_name = "default"`. `class_table["default"]` keeps the FIRST stub (MemoryStorage's, with `[incrby, set, get, delete]`); `class_ids["default"]` and `imported_class_prefix["default"]` and `imported_class_source_name["default"]` all take the LAST writer's values (RateLimiterRes's class id 16, RateLimiterRes_js source prefix, canonical name "RateLimiterRes"). When the method-registry builder iterates `class_table.values()` for `c.name == "default"`, it builds method symbols by combining the kept stub's method list (MemoryStorage's) with the side maps' canonical name + prefix (RateLimiterRes's): `method_names[("default", "set")] = "perry_method_..._RateLimiterRes_js__RateLimiterRes__set"`. Phase F (which iterates `opts.imported_classes` directly) only sees RateLimiterRes's real method list and never declares `RateLimiterRes__set`. The dispatch tower in `lower_call.rs:1899-1911` then emits a call to the undeclared symbol. **Fix.** Three sites in `crates/perry-codegen/src/codegen.rs` flipped from `.insert()` to `.entry().or_insert()`: (1) `class_ids` registration for `effective_name`; (2) `imported_class_prefix` registration; (3) `imported_class_source_name` registration. With these flipped, all four maps agree first-writer-wins, and `method_names[("default", "set")]` resolves to `MemoryStorage__set` (real symbol) rather than `RateLimiterRes__set` (phantom). **Why this hadn't surfaced before.** Requires two specific conditions: two or more different classes default-imported in the same compilation unit AND `this..()` shape firing the dispatch tower. Most user code with two default-imports doesn't also chain method calls through dynamic receivers; most files with that pattern only have one default-import. rate-limiter-flexible's leaf files hit both conditions simultaneously. **Validation.** rate-limiter-flexible: all 22 leaf .js files compile to .o cleanly under `perry compile`, including the 5 that previously failed. 152/0/0 `perry` crate tests pass; full workspace exit 0. **Scope.** Fixes only the side-map consistency bug. **Out of scope.** Not changing the `local_alias=Some("default")` synthesis at `compile.rs:3155`; that synthesis exists for the wrapper-emission loop's name-lookup contract, and the codegen-side consistency fix is narrower. ## v0.5.895 — feat(resolve): #665 — `perry.compilePackages` overrides the built-in NATIVE_MODULES routing on a per-package basis. **Symptom.** A user who adds `"perry": { "compilePackages": ["rate-limiter-flexible"] }` to `package.json` — explicitly opting into "compile this npm package from its `node_modules` source rather than route to your built-in FFI binding" — was silently ignored for any package listed in `perry-api-manifest::NATIVE_MODULES`. With `PERRY_DEBUG_CJS_WRAP=1` the cjs_wrap pass logged **zero** invocations for `rate-limiter-flexible` despite the opt-in. The import resolver at `crates/perry/src/commands/compile/resolve.rs:806` short-circuited unconditionally on `is_native_module(...)`, returning the import to the stdlib FFI dispatch path. For `rate-limiter-flexible` specifically that path is broken: codegen at `crates/perry-codegen/src/runtime_decls.rs:2213-2214` declares a single-arg `js_ratelimit_create` returning a 0-stub, but the real implementation at `crates/perry-ext-ratelimit/src/lib.rs:39` is two-arg `js_ratelimit_new(points, duration_secs)` — name and arity both mismatch — and only `consume` is wired through codegen beyond that. So users hit `typeof limiter.consume === "undefined"` and a `TypeError: value is not a function` at the first request, with no obvious way out. **Root cause.** Two parallel sites cache the "is this import native" decision and both ignored `compilePackages`. (1) `resolve::resolve_import` early-returns `None` (which the rest of the pipeline treats as "stdlib FFI path") whenever `perry_hir::is_native_module(import_source)` matches. (2) `collect_modules::process_imports` reads `import.is_native` from the HIR (set during lowering based on the same NATIVE_MODULES table), and any import flagged as native skips the file-resolution and cjs_wrap path entirely. Neither check consulted `ctx.compile_packages` — so the opt-in was structurally unable to win. **Fix.** Targeted opt-in override at both sites, scoped to packages the user has explicitly named in `perry.compilePackages`. (a) `resolve.rs::resolve_import`: parse the package name out of the import source via the existing `parse_package_specifier`, then `is_native_module && !compile_packages.contains(pkg)` is the new short-circuit condition. Bare `rate-limiter-flexible` and any subpath under it (`rate-limiter-flexible/lib/RateLimiterMemory`) both fall through to file resolution when the opt-in is present. (b) `collect_modules.rs`: after the existing `package_aliases` rewrite, when `import.is_native` is true and the package is in `ctx.compile_packages`, flip `import.is_native = false` so the subsequent module-kind dispatch routes through file resolution + cjs_wrap + native codegen instead of stdlib FFI. The two changes are intentionally redundant — the resolver fix governs the path-resolution side of `is_native_module`, the collect_modules fix governs the per-import HIR-cached `is_native` flag — and they need to agree or one side will silently win. Default behavior unchanged: a stock project without `compilePackages` (or with `compilePackages` not listing a given native-bound package) goes through the exact same path as before. The native binding remains the recommended default for packages where it's complete; the override is for users who know their built-in binding is incomplete and have a working `node_modules` copy. **Validation.** All 152 `perry` crate tests pass (`cargo test --release -p perry`); 37 cjs_wrap unit tests pass unchanged; full workspace `cargo test --release --workspace --exclude perry-ui-{ios,tvos,watchos,visionos,android,windows,gtk4}` returns exit 0. Build clean. **Scope.** This commit only lands the routing opt-in. Three follow-up gaps tracked in subsequent commits: (1) codegen side-map consistency for default-imported classes (v0.5.896); (2) `setTimeout(fn, delay, ...args)` trailing args (v0.5.897); (3) HIR's `is_native_module` honoring `compilePackages` so `instance.method` doesn't lower as a zero-arg FFI getter (v0.5.898). **Out of scope.** No new tests/release/packages fixture for rate-limiter-flexible — added once the full chain runs end-to-end (v0.5.898 ships that). **Version-bump note.** Renumbered from the original v0.5.892 because #734 (v0.5.892 linker), #736 (v0.5.893 HarmonyOS Chart + TreeView), and #737 (v0.5.894 dependabot batch) all landed on main while this PR was in review. diff --git a/CLAUDE.md b/CLAUDE.md index 84c8e40d6b..f4c26d83f8 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.896 +**Current Version:** 0.5.897 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index dddce3aeb2..2d49f1b4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4636,7 +4636,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "base64", @@ -4691,14 +4691,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.896" +version = "0.5.897" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "log", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-hir", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-hir", @@ -4728,7 +4728,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-dispatch", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-hir", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "base64", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-hir", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.896" +version = "0.5.897" dependencies = [ "serde", "serde_json", @@ -4776,7 +4776,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.896" +version = "0.5.897" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4787,7 +4787,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "clap", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.896" +version = "0.5.897" dependencies = [ "argon2", "perry-ffi", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "reqwest", @@ -4819,7 +4819,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.896" +version = "0.5.897" dependencies = [ "bcrypt", "perry-ffi", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "rusqlite", @@ -4835,7 +4835,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "scraper", @@ -4843,14 +4843,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.896" +version = "0.5.897" dependencies = [ "chrono", "cron", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.896" +version = "0.5.897" dependencies = [ "chrono", "perry-ffi", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "rust_decimal", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "serde_json", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4891,21 +4891,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.896" +version = "0.5.897" dependencies = [ "bytes", "http-body-util", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lazy_static", "perry-ffi", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -4942,7 +4942,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.896" +version = "0.5.897" dependencies = [ "bytes", "http-body-util", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lazy_static", "perry-ffi", @@ -4971,7 +4971,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.896" +version = "0.5.897" dependencies = [ "base64", "jsonwebtoken", @@ -4982,7 +4982,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lru", "perry-ffi", @@ -4990,7 +4990,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.896" +version = "0.5.897" dependencies = [ "chrono", "perry-ffi", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.896" +version = "0.5.897" dependencies = [ "bson", "futures-util", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.896" +version = "0.5.897" dependencies = [ "chrono", "perry-ffi", @@ -5020,7 +5020,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.896" +version = "0.5.897" dependencies = [ "nanoid", "perry-ffi", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "rustls", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lettre", "perry-ffi", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "sqlx", @@ -5059,7 +5059,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.896" +version = "0.5.897" dependencies = [ "governor", "perry-ffi", @@ -5067,7 +5067,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.896" +version = "0.5.897" dependencies = [ "base64", "image", @@ -5076,14 +5076,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.896" +version = "0.5.897" dependencies = [ "lazy_static", "perry-ffi", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "uuid", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.896" +version = "0.5.897" dependencies = [ "perry-ffi", "regex", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.896" +version = "0.5.897" dependencies = [ "futures-util", "lazy_static", @@ -5120,7 +5120,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.896" +version = "0.5.897" dependencies = [ "flate2", "perry-ffi", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.896" +version = "0.5.897" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-api-manifest", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "deno_core", @@ -5170,7 +5170,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-diagnostics", @@ -5182,7 +5182,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "base64", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.896" +version = "0.5.897" dependencies = [ "aes", "aes-gcm", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "perry-hir", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.896" +version = "0.5.897" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5292,11 +5292,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.896" +version = "0.5.897" [[package]] name = "perry-ui-android" -version = "0.5.896" +version = "0.5.897" dependencies = [ "itoa", "jni", @@ -5311,7 +5311,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.896" +version = "0.5.897" dependencies = [ "rand 0.8.6", "serde", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.896" +version = "0.5.897" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5340,7 +5340,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.896" +version = "0.5.897" dependencies = [ "block2", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.896" +version = "0.5.897" dependencies = [ "block2", "libc", @@ -5373,11 +5373,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.896" +version = "0.5.897" [[package]] name = "perry-ui-tvos" -version = "0.5.896" +version = "0.5.897" dependencies = [ "block2", "libc", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.896" +version = "0.5.897" dependencies = [ "block2", "libc", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.896" +version = "0.5.897" dependencies = [ "block2", "libc", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.896" +version = "0.5.897" dependencies = [ "libc", "perry-runtime", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.896" +version = "0.5.897" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 011c9bc5f0..0122d1d24e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.896" +version = "0.5.897" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 625af8d913..761e6b9184 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -751,6 +751,42 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R ); return Ok(nanbox_pointer_inline(blk, &id)); } + // Refs #665: `setTimeout(fn, delay, ...args)` — JS spec forwards + // the trailing args to `fn` when the timer fires. Pack them into + // a stack buffer of doubles and hand off to the varargs runtime + // entry. Used by Promise-executor patterns like + // `setTimeout(resolve, delay, res)` (rate-limiter-flexible's + // `RateLimiterMemory.consume` is the discovering call site). + "setTimeout" if args.len() >= 3 => { + let cb_box = lower_expr(ctx, &args[0])?; + let delay_box = lower_expr(ctx, &args[1])?; + let n = args.len() - 2; + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, a) in args.iter().skip(2).enumerate() { + let v = lower_expr(ctx, a)?; + let blk = ctx.block(); + let slot = blk.gep(DOUBLE, &buf, &[(I64, &format!("{}", i))]); + blk.store(DOUBLE, &v, &slot); + } + let ptr_reg = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", + ptr_reg, n, buf + )); + let blk = ctx.block(); + let cb_handle = unbox_to_i64(blk, &cb_box); + let id = blk.call( + I64, + "js_set_timeout_callback_args", + &[ + (I64, &cb_handle), + (DOUBLE, &delay_box), + (crate::types::PTR, &ptr_reg), + (I32, &n.to_string()), + ], + ); + return Ok(nanbox_pointer_inline(blk, &id)); + } "setInterval" if args.len() == 2 => { let cb_box = lower_expr(ctx, &args[0])?; let delay_box = lower_expr(ctx, &args[1])?; diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index 73a015eb63..3021288442 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -1078,6 +1078,15 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // next body iteration's microtask drain. module.declare_function("js_microtasks_pending", I32, &[]); module.declare_function("js_set_timeout_callback", I64, &[I64, DOUBLE]); + // Refs #665: `setTimeout(fn, delay, ...args)` with trailing args. The + // args are packed into a stack buffer of doubles at the call site and + // forwarded by index when the timer fires. Used by Promise-executor + // patterns like `setTimeout(resolve, delay, res)`. + module.declare_function( + "js_set_timeout_callback_args", + I64, + &[I64, DOUBLE, crate::types::PTR, I32], + ); module.declare_function("setInterval", I64, &[I64, DOUBLE]); module.declare_function("clearTimeout", VOID, &[I64]); module.declare_function("clearInterval", VOID, &[I64]); diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index 5473a0bb74..15fb370e3c 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -160,6 +160,13 @@ struct CallbackTimer { deadline: Instant, /// The closure pointer to call callback: i64, + /// Trailing arguments to forward to the callback when it fires. + /// Empty for the standard `setTimeout(fn, delay)` shape; non-empty + /// when the call site is `setTimeout(fn, delay, ...args)` (JS spec + /// allows trailing args that get passed to the callback — used in + /// e.g. `setTimeout(resolve, delay, res)` inside Promise executors). + /// Refs #665. + args: Vec, /// Whether this timer has been cleared cleared: bool, } @@ -191,6 +198,53 @@ pub extern "C" fn js_set_timeout_callback(callback: i64, delay_ms: f64) -> i64 { id, deadline, callback, + args: Vec::new(), + cleared: false, + }); + + id +} + +/// JS-style setTimeout that takes a callback function, delay, and a buffer +/// of trailing arguments. The callback is invoked as `callback(...args)` +/// when the timer fires. The args buffer is copied into the timer record +/// before this function returns (caller may free `args_ptr` immediately). +/// +/// Refs #665: `setTimeout(resolve, delay, res)` and similar shapes inside +/// Promise executors couldn't reach codegen because the existing +/// `js_set_timeout_callback` only handled the 2-arg form; 3+ arg call sites +/// fell through and emitted a bare `setTimeout` symbol the linker couldn't +/// resolve. +#[no_mangle] +pub unsafe extern "C" fn js_set_timeout_callback_args( + callback: i64, + delay_ms: f64, + args_ptr: *const f64, + n_args: i32, +) -> i64 { + ensure_initialized(); + + let delay = Duration::from_millis(delay_ms.max(0.0) as u64); + let deadline = Instant::now() + delay; + + let args: Vec = if args_ptr.is_null() || n_args <= 0 { + Vec::new() + } else { + std::slice::from_raw_parts(args_ptr, n_args as usize).to_vec() + }; + + let id = { + let mut next = NEXT_CALLBACK_TIMER_ID.lock().unwrap(); + let current = *next; + *next += 1; + current + }; + + CALLBACK_TIMERS.lock().unwrap().push(CallbackTimer { + id, + deadline, + callback, + args, cleared: false, }); @@ -201,7 +255,10 @@ pub extern "C" fn js_set_timeout_callback(callback: i64, delay_ms: f64) -> i64 { /// Returns the number of callbacks that were called #[no_mangle] pub extern "C" fn js_callback_timer_tick() -> i32 { - use crate::closure::js_closure_call0; + use crate::closure::{ + js_closure_call0, js_closure_call1, js_closure_call2, js_closure_call3, js_closure_call4, + js_closure_call5, js_closure_call6, js_closure_call7, js_closure_call8, js_closure_call9, + }; let now = Instant::now(); @@ -223,11 +280,48 @@ pub extern "C" fn js_callback_timer_tick() -> i32 { }; let mut fired = 0; - // Call the callbacks + // Call the callbacks, forwarding any trailing args captured at + // `setTimeout(fn, delay, ...args)` time. Refs #665. for timer in expired { if !timer.cleared { + let cb = timer.callback as *const crate::closure::ClosureHeader; + let a = &timer.args; unsafe { - js_closure_call0(timer.callback as *const crate::closure::ClosureHeader); + match a.len() { + 0 => { + js_closure_call0(cb); + } + 1 => { + js_closure_call1(cb, a[0]); + } + 2 => { + js_closure_call2(cb, a[0], a[1]); + } + 3 => { + js_closure_call3(cb, a[0], a[1], a[2]); + } + 4 => { + js_closure_call4(cb, a[0], a[1], a[2], a[3]); + } + 5 => { + js_closure_call5(cb, a[0], a[1], a[2], a[3], a[4]); + } + 6 => { + js_closure_call6(cb, a[0], a[1], a[2], a[3], a[4], a[5]); + } + 7 => { + js_closure_call7(cb, a[0], a[1], a[2], a[3], a[4], a[5], a[6]); + } + 8 => { + js_closure_call8(cb, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]); + } + _ => { + // >= 9 args: clamp to 9. Real-world setTimeout + // rarely exceeds 1-2 trailing args; this is a + // conservative safety net rather than spec coverage. + js_closure_call9(cb, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]); + } + } } fired += 1; } From 5cbade5cc74c38a6b78cf8bdf04de4d47720900b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 07:48:10 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(hir):=20#665=20=E2=80=94=20compilePacka?= =?UTF-8?q?ges-opted-in=20packages=20skip=20native-instance=20lowering=20(?= =?UTF-8?q?v0.5.898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIR's `is_native_module` only consulted the NATIVE_MODULES manifest, so `rate-limiter-flexible` was lowered as a native import regardless of the user's `compilePackages` opt-in. That cascaded: register_native_module(local, "rate-limiter-flexible", ...) → new RateLimiterMemory(...) triggers register_native_instance(limiter, "rate-limiter-flexible", "RateLimiterMemory") → limiter.consume in expr_member::lower_member emits NativeMethodCall { module, object: Some(limiter), method: "consume", args: [] } → codegen routes to js_native_call_method → no FFI entry → 0.0 So `typeof limiter.consume === "number"`. For genuine native bindings (req.method on http.IncomingMessage) this zero-arg-getter lowering is correct; for compile-package-overridden classes it's wrong because `consume` is a real method, not a getter. Fix: add a thread-local `COMPILE_PACKAGES_OVERRIDE: HashSet` in perry-hir, set by the compiler driver via `set_compile_packages_override(ctx.compile_packages.clone())` before each `lower_module_full` invocation and cleared after. `is_native_module` parses the package name (supports `node:` prefix + `@scope/pkg` scoped form) and returns false when the package is in the override even if it appears in NATIVE_MODULES. Validation: rate-limiter-flexible probe now reports `typeof limiter.consume: function` (was "number"). Workspace tests green. Refs #665. See CHANGELOG.md for full root-cause walkthrough. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 134 +++++++++--------- Cargo.toml | 2 +- crates/perry-hir/src/ir.rs | 72 +++++++++- .../src/commands/compile/collect_modules.rs | 23 ++- 6 files changed, 162 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 264263e619..9b7f45418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.898 — fix(hir): #665 — `compilePackages`-opted-in packages no longer trigger native-instance lowering, so `instance.method` returns a function instead of `number 0`. **Symptom.** With v0.5.895 (resolver opt-in), v0.5.896 (codegen side-map), and v0.5.897 (setTimeout trailing args) all applied, rate-limiter-flexible compiled and ran, but a probe reading `typeof limiter.consume` returned `"number"` instead of `"function"`. The value bits passed to `js_value_typeof` were `0x0000000000000000` (literal zero) — perry was evaluating `limiter.consume` as a zero-arg method *call* that returned 0.0, not as a *property read* yielding the function reference. Confusingly, `console.log("limiter.consume:", limiter.consume)` printed `[Function (anonymous)]`, so the same expression was being lowered consistently but its runtime path diverged. **Root cause.** Two-step trap. (1) `crates/perry-hir/src/ir.rs::is_native_module` only consulted the NATIVE_MODULES manifest. `rate-limiter-flexible` is in that manifest (the `perry-ext-ratelimit` binding exists, even if incomplete), so during HIR lowering the import statement was tagged `is_native = true`. (2) HIR lowering chain: `register_native_module(limiter_alias, "rate-limiter-flexible", ...)` → `new RateLimiterMemory(...)` in lower.rs:3848 → `lookup_native_module("RateLimiterMemory")` returns Some → `register_native_instance(limiter, "rate-limiter-flexible", "RateLimiterMemory")` → every subsequent `limiter.` member access in `expr_member::lower_member:475` matches the "native instance" arm and lowers as `NativeMethodCall { module, object: Some(limiter), method: , args: vec![] }` — a zero-arg FFI-getter call, on the assumption that `obj.prop` on a native binding is implemented as a getter function. For genuine FFI bindings (req.method on http.IncomingMessage etc.) this is correct. For a class compiled from JS source via cjs_wrap, it is wrong: `consume` is a real method, not a getter. The codegen routes the NativeMethodCall through `js_native_call_method` for `rate-limiter-flexible.` — finds no matching FFI entry — and returns `0.0`. `typeof 0.0` is `"number"`. **Fix.** Two coordinated changes. (1) `crates/perry-hir/src/ir.rs`: add a thread-local `COMPILE_PACKAGES_OVERRIDE: RefCell>` plus setter/clearer (`set_compile_packages_override` / `clear_compile_packages_override`). Update `is_native_module` to: parse the package name out of the path via a new `package_name_of` helper (handles `node:` prefix + `@scope/pkg/subpath` scoped form), then return false when the package is in the override set even if it appears in NATIVE_MODULES. (2) `crates/perry/src/commands/compile/collect_modules.rs`: wrap the `lower_module_full` call with `set_compile_packages_override(ctx.compile_packages.clone())` before and `clear_compile_packages_override()` after. The thread-local is rayon-safe (each worker thread has its own copy) and cleared promptly so it can't leak to unrelated work. Together: HIR's import lowering now sees `is_native = false` for compile-package-opted-in packages, `register_native_module` and `register_native_instance` don't fire, `expr_member::lower_member` falls through to the regular property-access path, and codegen's static-class fast path at `expr.rs:3742-3772` emits `js_class_method_bind` — a bound-method closure NaN-boxed with POINTER_TAG. **Validation.** rate-limiter-flexible probe: `typeof limiter.consume: function` ✓ (was "number"); `limiter.consume: [Function (anonymous)]` unchanged ✓; `limiter.points: 3` and `limiter.duration: 60` unchanged ✓. Full workspace test suite exit 0. **Scope.** Closes the last user-visible gap from the #665 investigation. With this commit, `import { RateLimiterMemory } from "rate-limiter-flexible"; new RateLimiterMemory(...)` produces an instance whose methods are reachable as function values — the original blocking shape from the issue's repro. **Out of scope.** Calling `await limiter.consume(...)` end-to-end has to go through the bound-method-closure dispatch via `js_native_call_method`'s CLASS_VTABLE_REGISTRY lookup; that path works for the drizzle and hono fixtures so it should work here, but I haven't probed it through the full Promise resolution chain. The thread-local-based approach is the pragmatic choice over threading `compile_packages` through every HIR lowering API; if perry ever drops single-threaded sections of HIR lowering, the locking semantics may need to be revisited. + ## v0.5.897 — fix(codegen,runtime): #665 — `setTimeout(fn, delay, ...args)` with trailing args no longer leaves a bare `_setTimeout` symbol at link time; trailing args are now forwarded to the callback. **Symptom.** With v0.5.895 (resolver opt-in) and v0.5.896 (default-import codegen collision) both applied, the rate-limiter-flexible compile reached the link stage cleanly, then failed with `Undefined symbols for architecture arm64: "_setTimeout", referenced from: _perry_closure_node_modules_rate_limiter_flexible_lib_RateLimiterMemory_js__1`. Discovering call site: `RateLimiterMemory.consume()` does `setTimeout(resolve, delay, res)` inside its Promise executor (per ECMA-262 §27.5.4.1, the trailing arg is forwarded to `resolve` so the Promise resolves with `res`). **Root cause.** `crates/perry-codegen/src/lower_call.rs:742` had a single `"setTimeout" if args.len() == 2` arm routing the 2-arg shape to `js_set_timeout_callback(callback, delay)`. Any other arity (including the spec-defined `setTimeout(fn, delay, ...args)` shape) fell through to the generic ExternFuncRef fallthrough, which emits `call double @setTimeout(...)` against a symbol nothing in stdlib defines. clang prefixes `_` on macOS → linker looks for `_setTimeout` → no such symbol. The 2-arg-only restriction had been the gate since the codegen arm was first written; until now, every real-world setTimeout call site Perry had been tested against used exactly 2 args. **Fix.** Three coordinated changes. (1) `crates/perry-runtime/src/timer.rs`: extend `CallbackTimer` with `args: Vec` (NaN-boxed JSValues), defaulting to empty for the 2-arg shape (back-compat). Add new runtime entry `js_set_timeout_callback_args(callback: i64, delay_ms: f64, args_ptr: *const f64, n_args: i32) -> i64` that copies the buffer into the timer record before returning. `js_callback_timer_tick` dispatches via `match timer.args.len()` to `js_closure_call0`..`js_closure_call8`, clamping at 9 (covers any realistic trailing-args count). (2) `crates/perry-codegen/src/runtime_decls.rs`: declare the new symbol `js_set_timeout_callback_args(I64, DOUBLE, PTR, I32) -> I64`. (3) `crates/perry-codegen/src/lower_call.rs`: add a second `"setTimeout" if args.len() >= 3` arm. Allocate a stack buffer of `n = args.len() - 2` doubles via `alloca_entry_array`, store each lowered trailing arg into its slot, GEP for the base pointer, and call `js_set_timeout_callback_args`. The existing 2-arg arm is untouched. **Validation.** rate-limiter-flexible now compiles, links, AND runs end-to-end. Standalone probe `setTimeout((a,b)=>log("fired:",a,b), 100, "hello", 42)` prints `fired: hello 42` byte-equal to Bun. Workspace tests green. **Scope.** Closes the link-stage blocker. **Not in scope.** Calling `await limiter.consume(...)` end-to-end through Promise resolution — bound-method dispatch via CLASS_VTABLE_REGISTRY works for drizzle/hono so should work here. Support for >9 trailing args to setTimeout (would need a variadic-dispatcher closure-call helper); real-world JS rarely exceeds 1-2 trailing args. **Why `_setTimeout` not `setTimeout`.** The macOS Mach-O ABI prefixes C-extern symbol names with `_` (`__USER_LABEL_PREFIX__`); the linker error reports the prefixed form. ## v0.5.896 — fix(codegen): #665 — multiple default-imported classes in the same file no longer mint phantom cross-class method symbols. **Symptom.** With v0.5.895's routing opt-in active and `perry.compilePackages: ["rate-limiter-flexible"]` set, compiling `import { RateLimiterMemory } from "rate-limiter-flexible"` failed with 5 leaf files emitting LLVM IR that referenced undefined symbols: `RateLimiterMemory.js` → `@perry_method_..._RateLimiterRes_js__RateLimiterRes__delete`; `RateLimiterMemcache.js` / `RateLimiterRedis.js` → `..._RateLimiterRes__get`; `RateLimiterCluster.js` → `..._RateLimiterRes__block`; `RateLimiterStoreAbstract.js` → `..._RateLimiterRes__delete`. None of these `RateLimiterRes__` symbols exist — `RateLimiterRes.js` only declares `_getDecoratedProperties`, `toString`, `toJSON`, and 4 getter/setter pairs; it has no `delete` / `get` / `set` / `block` method. The clang `-c` invocation for each of those 5 files failed with `error: use of undefined value '@perry_method_..._RateLimiterRes__delete'` and the build stopped before linking. **Root cause.** In `crates/perry-codegen/src/codegen.rs`'s `compile_module`, three side maps for imported classes (`class_ids`, `imported_class_prefix`, `imported_class_source_name`) used `.insert()` — last-writer-wins — while the parallel `class_table` used `.entry().or_insert()` — first-writer-wins. The inconsistency surfaces when two different classes are both imported via the default-export bridge in the same compilation unit: `compile.rs:3155` pushes a SECOND `ImportedClass` entry per default-imported class with `local_alias = Some("default")`. In a file that does `const RateLimiterRes = require("./RateLimiterRes")` AND `const MemoryStorage = require("./component/MemoryStorage/MemoryStorage")`, both entries land in `opts.imported_classes` with `local_alias = Some("default")` → both derive `effective_name = "default"`. `class_table["default"]` keeps the FIRST stub (MemoryStorage's, with `[incrby, set, get, delete]`); `class_ids["default"]` and `imported_class_prefix["default"]` and `imported_class_source_name["default"]` all take the LAST writer's values (RateLimiterRes's class id 16, RateLimiterRes_js source prefix, canonical name "RateLimiterRes"). When the method-registry builder iterates `class_table.values()` for `c.name == "default"`, it builds method symbols by combining the kept stub's method list (MemoryStorage's) with the side maps' canonical name + prefix (RateLimiterRes's): `method_names[("default", "set")] = "perry_method_..._RateLimiterRes_js__RateLimiterRes__set"`. Phase F (which iterates `opts.imported_classes` directly) only sees RateLimiterRes's real method list and never declares `RateLimiterRes__set`. The dispatch tower in `lower_call.rs:1899-1911` then emits a call to the undeclared symbol. **Fix.** Three sites in `crates/perry-codegen/src/codegen.rs` flipped from `.insert()` to `.entry().or_insert()`: (1) `class_ids` registration for `effective_name`; (2) `imported_class_prefix` registration; (3) `imported_class_source_name` registration. With these flipped, all four maps agree first-writer-wins, and `method_names[("default", "set")]` resolves to `MemoryStorage__set` (real symbol) rather than `RateLimiterRes__set` (phantom). **Why this hadn't surfaced before.** Requires two specific conditions: two or more different classes default-imported in the same compilation unit AND `this..()` shape firing the dispatch tower. Most user code with two default-imports doesn't also chain method calls through dynamic receivers; most files with that pattern only have one default-import. rate-limiter-flexible's leaf files hit both conditions simultaneously. **Validation.** rate-limiter-flexible: all 22 leaf .js files compile to .o cleanly under `perry compile`, including the 5 that previously failed. 152/0/0 `perry` crate tests pass; full workspace exit 0. **Scope.** Fixes only the side-map consistency bug. **Out of scope.** Not changing the `local_alias=Some("default")` synthesis at `compile.rs:3155`; that synthesis exists for the wrapper-emission loop's name-lookup contract, and the codegen-side consistency fix is narrower. diff --git a/CLAUDE.md b/CLAUDE.md index f4c26d83f8..1b5c9294d4 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.897 +**Current Version:** 0.5.898 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2d49f1b4c6..39519477c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4636,7 +4636,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "base64", @@ -4691,14 +4691,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.897" +version = "0.5.898" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "log", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-hir", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-hir", @@ -4728,7 +4728,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-dispatch", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-hir", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "base64", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-hir", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.897" +version = "0.5.898" dependencies = [ "serde", "serde_json", @@ -4776,7 +4776,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.897" +version = "0.5.898" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4787,7 +4787,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "clap", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.897" +version = "0.5.898" dependencies = [ "argon2", "perry-ffi", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "reqwest", @@ -4819,7 +4819,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.897" +version = "0.5.898" dependencies = [ "bcrypt", "perry-ffi", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "rusqlite", @@ -4835,7 +4835,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "scraper", @@ -4843,14 +4843,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.897" +version = "0.5.898" dependencies = [ "chrono", "cron", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.897" +version = "0.5.898" dependencies = [ "chrono", "perry-ffi", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "rust_decimal", @@ -4875,7 +4875,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "serde_json", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4891,21 +4891,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.897" +version = "0.5.898" dependencies = [ "bytes", "http-body-util", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lazy_static", "perry-ffi", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -4942,7 +4942,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.897" +version = "0.5.898" dependencies = [ "bytes", "http-body-util", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lazy_static", "perry-ffi", @@ -4971,7 +4971,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.897" +version = "0.5.898" dependencies = [ "base64", "jsonwebtoken", @@ -4982,7 +4982,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lru", "perry-ffi", @@ -4990,7 +4990,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.897" +version = "0.5.898" dependencies = [ "chrono", "perry-ffi", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.897" +version = "0.5.898" dependencies = [ "bson", "futures-util", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.897" +version = "0.5.898" dependencies = [ "chrono", "perry-ffi", @@ -5020,7 +5020,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.897" +version = "0.5.898" dependencies = [ "nanoid", "perry-ffi", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "rustls", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lettre", "perry-ffi", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "sqlx", @@ -5059,7 +5059,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.897" +version = "0.5.898" dependencies = [ "governor", "perry-ffi", @@ -5067,7 +5067,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.897" +version = "0.5.898" dependencies = [ "base64", "image", @@ -5076,14 +5076,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.897" +version = "0.5.898" dependencies = [ "lazy_static", "perry-ffi", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "uuid", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.897" +version = "0.5.898" dependencies = [ "perry-ffi", "regex", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.897" +version = "0.5.898" dependencies = [ "futures-util", "lazy_static", @@ -5120,7 +5120,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.897" +version = "0.5.898" dependencies = [ "flate2", "perry-ffi", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.897" +version = "0.5.898" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-api-manifest", @@ -5151,7 +5151,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "deno_core", @@ -5170,7 +5170,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-diagnostics", @@ -5182,7 +5182,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "base64", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.897" +version = "0.5.898" dependencies = [ "aes", "aes-gcm", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "perry-hir", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.897" +version = "0.5.898" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5292,11 +5292,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.897" +version = "0.5.898" [[package]] name = "perry-ui-android" -version = "0.5.897" +version = "0.5.898" dependencies = [ "itoa", "jni", @@ -5311,7 +5311,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.897" +version = "0.5.898" dependencies = [ "rand 0.8.6", "serde", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.897" +version = "0.5.898" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5340,7 +5340,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.897" +version = "0.5.898" dependencies = [ "block2", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.897" +version = "0.5.898" dependencies = [ "block2", "libc", @@ -5373,11 +5373,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.897" +version = "0.5.898" [[package]] name = "perry-ui-tvos" -version = "0.5.897" +version = "0.5.898" dependencies = [ "block2", "libc", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.897" +version = "0.5.898" dependencies = [ "block2", "libc", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.897" +version = "0.5.898" dependencies = [ "block2", "libc", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.897" +version = "0.5.898" dependencies = [ "libc", "perry-runtime", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.897" +version = "0.5.898" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 0122d1d24e..34e63534fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.897" +version = "0.5.898" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index a5dd80fa2b..b388ad6659 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -44,10 +44,78 @@ pub fn typed_array_kind_for_name(name: &str) -> Option { /// import from `perry_api_manifest` directly. pub const NATIVE_MODULES: &[&str] = perry_api_manifest::NATIVE_MODULES; -/// Check if a module path refers to a native stdlib module +thread_local! { + /// Refs #665: per-thread set of packages the user opted into via + /// `perry.compilePackages`. When non-empty, `is_native_module` returns + /// false for any path whose package name is in this set — so HIR + /// lowering treats the import as a regular ESM/CJS module (running + /// cjs_wrap, registering classes as imported rather than native), and + /// `obj.method` on a compile-package-overridden class lowers as a + /// real PropertyGet instead of a zero-arg `NativeMethodCall` (which + /// would have called the missing FFI getter and returned `0.0`). + /// + /// The compiler driver sets this thread-local before each + /// `lower_module_full` invocation and clears it after. Rayon's + /// thread pool gives each worker its own copy. + static COMPILE_PACKAGES_OVERRIDE: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashSet::new()); +} + +/// Set the per-thread override of which packages to treat as +/// non-native during HIR lowering. Called by the compiler driver before +/// each `lower_module_full` invocation. Refs #665. +pub fn set_compile_packages_override(set: std::collections::HashSet) { + COMPILE_PACKAGES_OVERRIDE.with(|cell| *cell.borrow_mut() = set); +} + +/// Clear the per-thread override. Refs #665. +pub fn clear_compile_packages_override() { + COMPILE_PACKAGES_OVERRIDE.with(|cell| cell.borrow_mut().clear()); +} + +/// Parse the package name out of an import specifier. Mirrors the +/// `parse_package_specifier` helper in `crates/perry/src/commands/compile/resolve.rs` +/// but lives here so `is_native_module` doesn't gain a perry-crate dep. +fn package_name_of(path: &str) -> &str { + let normalized = path.strip_prefix("node:").unwrap_or(path); + if let Some(stripped) = normalized.strip_prefix('@') { + // Scoped: `@scope/pkg/subpath` → `@scope/pkg` + let mut parts = stripped.splitn(3, '/'); + let scope = parts.next().unwrap_or(""); + let pkg = parts.next().unwrap_or(""); + if scope.is_empty() || pkg.is_empty() { + normalized + } else { + // Return a slice covering "@scope/pkg" from the original normalized + // string. Since `stripped = &normalized[1..]`, the scope segment + // ends at `1 + scope.len()` and "@scope/pkg" ends at + // `1 + scope.len() + 1 + pkg.len()`. + let end = 1 + scope.len() + 1 + pkg.len(); + &normalized[..end] + } + } else { + // Regular: `pkg/subpath` → `pkg` + normalized.split('/').next().unwrap_or(normalized) + } +} + +/// Check if a module path refers to a native stdlib module. +/// +/// Refs #665: when the user has opted the package into +/// `perry.compilePackages`, this returns false even for paths that +/// match the built-in NATIVE_MODULES manifest — the user's +/// `node_modules` copy will be compiled from source and HIR lowering +/// must not register the import as a native module (which would +/// cascade into `obj.prop` being lowered as a zero-arg FFI getter call +/// instead of a real PropertyGet → bound-method-closure). pub fn is_native_module(path: &str) -> bool { let normalized = path.strip_prefix("node:").unwrap_or(path); - NATIVE_MODULES.contains(&normalized) + if !NATIVE_MODULES.contains(&normalized) { + return false; + } + let pkg = package_name_of(path); + let overridden = COMPILE_PACKAGES_OVERRIDE.with(|cell| cell.borrow().contains(pkg)); + !overridden } /// Check if a module path refers to a native module, including external native libraries. diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 4cb6216bf2..ac4953d16e 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -216,7 +216,24 @@ pub(super) fn collect_modules( let is_external_module = !canonical.starts_with(&ctx.project_root) || canonical.to_string_lossy().contains("/node_modules/") || entry_path.to_string_lossy().contains("/node_modules/"); - let (mut hir_module, new_next_class_id) = perry_hir::lower_module_full( + // Refs #665: install per-thread override so HIR's `is_native_module` + // returns false for packages the user opted into via + // `perry.compilePackages`. Without this, the HIR lowering at + // `expr_member::lower_member` treats `obj.prop` on a registered + // native instance as a zero-arg FFI getter call (`NativeMethodCall { + // method, args: [] }`), which for compile-package-overridden classes + // routes through `js_native_call_method` and returns `0.0` — the + // bug Ralph hit as `typeof limiter.consume === "number"`. With the + // override in place, `is_native_module("rate-limiter-flexible")` + // returns false, the import is not registered as a native module, + // `limiter` is not tagged as a native instance, and `limiter.consume` + // lowers as a real `PropertyGet` → codegen's class-method-bind path + // synthesizes a `BOUND_METHOD_FUNC_PTR` closure. The thread-local + // is rayon-safe (each worker thread has its own copy) and cleared + // immediately after the lower call so it can't leak to subsequent + // unrelated work on the same thread. + perry_hir::set_compile_packages_override(ctx.compile_packages.clone()); + let lower_result = perry_hir::lower_module_full( ast_module, &module_name, &source_file_path, @@ -225,7 +242,9 @@ pub(super) fn collect_modules( imported_class_fields, is_entry_module, is_external_module, - )?; + ); + perry_hir::clear_compile_packages_override(); + let (mut hir_module, new_next_class_id) = lower_result?; *next_class_id = new_next_class_id; // Update the global class_id counter // Process imports and update their resolved paths and module kinds