This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
NOTE: Keep this file concise. Detailed changelogs live in CHANGELOG.md.
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.414
Tracked via the gap test suite (test-files/test_gap_*.ts, 28 tests). Compared byte-for-byte against node --experimental-strip-types. Run via /tmp/run_gap_tests.sh after cargo build --release -p perry-runtime -p perry-stdlib -p perry.
Last sweep (v0.5.388): 27/28 passing. typed_arrays flipped to pass after the b2b0a3e6 Uint8ClampedArray work. Only console_methods still fails — and only on the macos-14 CI runner; passes locally. Long-documented ci-env quirk (console.time value normalization edge case). Parity sweep: 167/167 (100%) with both v0.5.388 fixes landed for #302 (Map/Set on class field) + #154 (using/dispose SIGBUS). Run via /tmp/run_gap_tests.sh and ./run_parity_tests.sh after full rebuild.
Known categorical gaps: lookbehind regex (Rust regex crate), console.dir/console.group* formatting, lone surrogate handling (WTF-8).
IMPORTANT: Follow these practices for every code change made directly on main (maintainer workflow):
- Update CLAUDE.md: Add 1-2 line entry in "Recent Changes" for new features/fixes
- Increment Version: Bump patch version (e.g., 0.5.48 → 0.5.49)
- Commit Changes: Include code changes and CLAUDE.md updates together
PRs from outside contributors should not touch [workspace.package] version in Cargo.toml, the **Current Version:** line in CLAUDE.md, or add a "Recent Changes" entry. The maintainer bumps the version and writes the changelog entry at merge time — usually by rebasing the PR branch and amending. This avoids the patch-version collisions that happen when Perry's main ships several commits while a PR is in review (each on-main commit bumps the version; a PR that bumped to the same patch on day 1 is already behind by merge day). Contributors just write code; let the maintainer fold in the metadata last.
cargo build --release # Build all crates
cargo build --release -p perry-runtime -p perry-stdlib # Rebuild runtime (MUST rebuild stdlib too!)
cargo test --workspace --exclude perry-ui-ios # Run tests (exclude iOS on macOS host)
cargo run --release -- file.ts -o output && ./output # Compile and run TypeScript
cargo run --release -- file.ts --print-hir # Debug: print HIRTypeScript (.ts) → Parse (SWC) → AST → Lower → HIR → Transform → Codegen (LLVM) → .o → Link (cc) → Executable
| Crate | Purpose |
|---|---|
| perry | CLI driver (parallel module codegen via rayon) |
| perry-parser | SWC wrapper for TypeScript parsing |
| perry-types | Type system definitions |
| perry-hir | HIR data structures (ir.rs) and AST→HIR lowering (lower.rs) |
| perry-transform | IR passes (closure conversion, async lowering, inlining) |
| perry-codegen | LLVM-based native code generation |
| perry-runtime | Runtime: value.rs, object.rs, array.rs, string.rs, gc.rs, arena.rs, thread.rs |
| perry-stdlib | Node.js API support (mysql2, redis, fetch, fastify, ws, etc.) |
| perry-ui / perry-ui-macos / perry-ui-ios / perry-ui-tvos | Native UI (AppKit/UIKit) |
| perry-jsruntime | JavaScript interop via QuickJS |
Perry uses NaN-boxing to represent JavaScript values in 64 bits (perry-runtime/src/value.rs):
TAG_UNDEFINED = 0x7FFC_0000_0000_0001 BIGINT_TAG = 0x7FFA (lower 48 = ptr)
TAG_NULL = 0x7FFC_0000_0000_0002 POINTER_TAG = 0x7FFD (lower 48 = ptr)
TAG_FALSE = 0x7FFC_0000_0000_0003 INT32_TAG = 0x7FFE (lower 32 = int)
TAG_TRUE = 0x7FFC_0000_0000_0004 STRING_TAG = 0x7FFF (lower 48 = ptr)
Key functions: js_nanbox_string/pointer/bigint, js_nanbox_get_pointer, js_get_string_pointer_unified, js_jsvalue_to_string, js_is_truthy
Module-level variables: Strings stored as F64 (NaN-boxed), Arrays/Objects as I64 (raw pointers). Access via module_var_data_ids.
Generational mark-sweep GC in crates/perry-runtime/src/gc.rs (default since v0.5.237 / Phase D). Two regions in the per-thread arena: nursery (ARENA, fills with new allocations, swept on minor GC) and old-gen (OLD_ARENA, holds tenured/evacuated objects). Conservative stack scan + precise shadow-stack roots + 9 registered scanners. Write barriers populate a remembered set so minor GC can avoid retracing the old-gen. Two-bit aging (HAS_SURVIVED / TENURED) promotes nursery survivors after 2 minor cycles; the C4b evacuation pass moves non-pinned tenured objects into old-gen with full reference rewriting. Idle nursery blocks observed empty for 2 GC cycles are dealloc'd back to the OS (C4b-δ, v0.5.235), and the next-trigger calc is hard-capped at the initial threshold (64 MB) so >90%-freed step-doubling can't blow up peak occupancy (C4b-δ-tune, v0.5.236). Triggers on arena block allocation (1 MB blocks since v0.5.196), malloc count threshold, or explicit gc() call. 8-byte GcHeader per allocation.
Escape hatches: PERRY_GEN_GC=0/off/false reverts to full mark-sweep (bisection only). PERRY_GEN_GC_EVACUATE=1 enables the copying evacuation pass (default OFF — complete and correctness-safe but adds work that's a no-op on workloads where nothing tenures). PERRY_WRITE_BARRIERS=1 opts into codegen-emitted write barriers (default OFF — barrier emission has its own perf cost; the runtime barrier always exists). PERRY_GC_DIAG=1 prints per-cycle diagnostics.
Single-threaded by default. perry/thread provides:
parallelMap(array, fn)/parallelFilter(array, fn)— data-parallel across all coresspawn(fn)— background OS thread, returns Promise
Values cross threads via SerializedValue deep-copy. Each thread has independent arena + GC. Results from spawn flow back via PENDING_THREAD_RESULTS queue, drained during js_promise_run_microtasks().
Declarative TypeScript compiles to AppKit/UIKit calls. Handle-based widget system (1-based i64 handles, NaN-boxed with POINTER_TAG). --target ios-simulator/--target ios/--target tvos-simulator/--target tvos for cross-compilation.
To add a new widget — change 4 places:
- Runtime:
crates/perry-ui-macos/src/widgets/— create widget,register_widget(view) - FFI:
crates/perry-ui-macos/src/lib.rs—#[no_mangle] pub extern "C" fn perry_ui_<widget>_create - Codegen:
crates/perry-codegen/src/codegen.rs— declare extern + NativeMethodCall dispatch - HIR:
crates/perry-hir/src/lower.rs— only if widget has instance methods
Configured in package.json:
{ "perry": { "compilePackages": ["@noble/curves", "@noble/hashes"] } }First-resolved directory cached in compile_package_dirs; subsequent imports redirect to the same copy (dedup).
- No runtime type checking: Types erased at compile time.
typeofvia NaN-boxing tags.instanceofvia class ID chain. - No shared mutable state across threads: No
SharedArrayBufferorAtomics.
- Double NaN-boxing: If value is already F64, don't NaN-box again. Check
builder.func.dfg.value_type(val). - Wrong tag: Strings=STRING_TAG, objects=POINTER_TAG, BigInt=BIGINT_TAG.
as f64vsfrom_bits:u64 as f64is numeric conversion (WRONG). Usef64::from_bits(u64)to preserve bits.
- Loop counter optimization produces i32 — always convert before passing to f64/i64 functions
- Constructor parameters always f64 (NaN-boxed) at signature level
- Thread-local arenas: JSValues from tokio workers invalid on main thread
- Use
spawn_for_promise_deferred()— return raw Rust data, convert to JSValue on main thread - Async closures: Promise pointer (I64) must be NaN-boxed with POINTER_TAG before returning as F64
- ExternFuncRef values are NaN-boxed — use
js_nanbox_get_pointerto extract - Module init order: topological sort by import dependencies
- Optional params need
imported_func_param_countspropagation through re-exports
collect_local_refs_expr()must handle all expression types — catch-all silently skips refs- Captured string/pointer values must be NaN-boxed before storing, not raw bitcast
- Loop counter i32 values:
fcvt_from_sintto f64 before capture storage
- TWO systems:
HANDLE_METHOD_DISPATCH(methods) andHANDLE_PROPERTY_DISPATCH(properties) - Both must be registered. Small pointer detection: value < 0x100000 = handle.
define_class!with#[unsafe(super(NSObject))],msg_send!returnsRetaineddirectly- All AppKit constructors require
MainThreadMarker
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
- v0.5.414 — Closes #315:
String.prototype.startsWith(searchString, position)andendsWith(searchString, endPosition)(the standard 2-arg ES forms) were rejected at codegen withperry-codegen: String.startsWith expects 1 arg, got 2— surfaced compiling Effect'ssrc/String.ts(func 19) during the #309 compat sweep. The 1-arglower_string_method.rsarm hard-bailed onargs.len() != 1, andlower_call.rsonly routed Any-typed receivers to the string dispatcher whenargs.len() == 1. Fix in three places: (1) newjs_string_starts_with_at/js_string_ends_with_atincrates/perry-runtime/src/string.rsthat take a position i32 and use the existingis_ascii_string/utf16_offset_to_byte_offsethelpers (UTF-16 code-unit indexing per spec, position clamped to[0, len]). (2)runtime_decls.rsdeclares both new externs(I64, I64, I32) -> I32. (3)lower_string_method.rs:548widens the gate toargs.len() in 1..=2and dispatches the 2-arg form to the_atruntime variant viafptosi(DOUBLE → I32)on the position arg — matches theslice/substringpattern. (4)lower_call.rs:934widens the Any-typed-receiver routing gate fromargs.len() == 1toargs.len() == 1 || args.len() == 2forstartsWith / endsWithsince neither method exists on Array, so 2-arg dispatch is unambiguous. New regression testtest-files/test_issue_315_starts_with_position.tscovers 1-arg + 2-arg forms, position clamping (negative, beyond length), and multi-byte UTF-8 / UTF-16 indexing (αβγδε) — matchesnode --experimental-strip-typesbyte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); regression test matches Node byte-for-byte. Companion follow-up under umbrella #321 — flips Effect'sString.tsfrom "rejected at codegen" to "compiles cleanly". - v0.5.413 — Closes #324:
Array.isArray(value)constant-folded toTAG_TRUEfor any value statically typed as a Union with at least one Array variant — sofunction hook(value: number | readonly number[]) { if (Array.isArray(value)) { ... } else { ... } }always picked the array branch even when the runtime value was anumber. Surfaced on @codehz's ECS demo (same issue reporter as #313). Root cause: theExpr::ArrayIsArraylowering atcrates/perry-codegen/src/expr.rs:6851short-circuited to TAG_TRUE wheneveris_array_expr(ctx, o)returned true, but that helper is deliberately loose — it returns true if any variant of a Union isArray(_)/Tuple(_), which is correct for routing.length/.push/[i]dispatch onT[] | nullafter a truthy narrow (so(maybeArr || []).slice()still hits the array fast path), but wrong forArray.isArraywhich must reflect the actual runtime tag. The HIR confirms:function hook(value: number | readonly number[])lowered the parameter asUnion([Number, Array(Number)]),is_array_exprreturned true via the union arm, andArrayIsArray(LocalGet(0))constant-folded to TAG_TRUE before the runtime ever saw the integer 1. Fix in one place (expr.rs::Expr::ArrayIsArray): replace the looseis_array_expr(ctx, o)check with a strictmatches!(ty, Type::Array(_) | Type::Tuple(_))direct match againststatic_type_of(ctx, o). PureT[]/[T, U]types still constant-fold to TAG_TRUE; Union shapes (including theT[] | nullpost-narrow case) fall through to the existing runtimejs_array_is_arraydispatch which correctly inspects the NaN-box tag and the GC type. The FALSE side already correctly skipped Union (no match), so the only behavioral change is on the TRUE side. New regression testtest-files/test_issue_324_array_isarray_union.tscovers:number | readonly number[]parameter in if-guard with both numeric and array call shapes (the issue's exact repro),string | number[]ternary form,number[] | undefinedoptional form, and a control case verifying the fast path still fires on a definitively-Array parameter. Matchesnode --experimental-strip-typesbyte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); regression test matches Node byte-for-byte. - v0.5.412 — Closes #323:
const values = new Array(4); values[1]returned0instead ofundefined,1 in valuesand2 in values(aftervalues[2] = undefined) both returnedfalseregardless of presence, andObject.keys(values)happened to return[]only because uninitialized arena bytes read as zero (a freshjs_object_keyscall after the title-bug fix segfaulted dereferencing slot[1] as akeys_arraypointer). Found in user @codehz's ECS demo. Root cause layered four overlapping defects: (1)js_array_alloc_with_lengthincrates/perry-runtime/src/array.rsleft element bytes uninitialized — JS spec saysnew Array(n)slots are holes that read asundefined; (2)js_object_keysandjs_object_has_propertycast their argument to*const ObjectHeaderregardless of the actual GC type, so passing an array walked the slot bytes as ObjectHeader fields (object_type=length,keys_array=elements[1]); (3)Object.keys(arr)andn in arrhad no array-specific path — the former relied on the slot[1] zero-coincidence, the latter always returned false because the key-is-string guard rejected numeric keys; (4) the codegen's three inline IndexGet fast paths incrates/perry-codegen/src/expr.rs(bounded-index loop fast path, generic IndexGet, generic-Object numeric fallback) each emitted a rawload DOUBLE, ptratarr+8+idx*8— bypassingjs_array_get_f64's translation entirely. Fix across five files with a HOLE sentinel approach: (a)crates/perry-runtime/src/value.rsdefinespub(crate) const TAG_HOLE: u64 = 0x7FFC_0000_0000_0010(next free slot in the 0x7FFC singleton namespace, after UNDEFINED/NULL/FALSE/TRUE); (b)js_array_alloc_with_lengthinitializes every reachable slot (0..capacity, the requested length — slotscapacity..actual_capacityare unreachable through the bounds-checked accessor) to TAG_HOLE; (c)js_array_get_f64andjs_array_get_f64_uncheckedtranslate HOLE → UNDEFINED at the read site so the sentinel never leaks to user code; (d)js_object_keysdetects ArrayHeader byGcHeader::obj_type == GC_TYPE_ARRAY, walks the slot bytes, and emitsjs_string_new_ssodecimal indices for every non-HOLE slot — short-circuits before the ObjectHeader path; (e)js_object_has_propertydetects ArrayHeader by the same GC type byte, parses the key as a numeric index (accepting both NaN-boxed i32 and plain f64 integers in[0, length)), and returns true iffslot != TAG_HOLE; (f)crates/perry-codegen/src/nanbox.rsmirrorsTAG_HOLE_I64 = "9222246136947933200"for codegen use, with a tag-strings-match-u64 unit-test pin to catch any future drift; (g) all three inline IndexGet paths incrates/perry-codegen/src/expr.rsemit a branchlessbitcast double→i64+icmp eq TAG_HOLE_I64+selectafter the raw load, so user code readingarr[i]never observes the sentinel even on the hot inline path; (h)crates/perry-codegen/src/type_analysis.rs::refine_type_from_initaddsExpr::New { class_name: "Array", .. } => Some(HirType::Array(Box::new(HirType::Any)))soconst xs = new Array(n)(no annotation) gets refined to Array — without this, the local stays at typeAny,is_array_exprreturns false, andxs[i]falls through to the generic-Object numeric fallback path; (i)crates/perry-codegen/src/lower_call/builtin.rsupdates the misleading "(zero-initialized slots)" comment. New regression testtest-files/test_issue_323_array_holes.tscovers the issue's exact 11-line repro shape (length / hole-read /===/in/Object.keys/ explicit-undefined-write / post-write-in), the MIN_ARRAY_CAPACITY=16 padding boundary (size 20), the empty-array edge case (size 0), and a function-scopedforloop readingarr[i]over a freshnew Array(3)(exercises the bounded-index inline fast path's HOLE→UNDEFINED translation distinct from the generic IndexGet path). Matchesnode --experimental-strip-typesbyte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); parity 179/179 (100% on a clean re-run; one earlier run showed 2 flakytest_net_*failures from a stale TCP listener bind on port 8080 — not caused by this change); user's literal repro now matches Node byte-for-byte across all 11 lines. Caveat: the--no-cacheflag is required when re-compiling cached test files after this change because the build cache keys on source bytes, not type-analysis pass output — the cached.owas built before #323'sis_array_exprflip recognizednew Array(...)as Array. - v0.5.411 — Closes #319:
s = s + ton a closure-captured string-typed local aborted codegen withError compiling module ...: lowering closure func_id=N: lowering closure body func_id=N: string self-append: local M not in scope. Surfaced on Effect'sinternal/channel.ts(1500+ LOC) during the #309/#310/#318 follow-up sweep. Root cause: the string self-append fast path atcrates/perry-codegen/src/expr.rs::Expr::LocalSet(the optimization that turnslet s = ""; for (...) s = s + "a"from O(n²)js_string_concatallocations into O(n) in-placejs_string_appendmutations) only checkedlocal_types[id] == String && !module_globals.contains_key(id)before callinglower_string_self_append, which then requiredctx.locals.get(id)to return an alloca slot. Closure-captured locals don't have alloca slots — they live in the closure env and are stored viajs_closure_set_capture_f64— so the helper'sok_or_else(|| anyhow!("string self-append: local {} not in scope"))fired and aborted compilation. Boxed vars (heap cells viajs_box_set) had the same shape but the closure-capture case is what surfaces in real code (e.g.function makeAppender() { let s = ""; return function step(t) { s = s + t; return s; }; }). Fix in one place (crates/perry-codegen/src/expr.rs:810): extend the fast-path gate fromlocal_types == String && !module_globalsto also require!closure_captures.contains_key(id) && !boxed_vars.contains(id) && locals.contains_key(id)— same three predicates the regularLocalSetstore path uses to choose between closure-set / box-set / alloca-store / global-store dispatch. When any of those three predicates fail,LocalSetfalls through to the regular path which correctly handles all four storage types via the existingjs_closure_set_capture_f64/js_box_set/ alloca-store /@globalstore mechanics. The fast-path'sjs_string_appendin-place mutation is still preserved for plain (non-captured, non-boxed) string locals — the canonicallet s = ""; for (...) s = s + "a"build pattern that's the load-bearing optimization forbench_string_ops. New regression testtest-files/test_issue_319_string_self_append_capture.tscovers: closure-captured string self-append (the issue's exact shape), independent appender instances (verifies each closure has its own captured slot), self-append with non-string rhs (number coerced viajs_jsvalue_to_string— must hit the regular path's slow-path branch, not the fast path's bail), and plain (non-captured) string self-append (verifies the fast path still fires for the optimization-target case). Matchesnode --experimental-strip-typesbyte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); regression test matches Node byte-for-byte. Companion follow-up under umbrella #321 — flips one of the long-tail Effect codegen failures specifically called out in the issue (internal/channel.ts). - v0.5.410 — Closes #320: filed against v0.5.405 reporting
Undefined symbols: _js_readable_stream_new, _js_readable_stream_controller_{enqueue,close,error}at link time when compiling Effect'sStreammodule. The four FFI symbols (and the surrounding ReadableStream / WritableStream / TransformStream / reader / writer / pipe surface) are all already implemented incrates/perry-stdlib/src/streams.rs(1300+ lines, registered viapub mod streams; pub use streams::*;inlib.rs:80,82) — landed via #237/#301 (PR merged 2026-04-29 17:44 UTC, ~9h before #320 was filed and three on-main commits ahead of the v0.5.405 cut at the time of writing). #320's "Likely fix site" pointed atcrates/perry-codegen/src/lower_call.rsforjs_readable_stream_*(already wired across 14 sites at lines 3469-3584) and atcrates/perry-stdlib/src/streams.rs(already exists, 38#[no_mangle]extern "C" entries covering the issue's "adjacent gaps" —WritableStream,TransformStream,ReadableStreamDefaultReader,ReadableStreamDefaultController,pipeTo,pipeThrough,Response.body). Re-ran the issue's literal minimal repro (new ReadableStream({ start(controller) { controller.enqueue("hello"); controller.close(); } })) onmainHEAD → compiles + links + runs to exit 0. The cosmeticconsole.log(stream)rendering still prints1(raw handle id) instead ofReadableStream { locked: false, state: 'readable', supportsBYOB: false }— registry-id-as-f64 handle pattern (same asBLOB_REGISTRY/FETCH_RESPONSES) means the value is bit-indistinguishable from a number when console.log dispatches; that's a generic console-formatter dispatch concern across all handle-based registries, not a stream implementation gap, and is out of scope for #320 (which documents the link error specifically). Verification: all 3 existing #237 stream regression tests still matchnode --experimental-strip-typesbyte-for-byte (test_issue_237_streams_blob/_pipe/_user_source); new pintest-files/test_issue_320_readable_stream.tsexercises the issue's exact code shape (string-chunkcontroller.enqueue("hello")— distinct from the existing #237 tests which all useUint8Arraychunks), matches Node byte-for-byte forr1/r2/r3.{done,value}. Gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk). Companion follow-up under umbrella #321 — flips ReadableStream-related Effect modules from "compile fails at link" to "compiles + links cleanly". - v0.5.409 — Closes #314: indirect closure calls > 5 args were rejected at codegen with
perry-codegen Phase D.1: closure call with N args (max 5), blocking 5 Effect modules during the #309/#310/#318 follow-up sweep (metric/hook6-arg,sink6-arg,stm/tPubSub6-arg,stm/core8-arg,schedule10-arg). Ceiling was artificial — the runtime already exposesjs_closure_call0..js_closure_call16(crates/perry-runtime/src/closure.rs:187-727) andruntime_decls.rs:180-266already declares all 17 externs. The sibling fallthrough closure-call path atlower_call.rs:2076was already gated at<= 16; only the closure-typed-local LocalGet path atlower_call.rs:367still had the historical> 5bail from the original Phase D.1 slice. Fix: raise the ceiling to 16 and update the diagnostic message — one-line change. Both lowering sites now match the runtime contract and the existingruntime_declstable. New regression testtest-files/test_issue_314_closure_call_arity.tscovers 5/6/8/10/16-arg shapes (mirrors the issue's minimal repro plus all four Effect arities + the runtime upper bound) — matchesnode --experimental-strip-typesbyte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); parity 176/176 (100%, picks up the new test). Companion follow-up under umbrella #321 — flips 5 of 26 post-#309/#310 Effect codegen failures (~19%). - v0.5.408 — Closes #318: post-#309/#310 Effect compile tripped
clang: error: use of undefined value '@perry_closure_node_modules_effect_src_<MODULE>_ts__<N>'on 17/26 failing modules — same recurring walker-bug class as v0.5.323 / v0.5.388 / v0.5.396 / v0.5.405. Root cause:crates/perry-codegen/src/collectors.rs::collect_closures_in_exprcarried its own ad-hoc HIR descent with an_ => {}catch-all, and anyExprvariant added since the last sweep that wasn't enumerated silently dropped its sub-expressions — so closures buried insideExpr::RegExpReplaceFn { callback },Expr::NetCreateServer { connection_listener }, theExpr::Proxy*/Expr::Reflect*/Expr::Buffer*/Expr::Date*/Expr::Math{Sin,Cos,Log,…}/Expr::PropertyUpdate/Expr::IndexUpdate/Expr::String{Match,Replace,Split,…}families and ~80 others got registered nowhere. Codegen later emittedjs_closure_alloc(@perry_closure_*)references at the closure-creation sites, but the bodies were never compiled into LLVM functions, so clang flagged them as undefined. Fix in one place: rewritecollect_closures_in_exprto delegate descent toperry_hir::walker::walk_expr_children— the centralised exhaustive walker introduced in v0.5.329 (Tier 1.1) for the four other consumers (substitute_locals,find_max_local_id,collect_local_refs_expr,remap_local_ids_in_expr). The new body has exactly two responsibilities: wheneitself isExpr::Closure, register the func_id and recurse into the body (Vec<Stmt>, which the walker doesn't visit per its module docs); for every variant includingClosure, askwalk_expr_childrento call us back on each direct sub-Expr. New HIR variants now produce a compile error inwalker.rsuntil they're enumerated there, closing the bug class permanently. Replaces ~570 lines of ad-hoc match arms (every variant + a_ => {}foot-gun) with ~36 lines that can't drift. New regression testtest-files/test_issue_318_closure_collector_walker.tsexercises closures nested insideMath.sin/Math.cos/Math.log2operands (representative of the >80 missed variants — old arm list hadMathSqrt/Floor/Ceil/Round/Abs/MinSpread/MaxSpreadbut not the trig/log family). Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); parity 175/175 (100%); regression test matchesnode --experimental-strip-typesbyte-for-byte (0.9975 / 0.0707 / 3.0000). Companion follow-up under umbrella #321 (Effect end-to-end compat) — this fix flips the largest single bucket of post-#309/#310 codegen failures (17/26 modules, ~65%). - v0.5.407 — Closes #313:
class Holder { v = 10; s: Store; constructor() { const self = this; this.s = new Store((x) => x + self.v); } }printedself.v: undefinedand the symptom-2 sibling SIGSEGV'd. Root cause: scalar replacement (collectors.rs:collect_non_escaping_news→ stmt.rs:264-343) was rewritinglet h = new Holder()into per-field stack allocas and inlining the ctor body with a dummythis_stackslot that's never populated — the comment at stmt.rs:316 explicitly notes "scalar-replaced PropertySet intercepts it before loading," and that's true forthis.field = …andthis.field(intercepted at expr.rs:2738/2851), but not for any other shape.const self = thislowered toStmt::Let { init: Expr::This }whose Expr::This handler at expr.rs:3588 reads fromthis_stack.last()→ loads the dummy alloca → produces TAG_UNDEFINED →self.vreturns undefined; the symptom-2 inline-arrow withcaptures_this:truehad its closure env'sthisslot patched byapply_field_initializers_recursive(lower_call.rs:2660-2665) using the same dummythis_stackvalue, so the captured this was TAG_UNDEFINED —this.vthen dereferenced an unboxed pointer of0x0001and SIGSEGV'd. Bug is a silent-correctness regression on Symptom 1 (no diagnostics, exit 0) — found in user @codehz's ECS library demo. Fix in one place: new "Pass 3" incollect_non_escaping_news(collectors.rs) that walks each candidate class'sconstructor.body+ every instance-fieldinit(own + parent chain) and marks the candidate as escaped whenExpr::Thisappears outside of(PropertyGet|PropertySet|PropertyUpdate).objectwith a known field property, when anExpr::Closure { captures_this: true }appears (its env storesthisat construction), or whenExpr::SuperCall/Expr::SuperMethodCallappear (implicitthis). Three new helpersclass_uses_this_as_value/stmts_use_this_as_value/expr_uses_this_as_valueenumerate the safe HIR variants explicitly and use_ => truefor unknowns — strictly conservative, only loses the optimization on patterns we haven't enumerated. Method/getter property names likethis.method()correctly route to "unsafe" via thefields.contains(property)check, since they materializethisas the receiver passed to method dispatch in lower_call.rs:1465. New regression testtest-files/test_issue_313_arrow_stored_in_field.tscovers both symptoms + chainedh.s.fn(7)direct-closure dispatch — matchesnode --experimental-strip-typesbyte-for-byte. Verified: clean rebuild; gap tests 27/28 = baseline (sameconsole_methodsci-env quirk); parity 173/173 (100%) — no regression. - v0.5.406 — Closes #210 (last 1 of 5): wires the Windows
widget.shadowpaint pass via a parent-windowWM_PAINTsubclass that renders the shadow onto a 32bppCreateDIBSectionandAlphaBlends onto the parent's surface (Win32 children clip painting to their bounds, so shadows must come from the parent — same pattern as the v0.5.347 border subclass but installed on parent). Per-pixel quadratic Gaussian-approx falloffalpha = base * (1 - d/blur)^2. Newapply_shadow(handle)companion toapply_corner_radius, called from the same 4 layout sites;set_shadowauto-calls it post-store. Bounds-clamped (blur ≤ 64px, |offset| ≤ 256px). Styling matrix flipped Stub → Wired — every Apple platform + Android + GTK4 + Web + Windows now reads 43/43 Wired for the first time. Visual fidelity is approximate vs CSSbox-shadow(stepped quadratic, no GPU); true Gaussian via DirectComposition (IDCompositionVisual+DropShadowEffect) is a separate refactor with identical API contract. Verified on Windows 11 host: cargo build clean; matrix drift test 2/2; matrix CLI --check clean (43 × 8);shadow.tssmoke compile → 0.8 MB binary exit 0;visual_test.ts(13-section) → 0.9 MB binary exit 0. - v0.5.405 — Closes #310:
export * as Foo from "./Foo"(ES2020 namespace re-export) was silently dropped at HIR lowering —ExportNamed'sif let ExportSpecifier::Namedfilter atcrates/perry-hir/src/lower.rs:4047only matched the regularNamedshape; SWC'sExportSpecifier::Namespacevariant fell through and the re-exported file never entered the module graph. The consumer'simport { Foo } from "pkg"then resolved to a stale binding and everyFoo.<member>access lowered to0— silent-correctness bug, exit 0, no diagnostics. Surfaced on Effect (effect/src/index.ts:229doesexport * as Effect from "./Effect.js") whereEffect.runSync(Effect.succeed(42))returned0instead of42. Fix in four parts: (1) newExport::NamespaceReExport { source, name }HIR variant incrates/perry-hir/src/ir.rs. (2)ExportNamedlowering rewritten fromif let Namedto a fullmatchonExportSpecifier::{Named,Namespace,Default}so the namespace specifier produces the new variant; the never-standardisedDefaultform ("export v from 'mod'") is silently ignored. (3)collect_modules(crates/perry/src/commands/compile/collect_modules.rs) and the topo-sort dep-walk incrates/perry/src/commands/compile.rsboth extend theirExport::*source-extraction match withNamespaceReExport => Some(source)so the target file enters the module graph and its init runs before the re-exporter's. (4) Per-import-spec consumer-side dispatch incompile.rs: when a Named import'sexported_namematches aNamespaceReExport { name }in the source module's HIR exports, resolve the namespace target relative to the source's directory, addlocal_nametonamespace_imports, and register every export of the target file inimport_function_prefixes/imported_classes/imported_param_counts/imported_enums— same machineryimport * as Foo from "pkg/Foo"already uses. (5) Codegen-side companion atcrates/perry-codegen/src/expr.rs::Expr::StaticMethodCall: the existing HIR rule "uppercase imported Ident followed by.<method>(...)lifts to StaticMethodCall" interceptsFoo.succeed(42)before the namespace path can fire, so the methods-table miss now falls through to a newnamespace_imports.contains(class_name)arm that emitsperry_fn_<source_prefix>__<method_name>directly — same symbol the explicitimport * as Fooform would have produced. New regression testtest-files/test_issue_310_namespace_reexport.ts+ 3-file fixture undertest-files/fixtures/issue_310_pkg/(re-exporterindex.tsdoesexport * as Foo from "./Foo.ts"; export * as Bar from "./Bar.ts") covers single-arg / multi-arg / chained / cross-namespace / nested-expression dispatch — matchesnode --experimental-strip-typesbyte-for-byte. Out of scope (follow-ups): (a) transitiveexport * from "./pkg-b"propagation of namespace re-exports throughall_module_exportschains — pre-fix isn't tested and isn't part of #310's repro shape. (b) The pre-existing[object Object]bug for plain re-exports of string constants (export { tag } from "./Foo.ts"wheretagis a string) — surfaced during the test development but is orthogonal to namespace re-exports and exists onmainwithout #310. (c) End-to-end on the actualeffectnpm package needs #309's OOM fix (already shipped in v0.5.403) plus this one to fully renderEffect.runSync(Effect.succeed(42))to "42" on the user's literal repro — separate verification step. Verified: cargo build --release clean; gap tests 27/28 = baseline (lone fail is pre-existingconsole_methodsci-env quirk); existing #311 regression test still matches Node byte-for-byte. - v0.5.404 — Closes #242: visionOS now registers the full geisterhand fn-pointer block (state_set / screenshot / textfield / scroll / read_value / query_tree / apply_style) — Phase D is fully cross-platform on every Apple target Perry supports (macOS / iOS / tvOS / visionOS).
- v0.5.403 — Closes #309:
perry compileof a 4-line program importing theeffectpackage OOM'd at 34 GB RSS / 249 GB peak virtual before being SIGKILL'd ~7 minutes in, during theGenerating code...phase. - v0.5.402 — Closes #311:
for...ofon a Map/Set held as a property of a plain object literal OR a class instance silently iterated zero times. - v0.5.401 — HarmonyOS Phase 2 v1.5: full widget set in
perry-codegen-arkts. - v0.5.400 — Closes #303: opt-in Win7 / Win8 / 8.1 compatibility for compiled executables via new
--min-windows-version=7|8|10CLI flag (default10). - v0.5.399 — HarmonyOS Phase 2 v1: TS→ArkUI emission via new
perry-codegen-arktscrate. - v0.5.398 — Closes #307:
JSON.stringify(parseResult)returned the literal string"null"for objects with ≥9 fields, silently corrupting any program that round-trips JSON (perry-hub built every CI build'sjob_assignmanifest asnullfor two days before the user pinned it down). - v0.5.397 — Closes #304 + #305 (two small wins from real-world repros).
- v0.5.396 — Closes #245 Phase 2: workspace-wide
cargo clippy --fixauto-correction sweep. - v0.5.395 — Closes the loop on #229: sign-side CLI tool for v2 manifest signatures.
- v0.5.394 — v0.5.393 cargo-test follow-up: also exclude
perry-jsruntimefromcargo test --workspaceon ubuntu-latest. - v0.5.393 — Release-night reliability + v0.5.392 follow-up.
- v0.5.392 — CI cost reduction: move
cargo-test,parity, andcompile-smokejobs frommacos-14(10× billing weight) →ubuntu-latest(1×) in.github/workflows/test.yml. - v0.5.391 — Closes #229: version-binding into the perry-updater signed payload (Option A from the design comment).
- v0.5.390 — Closes #228: enforce HTTPS on
@perry/updater's manifest + asset URLs. - v0.5.389 — Closes #300 (Windows SDK auto-discovery) + cosmetic cleanup for #266.
- v0.5.388 — Two real bug fixes that flipped the last 2 entries from
known_failures.jsonto passing — Perry's parity rate is now 100% (167/167) and gap tests are 27/28 (onlyconsole_methodsremains, a long-documented ci-env quirk). - v0.5.387 — CI overhaul (post-mortem from v0.5.386's painful release night).
- v0.5.386 — Hotfix: v0.5.385's new HIR arm for
module.Class.staticMethod()over-fired and brokefs.promises.readFile()(and likelyfs.promises.writeFile/mkdir/access/...+fs.constants.X+path.posix.X+path.win32.X). - v0.5.385 — Closes #278 (PR #299, manually rebased + landed): wire codegen dispatch for the 3 perry/system + ethers holdouts that #278 documented.
- v0.5.384 — CI hotfix-of-hotfix: with v0.5.383's nullglob fix actually landed, the compile-smoke run on 0bdccbd4 successfully printed
Compile smoke: 183 passed, 1 failed, 4 skipped— and the lone failure surfaced a separate race condition I introduced via parallelization (NJOBS=6 was… - v0.5.383 — CI hotfix: my v0.5.381 nullglob fix for the compile-smoke errexit bug never actually landed in 72d17ff8 — the diff hunk that replaced
PASS=$(ls -1 *.pass | wc -l)withshopt -s nullglob; pass_files=("$LOGS_DIR"/*.pass); PASS=${#pass_files[@]}got separated from the… - v0.5.382 — Closes #291: three independent SIGSEGV bug shapes from the same issue, fixed across 4 files.
Older entries → CHANGELOG.md.