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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Detailed changelog for Perry. See CLAUDE.md for concise summaries.

## v0.5.919 — fix(codegen): #678 — V8-fallback callsites now route through the V8 bridge. Builds on #785's re-export-rename suffix fix with the second half of the linker-error story: when an import resolves to a `ModuleKind::Interpreted` module (the V8 fallback — e.g. a `.js` file outside `perry.compilePackages`, or ink-style packages where a transitive dep like yoga-layout couldn't compile natively), no `perry_fn_<src>__<name>` symbol exists for the linker to bind to. The CLI driver was silently dropping V8-routed imports from `import_function_prefixes` (the `if import.module_kind != NativeCompiled { continue }` guard at `crates/perry/src/commands/compile.rs:3409`), so codegen emitted bare extern callsites that the linker rejected with `Undefined symbols: _perry_fn_..._render` — exactly the failure shape #678 reports for `import { render } from "ink"` in the ink-on-yoga repro. **Mechanism.** Three coordinated edits mirroring #785's diff template: (1) **Runtime bridge** (`crates/perry-jsruntime/src/interop.rs`): new `js_call_v8_export(specifier_ptr, specifier_len, export_name_ptr, export_name_len, args_ptr, args_len) -> f64` bundles the existing `js_load_module` + `js_call_function` pair into a single FFI entry. Specifier and export name use the same `(ptr, len)` convention as the surrounding APIs (zero len = null-terminated C string); args carry already-NaN-boxed Perry doubles; result is also NaN-boxed. Module-handle is cached by deno_core's loader so repeated calls to the same specifier don't re-evaluate. Returns NaN-boxed `undefined` on load failure (V8 exceptions are surfaced through the existing `call_function_impl` TryCatch path, same as `js_call_function`). (2) **CompileOptions / FnCtx plumbing** (`crates/perry-codegen/src/codegen.rs` + `expr.rs`): new sparse `import_function_v8_specifiers: HashMap<String, String>` (consumer-name → module specifier) parallels `import_function_origin_names` from #785. Propagated through `CrossModuleCtx` and every `FnCtx` construction site (6 in `codegen.rs`). New `emit_v8_export_call` helper in `expr.rs` materializes per-call-site rodata constants for the specifier + export name (linker merges duplicates across translation units via `private unnamed_addr constant`), stack-allocates an f64 args buffer via `alloca [N x double]`, and emits the `js_call_v8_export` call returning the NaN-boxed result. Lifetimes are clean because the rodata strings live in `.rodata` and the args alloca is per-call-site. (3) **Codegen dispatch** routes V8 imports through the bridge at every extern-construction site. Three sites in `lower_call.rs` — the namespace-dispatch path (~:570: `import * as ns; ns.foo(args)`), the direct extern call path (~:1094: `import { foo }; foo(args)`), and the fallback at ~:1066 (the bare-extern path used for native FFI; V8-routed names that lack `import_function_prefixes` entries hit this and would have linked against the bare name). Two sites in `expr.rs` — the namespace member resolved as `StaticMethodCall` (~:6100: `Foo.MyFn(...)` with uppercase namespace member, which HIR lifts to `StaticMethodCall` and `transform_js_imports` doesn't cover) and the ExternFuncRef-as-value path (~:11205: `let f = render; f(x)`; V8 imports return NaN-boxed `undefined` so a subsequent indirect call fast-paths through the closure-magic check instead of dereferencing a missing wrapper). One site in `codegen.rs` — the FuncRef-as-value wrapper emission loop (~:2705) treats V8 imports the same as imported classes: emits a no-op `__perry_wrap_extern_<v8_prefix>__<name>` that returns `undefined` so direct callbacks of `render`-as-value still link. (4) **CLI driver population** (`crates/perry/src/commands/compile.rs`): new V8-imports loop runs *after* the existing native-imports loop ends. For each `ModuleKind::Interpreted` import on the consumer, the loop reads the import's resolved canonical path (or falls back to the source specifier — matches the `JsModule.specifier` key in `ctx.js_modules`), builds a synthetic `__v8__<sanitized_path>` prefix, and registers `(local_name → synthetic_prefix)` into `import_function_prefixes` plus `(local_name → specifier)` into `import_function_v8_specifiers` for Named and Default specifiers. The synthetic prefix never reaches a `perry_fn_<src>__<name>` symbol because every codegen site probes `import_function_v8_specifiers` first and short-circuits to `emit_v8_export_call`. Namespace specifiers (`import * as X from "v8mod"`) fall through to the existing unresolved-namespace runtime stub on pure value reads — programs that mix `import * as X` with `import { Named } from "v8mod"` get full coverage via the Named branch. (5) **Object cache key** (`crates/perry/src/commands/compile/object_cache.rs`): includes the new `import_function_v8_specifiers` map alphabetized into the cache hash, so two builds where the same module imports the same names but the upstream package flipped between native and V8 fallback don't share a cached `.o`. **Defense-in-depth note.** Perry's existing `transform_js_imports` HIR pass (`crates/perry-hir/src/js_transform.rs`) already rewrites the most common `Call { callee: ExternFuncRef }` / `PropertyGet { ExternFuncRef }` / `ExternFuncRef`-as-value shapes for V8 imports into `JsCallFunction` / `JsCallMethod` / `JsGetExport` HIR variants — these pre-empt the codegen-level bridge for >95% of real-world V8 import sites. The codegen-level bridge added here is **fallback coverage** for shapes the HIR pass misses (`StaticMethodCall`-lifted uppercase namespace members, exotic re-export chains, future demotion paths that would land an import with `module_kind = NativeCompiled` against a module that actually went to `js_modules`) and for forward-compatibility with the planned "demote on native compile failure" path described in the issue body. Both layers coexist; nothing breaks for sites the HIR pass already covers. **Validation.** New regression fixture `test-files/test_issue_678_v8_fallback.ts` + `test-files/fixtures/issue_678_v8/mod.js` — TS entry imports two named exports (`greet`, `add`) from a `.js` module outside `compilePackages`, byte-for-byte parity with `node --experimental-strip-types`. Verified pre-fix shape via a standalone repro at `/tmp/ink_repro/`: a TS entry calling `greet("ink-repro")` from a sibling `.js` module compiles, links, and runs cleanly post-fix (the existing HIR rewriter handles this happy path; the codegen bridge stays defensive). `cargo test --release --workspace` (excluding cross-host UI crates per CLAUDE.md) green; `cargo test --release -p perry-codegen` 29/0/0; `cargo test --release -p perry` 223/0/0. `/tmp/run_gap_tests.sh` 34/36 (same two pre-existing failures as v0.5.913 — `test_gap_console_methods`, `test_gap_regexp_advanced`). **Files touched.** `crates/perry-jsruntime/src/interop.rs` (new `js_call_v8_export`), `crates/perry-codegen/src/codegen.rs` (CompileOptions + CrossModuleCtx field + 6 FnCtx threading sites + wrapper-loop V8 branch), `crates/perry-codegen/src/expr.rs` (FnCtx field + `emit_v8_export_call` helper + 3 V8-bridge dispatch sites: StaticMethodCall namespace member, ExternFuncRef-as-value, PropertyGet-on-namespace value read), `crates/perry-codegen/src/lower_call.rs` (2 V8-bridge dispatch sites: namespace dispatch, direct extern call), `crates/perry/src/commands/compile.rs` (V8 imports loop after native loop + CompileOptions field), `crates/perry/src/commands/compile/object_cache.rs` (cache key field + test fixture). **Refs.** Refs #678. Builds on #785 (re-export-rename suffix). Forward-compat with the planned "demote on native compile failure" path per the issue body — that path would set `import.module_kind = Interpreted` for a module that previously got NativeCompiled, and this codegen change makes the link succeed at that flip without further work in the codegen.

## v0.5.918 — fix(runtime): #833 — `str.replace(/.../g, fn)` no longer drops short (≤5-byte) replacer-fn return values; SSO results now thread through to the output string. **Symptom (reported as #833, surfaced by `test_gap_regexp_advanced.ts` under the #801 feature-matrix runner).** The canonical idiom `"hello world foo".replace(/(\w+)/g, m => m.charAt(0).toUpperCase() + m.slice(1))` returned `" "` (just the inter-word whitespace) under Perry instead of node's `"Hello World Foo"`. Every match was replaced with the empty string. Narrowing showed it wasn't every replacer — `(m) => "Y"`, `(m) => m`, `(m) => m.toUpperCase()`, `(m) => m + "!"` all worked; only outputs whose total byte length was ≤ 5 dropped (`m.charAt(0) + m.slice(1)` on a 5-byte input is exactly the boundary, which is why "hello"/"world"/"foo" all hit it). **Root cause.** `crates/perry-runtime/src/regex.rs::js_string_replace_regex_fn` decoded the closure's NaN-boxed return value with a hard-coded tag switch — it accepted `STRING_TAG` (0x7FFF, heap `*const StringHeader`) and `POINTER_TAG` (0x7FFD), but **not** `SHORT_STRING_TAG` (0x7FF9, Perry's small-string optimization where ≤ 5-byte strings live inline in the NaN-box payload). When `js_string_concat_box`'s SSO fast path saw a concat result that fit in 5 bytes, it returned the SSO-tagged form; the regex-replace handler fell through both `if tag == 0x7FFF` and `if tag == 0x7FFD`, pushed nothing to `result`, and the match was effectively erased. **Fix.** Route the closure return value through `crates/perry-runtime/src/value.rs::js_get_string_pointer_unified`, which already centrally handles all four string representations (heap `STRING_TAG`, SSO `SHORT_STRING_TAG` with on-the-fly heap-materialization, `POINTER_TAG`, raw heap pointer) plus the JS-spec number-to-string coercion for numeric returns. This is the same unified helper every other "decode a JSValue → string pointer" call site in the runtime uses; the regex-replace handler was the lone holdout still doing manual tag dispatch. Net change: 16 lines of branchy tag arithmetic replaced with 4 lines that delegate to the existing centralized decoder, eliminating the SSO blind spot by construction (the codepath physically cannot omit a string variant if it doesn't list them itself). **Validation.** Issue repro byte-identical to `node --experimental-strip-types`: `[Hello World Foo] / [aYbYc] / [aZcaZc] 1,4`. Diagnostic matrix — `g` (chained `.charAt(0).toUpperCase()` → "H"), `h` (`.slice(1)` → "ello"), `i` (`.toUpperCase() + .slice(1)` → "HELLOello", heap path), `j` (`.charAt(0) + .slice(1)` → "hello", SSO path, was broken), `k` (`.charAt(0).toUpperCase() + "x"` → "Hx", SSO path, was broken) — all byte-identical to node. Gap suite improves from 34/36 to **35/36**: `test_gap_regexp_advanced` (the test that surfaced the bug) flips from FAIL to PASS; only `test_gap_console_methods` remains as the unchanged pre-existing console.dir/group formatting gap. `cargo test --release -p perry-runtime --lib regex::` green (6/0/0). Bonus correctness improvement: replacer functions returning numbers now coerce to their string form via the unified decoder's number-to-string arm (was: empty replacement). Closes #833. Refs #793, #801.

## v0.5.917 — fix(hir): #832 — class decorator that returns the target class (identity return) is now accepted as a no-op; only genuine class-replacement returns still throw. **Symptom (reported as #832, surfaced by the #801 feature-matrix probe `test_feat_decorators.ts`).** The trivial identity decorator pattern — `function logged(target: any, ctx?: any): any { return target; } @logged class Greeter { ... }` — threw `TypeError: Class decorator @logged on Greeter returned a value. Perry does not install decorator return values as class replacements ...`. Real-world decorators (TypeORM `@Entity`, NestJS decorators, GraphQL resolver wrappers, and any decorator factory that hasn't been simplified for production) routinely end with `return target` / `return descriptor` as a benign no-op; this overly-strict gate blocked them. **Root cause.** `crates/perry-hir/src/lower.rs::append_decorator_invocations_inner` (added on PR #754 to surface the silent class-replacement-not-installed case) emitted `if (__perry_dec_ret !== undefined) throw TypeError(...)` — strictly any non-undefined return triggered the throw, including the identity case where the decorator received the class and returned the same class. **Fix.** Augment the condition to also accept identity returns: `if (__perry_dec_ret !== undefined && __perry_dec_ret !== target) throw ...`. The `target` reference is taken from `invocation_args.first()`, which is the `Expr::ClassRef(class.name)` that the caller (`append_class_decorator_invocations`) passes as the decorator's first argument — so the comparison is between the decorator's return value and the original class reference it received. Falls back to the original strict-undefined check if `invocation_args` is unexpectedly empty (defensive — should not happen for class decorators). Stage-3-style genuine class replacement (`return SomeOtherClass`) still throws with the same actionable message — only the no-op identity case is now silent, which matches what most decorator authors actually mean. **Validation.** Issue repro now prints `Hello, world` (the class instantiation + method dispatch work end-to-end through the identity-decorator); pre-fix it threw before the `new Greeter("world")` line. Regression test (`@replaceClass` that returns a *different* function) still correctly throws the TypeError, so the original #754 safety guarantee is preserved for the actual class-replacement case. Gap suite 34/36 — same as before (the two failures are the pre-existing known gaps `test_gap_console_methods` and `test_gap_regexp_advanced` / #833, both unrelated). Closes #832. Refs #793, #801, #754.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.

**Current Version:** 0.5.918
**Current Version:** 0.5.919


## TypeScript Parity Status
Expand Down
Loading
Loading