Skip to content

Latest commit

 

History

History
187 lines (137 loc) · 38.3 KB

File metadata and controls

187 lines (137 loc) · 38.3 KB

CLAUDE.md

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.

Project Overview

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

TypeScript Parity Status

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).

Workflow Requirements

IMPORTANT: Follow these practices for every code change made directly on main (maintainer workflow):

  1. Update CLAUDE.md: Add 1-2 line entry in "Recent Changes" for new features/fixes
  2. Increment Version: Bump patch version (e.g., 0.5.48 → 0.5.49)
  3. Commit Changes: Include code changes and CLAUDE.md updates together

External contributor PRs

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.

Build Commands

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 HIR

Architecture

TypeScript (.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

NaN-Boxing

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.

Garbage Collection

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.

Threading (perry/thread)

Single-threaded by default. perry/thread provides:

  • parallelMap(array, fn) / parallelFilter(array, fn) — data-parallel across all cores
  • spawn(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().

Native UI (perry/ui)

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:

  1. Runtime: crates/perry-ui-macos/src/widgets/ — create widget, register_widget(view)
  2. FFI: crates/perry-ui-macos/src/lib.rs#[no_mangle] pub extern "C" fn perry_ui_<widget>_create
  3. Codegen: crates/perry-codegen/src/codegen.rs — declare extern + NativeMethodCall dispatch
  4. HIR: crates/perry-hir/src/lower.rs — only if widget has instance methods

Compiling npm Packages Natively (perry.compilePackages)

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).

Known Limitations

  • No runtime type checking: Types erased at compile time. typeof via NaN-boxing tags. instanceof via class ID chain.
  • No shared mutable state across threads: No SharedArrayBuffer or Atomics.

Common Pitfalls & Patterns

NaN-Boxing Mistakes

  • 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 f64 vs from_bits: u64 as f64 is numeric conversion (WRONG). Use f64::from_bits(u64) to preserve bits.

LLVM Type Mismatches

  • Loop counter optimization produces i32 — always convert before passing to f64/i64 functions
  • Constructor parameters always f64 (NaN-boxed) at signature level

Async / Threading

  • 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

Cross-Module Issues

  • ExternFuncRef values are NaN-boxed — use js_nanbox_get_pointer to extract
  • Module init order: topological sort by import dependencies
  • Optional params need imported_func_param_counts propagation through re-exports

Closure Captures

  • 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_sint to f64 before capture storage

Handle-Based Dispatch

  • TWO systems: HANDLE_METHOD_DISPATCH (methods) and HANDLE_PROPERTY_DISPATCH (properties)
  • Both must be registered. Small pointer detection: value < 0x100000 = handle.

objc2 v0.6 API

  • define_class! with #[unsafe(super(NSObject))], msg_send! returns Retained directly
  • All AppKit constructors require MainThreadMarker

Recent Changes

Keep entries to 1-2 lines max. Full details in CHANGELOG.md.

  • v0.5.414 — Closes #315: String.prototype.startsWith(searchString, position) and endsWith(searchString, endPosition) (the standard 2-arg ES forms) were rejected at codegen with perry-codegen: String.startsWith expects 1 arg, got 2 — surfaced compiling Effect's src/String.ts (func 19) during the #309 compat sweep. The 1-arg lower_string_method.rs arm hard-bailed on args.len() != 1, and lower_call.rs only routed Any-typed receivers to the string dispatcher when args.len() == 1. Fix in three places: (1) new js_string_starts_with_at / js_string_ends_with_at in crates/perry-runtime/src/string.rs that take a position i32 and use the existing is_ascii_string / utf16_offset_to_byte_offset helpers (UTF-16 code-unit indexing per spec, position clamped to [0, len]). (2) runtime_decls.rs declares both new externs (I64, I64, I32) -> I32. (3) lower_string_method.rs:548 widens the gate to args.len() in 1..=2 and dispatches the 2-arg form to the _at runtime variant via fptosi(DOUBLE → I32) on the position arg — matches the slice / substring pattern. (4) lower_call.rs:934 widens the Any-typed-receiver routing gate from args.len() == 1 to args.len() == 1 || args.len() == 2 for startsWith / endsWith since neither method exists on Array, so 2-arg dispatch is unambiguous. New regression test test-files/test_issue_315_starts_with_position.ts covers 1-arg + 2-arg forms, position clamping (negative, beyond length), and multi-byte UTF-8 / UTF-16 indexing (αβγδε) — matches node --experimental-strip-types byte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-env quirk); regression test matches Node byte-for-byte. Companion follow-up under umbrella #321 — flips Effect's String.ts from "rejected at codegen" to "compiles cleanly".
  • v0.5.413 — Closes #324: Array.isArray(value) constant-folded to TAG_TRUE for any value statically typed as a Union with at least one Array variant — so function hook(value: number | readonly number[]) { if (Array.isArray(value)) { ... } else { ... } } always picked the array branch even when the runtime value was a number. Surfaced on @codehz's ECS demo (same issue reporter as #313). Root cause: the Expr::ArrayIsArray lowering at crates/perry-codegen/src/expr.rs:6851 short-circuited to TAG_TRUE whenever is_array_expr(ctx, o) returned true, but that helper is deliberately loose — it returns true if any variant of a Union is Array(_) / Tuple(_), which is correct for routing .length / .push / [i] dispatch on T[] | null after a truthy narrow (so (maybeArr || []).slice() still hits the array fast path), but wrong for Array.isArray which must reflect the actual runtime tag. The HIR confirms: function hook(value: number | readonly number[]) lowered the parameter as Union([Number, Array(Number)]), is_array_expr returned true via the union arm, and ArrayIsArray(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 loose is_array_expr(ctx, o) check with a strict matches!(ty, Type::Array(_) | Type::Tuple(_)) direct match against static_type_of(ctx, o). Pure T[] / [T, U] types still constant-fold to TAG_TRUE; Union shapes (including the T[] | null post-narrow case) fall through to the existing runtime js_array_is_array dispatch 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 test test-files/test_issue_324_array_isarray_union.ts covers: number | readonly number[] parameter in if-guard with both numeric and array call shapes (the issue's exact repro), string | number[] ternary form, number[] | undefined optional form, and a control case verifying the fast path still fires on a definitively-Array parameter. Matches node --experimental-strip-types byte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-env quirk); regression test matches Node byte-for-byte.
  • v0.5.412 — Closes #323: const values = new Array(4); values[1] returned 0 instead of undefined, 1 in values and 2 in values (after values[2] = undefined) both returned false regardless of presence, and Object.keys(values) happened to return [] only because uninitialized arena bytes read as zero (a fresh js_object_keys call after the title-bug fix segfaulted dereferencing slot[1] as a keys_array pointer). Found in user @codehz's ECS demo. Root cause layered four overlapping defects: (1) js_array_alloc_with_length in crates/perry-runtime/src/array.rs left element bytes uninitialized — JS spec says new Array(n) slots are holes that read as undefined; (2) js_object_keys and js_object_has_property cast their argument to *const ObjectHeader regardless 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) and n in arr had 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 in crates/perry-codegen/src/expr.rs (bounded-index loop fast path, generic IndexGet, generic-Object numeric fallback) each emitted a raw load DOUBLE, ptr at arr+8+idx*8 — bypassing js_array_get_f64's translation entirely. Fix across five files with a HOLE sentinel approach: (a) crates/perry-runtime/src/value.rs defines pub(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_length initializes every reachable slot (0..capacity, the requested length — slots capacity..actual_capacity are unreachable through the bounds-checked accessor) to TAG_HOLE; (c) js_array_get_f64 and js_array_get_f64_unchecked translate HOLE → UNDEFINED at the read site so the sentinel never leaks to user code; (d) js_object_keys detects ArrayHeader by GcHeader::obj_type == GC_TYPE_ARRAY, walks the slot bytes, and emits js_string_new_sso decimal indices for every non-HOLE slot — short-circuits before the ObjectHeader path; (e) js_object_has_property detects 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 iff slot != TAG_HOLE; (f) crates/perry-codegen/src/nanbox.rs mirrors TAG_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 in crates/perry-codegen/src/expr.rs emit a branchless bitcast double→i64 + icmp eq TAG_HOLE_I64 + select after the raw load, so user code reading arr[i] never observes the sentinel even on the hot inline path; (h) crates/perry-codegen/src/type_analysis.rs::refine_type_from_init adds Expr::New { class_name: "Array", .. } => Some(HirType::Array(Box::new(HirType::Any))) so const xs = new Array(n) (no annotation) gets refined to Array — without this, the local stays at type Any, is_array_expr returns false, and xs[i] falls through to the generic-Object numeric fallback path; (i) crates/perry-codegen/src/lower_call/builtin.rs updates the misleading "(zero-initialized slots)" comment. New regression test test-files/test_issue_323_array_holes.ts covers 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-scoped for loop reading arr[i] over a fresh new Array(3) (exercises the bounded-index inline fast path's HOLE→UNDEFINED translation distinct from the generic IndexGet path). Matches node --experimental-strip-types byte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-env quirk); parity 179/179 (100% on a clean re-run; one earlier run showed 2 flaky test_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-cache flag 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 .o was built before #323's is_array_expr flip recognized new Array(...) as Array.
  • v0.5.411 — Closes #319: s = s + t on a closure-captured string-typed local aborted codegen with Error compiling module ...: lowering closure func_id=N: lowering closure body func_id=N: string self-append: local M not in scope. Surfaced on Effect's internal/channel.ts (1500+ LOC) during the #309/#310/#318 follow-up sweep. Root cause: the string self-append fast path at crates/perry-codegen/src/expr.rs::Expr::LocalSet (the optimization that turns let s = ""; for (...) s = s + "a" from O(n²) js_string_concat allocations into O(n) in-place js_string_append mutations) only checked local_types[id] == String && !module_globals.contains_key(id) before calling lower_string_self_append, which then required ctx.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 via js_closure_set_capture_f64 — so the helper's ok_or_else(|| anyhow!("string self-append: local {} not in scope")) fired and aborted compilation. Boxed vars (heap cells via js_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 from local_types == String && !module_globals to also require !closure_captures.contains_key(id) && !boxed_vars.contains(id) && locals.contains_key(id) — same three predicates the regular LocalSet store path uses to choose between closure-set / box-set / alloca-store / global-store dispatch. When any of those three predicates fail, LocalSet falls through to the regular path which correctly handles all four storage types via the existing js_closure_set_capture_f64 / js_box_set / alloca-store / @global store mechanics. The fast-path's js_string_append in-place mutation is still preserved for plain (non-captured, non-boxed) string locals — the canonical let s = ""; for (...) s = s + "a" build pattern that's the load-bearing optimization for bench_string_ops. New regression test test-files/test_issue_319_string_self_append_capture.ts covers: 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 via js_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). Matches node --experimental-strip-types byte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-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's Stream module. The four FFI symbols (and the surrounding ReadableStream / WritableStream / TransformStream / reader / writer / pipe surface) are all already implemented in crates/perry-stdlib/src/streams.rs (1300+ lines, registered via pub mod streams; pub use streams::*; in lib.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 at crates/perry-codegen/src/lower_call.rs for js_readable_stream_* (already wired across 14 sites at lines 3469-3584) and at crates/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(); } })) on main HEAD → compiles + links + runs to exit 0. The cosmetic console.log(stream) rendering still prints 1 (raw handle id) instead of ReadableStream { locked: false, state: 'readable', supportsBYOB: false } — registry-id-as-f64 handle pattern (same as BLOB_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 match node --experimental-strip-types byte-for-byte (test_issue_237_streams_blob / _pipe / _user_source); new pin test-files/test_issue_320_readable_stream.ts exercises the issue's exact code shape (string-chunk controller.enqueue("hello") — distinct from the existing #237 tests which all use Uint8Array chunks), matches Node byte-for-byte for r1/r2/r3.{done,value}. Gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-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/hook 6-arg, sink 6-arg, stm/tPubSub 6-arg, stm/core 8-arg, schedule 10-arg). Ceiling was artificial — the runtime already exposes js_closure_call0..js_closure_call16 (crates/perry-runtime/src/closure.rs:187-727) and runtime_decls.rs:180-266 already declares all 17 externs. The sibling fallthrough closure-call path at lower_call.rs:2076 was already gated at <= 16; only the closure-typed-local LocalGet path at lower_call.rs:367 still had the historical > 5 bail 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 existing runtime_decls table. New regression test test-files/test_issue_314_closure_call_arity.ts covers 5/6/8/10/16-arg shapes (mirrors the issue's minimal repro plus all four Effect arities + the runtime upper bound) — matches node --experimental-strip-types byte-for-byte. Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-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_expr carried its own ad-hoc HIR descent with an _ => {} catch-all, and any Expr variant added since the last sweep that wasn't enumerated silently dropped its sub-expressions — so closures buried inside Expr::RegExpReplaceFn { callback }, Expr::NetCreateServer { connection_listener }, the Expr::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 emitted js_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: rewrite collect_closures_in_expr to delegate descent to perry_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: when e itself is Expr::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 including Closure, ask walk_expr_children to call us back on each direct sub-Expr. New HIR variants now produce a compile error in walker.rs until 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 test test-files/test_issue_318_closure_collector_walker.ts exercises closures nested inside Math.sin / Math.cos / Math.log2 operands (representative of the >80 missed variants — old arm list had MathSqrt/Floor/Ceil/Round/Abs/MinSpread/MaxSpread but not the trig/log family). Verified: cargo build clean; gap tests 27/28 = baseline (lone fail is pre-existing console_methods ci-env quirk); parity 175/175 (100%); regression test matches node --experimental-strip-types byte-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); } } printed self.v: undefined and the symptom-2 sibling SIGSEGV'd. Root cause: scalar replacement (collectors.rs:collect_non_escaping_news → stmt.rs:264-343) was rewriting let h = new Holder() into per-field stack allocas and inlining the ctor body with a dummy this_stack slot that's never populated — the comment at stmt.rs:316 explicitly notes "scalar-replaced PropertySet intercepts it before loading," and that's true for this.field = … and this.field (intercepted at expr.rs:2738/2851), but not for any other shape. const self = this lowered to Stmt::Let { init: Expr::This } whose Expr::This handler at expr.rs:3588 reads from this_stack.last() → loads the dummy alloca → produces TAG_UNDEFINED → self.v returns undefined; the symptom-2 inline-arrow with captures_this:true had its closure env's this slot patched by apply_field_initializers_recursive (lower_call.rs:2660-2665) using the same dummy this_stack value, so the captured this was TAG_UNDEFINED — this.v then dereferenced an unboxed pointer of 0x0001 and 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" in collect_non_escaping_news (collectors.rs) that walks each candidate class's constructor.body + every instance-field init (own + parent chain) and marks the candidate as escaped when Expr::This appears outside of (PropertyGet|PropertySet|PropertyUpdate).object with a known field property, when an Expr::Closure { captures_this: true } appears (its env stores this at construction), or when Expr::SuperCall/Expr::SuperMethodCall appear (implicit this). Three new helpers class_uses_this_as_value / stmts_use_this_as_value / expr_uses_this_as_value enumerate the safe HIR variants explicitly and use _ => true for unknowns — strictly conservative, only loses the optimization on patterns we haven't enumerated. Method/getter property names like this.method() correctly route to "unsafe" via the fields.contains(property) check, since they materialize this as the receiver passed to method dispatch in lower_call.rs:1465. New regression test test-files/test_issue_313_arrow_stored_in_field.ts covers both symptoms + chained h.s.fn(7) direct-closure dispatch — matches node --experimental-strip-types byte-for-byte. Verified: clean rebuild; gap tests 27/28 = baseline (same console_methods ci-env quirk); parity 173/173 (100%) — no regression.
  • v0.5.406 — Closes #210 (last 1 of 5): wires the Windows widget.shadow paint pass via a parent-window WM_PAINT subclass that renders the shadow onto a 32bpp CreateDIBSection and AlphaBlends 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 falloff alpha = base * (1 - d/blur)^2. New apply_shadow(handle) companion to apply_corner_radius, called from the same 4 layout sites; set_shadow auto-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 CSS box-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.ts smoke 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's if let ExportSpecifier::Named filter at crates/perry-hir/src/lower.rs:4047 only matched the regular Named shape; SWC's ExportSpecifier::Namespace variant fell through and the re-exported file never entered the module graph. The consumer's import { Foo } from "pkg" then resolved to a stale binding and every Foo.<member> access lowered to 0 — silent-correctness bug, exit 0, no diagnostics. Surfaced on Effect (effect/src/index.ts:229 does export * as Effect from "./Effect.js") where Effect.runSync(Effect.succeed(42)) returned 0 instead of 42. Fix in four parts: (1) new Export::NamespaceReExport { source, name } HIR variant in crates/perry-hir/src/ir.rs. (2) ExportNamed lowering rewritten from if let Named to a full match on ExportSpecifier::{Named,Namespace,Default} so the namespace specifier produces the new variant; the never-standardised Default form ("export v from 'mod'") is silently ignored. (3) collect_modules (crates/perry/src/commands/compile/collect_modules.rs) and the topo-sort dep-walk in crates/perry/src/commands/compile.rs both extend their Export::* source-extraction match with NamespaceReExport => 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 in compile.rs: when a Named import's exported_name matches a NamespaceReExport { name } in the source module's HIR exports, resolve the namespace target relative to the source's directory, add local_name to namespace_imports, and register every export of the target file in import_function_prefixes / imported_classes / imported_param_counts / imported_enums — same machinery import * as Foo from "pkg/Foo" already uses. (5) Codegen-side companion at crates/perry-codegen/src/expr.rs::Expr::StaticMethodCall: the existing HIR rule "uppercase imported Ident followed by .<method>(...) lifts to StaticMethodCall" intercepts Foo.succeed(42) before the namespace path can fire, so the methods-table miss now falls through to a new namespace_imports.contains(class_name) arm that emits perry_fn_<source_prefix>__<method_name> directly — same symbol the explicit import * as Foo form would have produced. New regression test test-files/test_issue_310_namespace_reexport.ts + 3-file fixture under test-files/fixtures/issue_310_pkg/ (re-exporter index.ts does export * as Foo from "./Foo.ts"; export * as Bar from "./Bar.ts") covers single-arg / multi-arg / chained / cross-namespace / nested-expression dispatch — matches node --experimental-strip-types byte-for-byte. Out of scope (follow-ups): (a) transitive export * from "./pkg-b" propagation of namespace re-exports through all_module_exports chains — 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" where tag is a string) — surfaced during the test development but is orthogonal to namespace re-exports and exists on main without #310. (c) End-to-end on the actual effect npm package needs #309's OOM fix (already shipped in v0.5.403) plus this one to fully render Effect.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-existing console_methods ci-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 compile of a 4-line program importing the effect package OOM'd at 34 GB RSS / 249 GB peak virtual before being SIGKILL'd ~7 minutes in, during the Generating code... phase.
  • v0.5.402 — Closes #311: for...of on 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|10 CLI flag (default 10).
  • v0.5.399 — HarmonyOS Phase 2 v1: TS→ArkUI emission via new perry-codegen-arkts crate.
  • 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's job_assign manifest as null for 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 --fix auto-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-jsruntime from cargo test --workspace on ubuntu-latest.
  • v0.5.393 — Release-night reliability + v0.5.392 follow-up.
  • v0.5.392 — CI cost reduction: move cargo-test, parity, and compile-smoke jobs from macos-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.json to passing — Perry's parity rate is now 100% (167/167) and gap tests are 27/28 (only console_methods remains, 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 broke fs.promises.readFile() (and likely fs.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) with shopt -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.