From 3be6c59074600ed979fe30593a287caead4db243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 17 May 2026 10:32:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(codegen):=20#894=20=E2=80=94=20class-expres?= =?UTF-8?q?sion=20static=20[Symbol]=20sees=20populated=20module=20lets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect's `Schema.ts` crashed during module init with `TypeError: Cannot read properties of undefined (reading '_tag')` because `init_static_fields` ran the `js_class_register_static_symbol(class_id, key, value)` emission BEFORE `stmt::lower_stmts` initialised the module-level lets that the key/value referenced. The registration recorded zeros — `isSchema(C)` returned false on every class returned from effect's `make()` factory, `dual`'s predicate failed, and the curried path eventually fed `undefined` back into a `make()` call. Two-part fix: 1. Split `init_static_fields` into `_early` (Error-registry + well-known symbol hooks — no user-value reads) and `_late` (per-class static-field stores + computed-Symbol-key registration + static blocks). The new `_late` runs after `stmt::lower_stmts`, so module globals are populated when the registration emits. 2. For class expressions returned from factory functions (the effect pattern), HIR lowering now sequences a new `Expr::RegisterClassStaticSymbol` in front of the `Expr::ClassRef` for each computed-Symbol-key static field. Each factory invocation re-emits the registration with current free-variable values — `class { static [k] = v }` evaluates k/v at definition time per spec. Followup gap: static methods with a synthesized `…arguments` rest param emitted unbundled call-site args, so `arguments.length` inside `static pipe()` read garbage. Track static methods in `method_has_rest` / `method_param_counts` and bundle trailing args at the `Expr::StaticMethodCall` codegen. Regression test: `test_issue_effect_tag_undefined.ts` covers the standalone shape; byte-for-byte parity with `node --experimental-strip-types`. Known follow-ups for the full effect smoke (not addressed here): - `arguments.length` reads 0 in FnExpr-returned-from-function whenever fixed params consume the user-passed args before the synthetic rest captures them. Blocks effect's `dual` predicate. - Dynamic property access on a factory-returned class can't reach static methods. Blocks `Literal(tag).pipe(...)`. --- CHANGELOG.md | 37 +++ CLAUDE.md | 6 +- Cargo.lock | 230 +++++++++--------- Cargo.toml | 2 +- crates/perry-codegen/src/codegen.rs | 74 +++++- crates/perry-codegen/src/collectors.rs | 17 ++ crates/perry-codegen/src/expr.rs | 73 ++++++ crates/perry-hir/src/ir.rs | 16 ++ crates/perry-hir/src/lower.rs | 49 +++- crates/perry-hir/src/stable_hash.rs | 10 + crates/perry-hir/src/walker.rs | 16 ++ crates/perry-transform/src/inline.rs | 23 ++ test-files/test_issue_effect_tag_undefined.ts | 85 +++++++ 13 files changed, 505 insertions(+), 133 deletions(-) create mode 100644 test-files/test_issue_effect_tag_undefined.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6257fdf902..3c847a1b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.940 — fix(codegen): #894 — class-expression `static [Symbol]` registration sees populated module lets + +**Symptom.** `import { Effect } from "effect"; console.log(typeof Effect.succeed)` aborted during Effect's Schema.ts module init with `TypeError: Cannot read properties of undefined (reading '_tag')`, before any user code ran. The crash chained back to a `make()` factory invocation receiving `undefined` as `ast`, which itself stemmed from `isSchema(C) = hasProperty(C, TypeId) && isObject(C[TypeId])` returning false on a class returned from the factory — so `dual()`'s predicate failed, the curried path returned a function instead of the schema, and downstream `.annotations({…})` / `class extends transform(…)` chains corrupted the AST eventually fed back into `make`. + +**Root cause.** Two timing/scope bugs combined for the failure on the symbol side: + +1. **Static-field init ran before user init.** `init_static_fields` emitted the per-class `js_class_register_static_symbol(class_id, key, value)` call at the top of each module's `__init`, BEFORE `stmt::lower_stmts(&hir.init)` populated the module-level lets the key/value referenced. The key expression (`Symbol.for("…")`) and value expression (`variance = {…}`) both lowered to `LocalGet(N)` of top-level `Stmt::Let` ids whose global slots were still zero. Registration recorded `(class_id, 0, 0)` — never matched against the live `TypeId` at lookup time. Effect's `function make(ast) { return class { static [TypeId] = variance } }` hit this on every `make()` invocation: the inner anonymous class is HIR-hoisted to module-level, so its symbol-keyed field init shared the same single-shot module-init emission as a regular top-level class. + +2. **No per-invocation re-emission for class expressions.** Even after the module's let initializers ran, every subsequent `make(other_ast)` call still saw the stale, pre-init registration — there was no codegen-side hook to re-register on each factory entry. JS spec semantics: `class C { static [k] = v }` evaluates `k` and `v` at CLASS-DEFINITION time, which for a class expression is every time the surrounding scope runs. Perry needed to mirror that. + +**Fix.** + +* Split `init_static_fields` into `init_static_fields_early` (Error-extending class registry + well-known Symbol method hooks — neither depends on user-let values) and `init_static_fields_late` (per-class static-field stores + computed-Symbol-key registration + static blocks). The two call sites in `compile_module_entry` / non-entry-module compile path now sandwich `stmt::lower_stmts` between them, so the late phase reads populated module globals. + +* Added a new `Expr::RegisterClassStaticSymbol { class_name, key_expr, value_expr }` HIR variant and sequenced one in front of every `Expr::ClassRef` returned from `ast::Expr::Class` lowering (one per computed-Symbol-key static field on the class expression). Each factory invocation now re-emits `js_class_register_static_symbol` against the same `class_id` with current free-variable values — the registry's `insert` overwrites the prior entry, so subsequent `C[TypeId]` reads see the fresh registration. + +* Followup gap fix: static methods with the synthetic `…arguments` rest param (`static pipe() { return pipeArguments(this, arguments) }` — Effect's `make()`'s returned class) emitted unbundled call-site args. Added `static_methods` to the `method_has_rest` / `method_param_counts` collection and taught `Expr::StaticMethodCall` codegen to bundle trailing args into a `js_array_alloc` + `js_array_push_f64` chain when the called static method ends in a rest param. + +**Files.** + +* `crates/perry-codegen/src/codegen.rs` — split `init_static_fields` → `_early` + `_late`, updated both call sites; track static-method rest info in `method_has_rest` / `method_param_counts`. +* `crates/perry-codegen/src/expr.rs` — handle new `Expr::RegisterClassStaticSymbol` (emits `js_class_register_static_symbol`); bundle trailing args in `Expr::StaticMethodCall` when the target has a rest param. +* `crates/perry-codegen/src/collectors.rs` — `collect_ref_ids_in_expr` recurses into `RegisterClassStaticSymbol` (and `RegisterClassParentDynamic`) children so the surrounding function's `referenced_from_fn` pre-scan includes the lets they reference, which `compile_module_entry`'s `module_globals` build then promotes to module-global storage. +* `crates/perry-hir/src/ir.rs` — new `Expr::RegisterClassStaticSymbol` variant. +* `crates/perry-hir/src/lower.rs` — `ast::Expr::Class` lowering collects each computed-Symbol-key static field's `(key_expr, value_expr)` and prepends `Expr::RegisterClassStaticSymbol` to the `Sequence` before the `ClassRef`. +* `crates/perry-hir/src/walker.rs`, `crates/perry-hir/src/stable_hash.rs` — new variant in both walkers + hasher. +* `crates/perry-transform/src/inline.rs` — `rewrite_call_init` recurses into the new variant; `body_references_class_in_set` includes it in the class-name match arm. +* `test-files/test_issue_effect_tag_undefined.ts` — standalone repro mirroring Effect's `make()` factory shape, validated byte-for-byte against `node --experimental-strip-types`. + +**Validation.** `test_issue_effect_tag_undefined.ts` prints the expected `isSchemaLike(A): true / isSchemaLike(B): true` after the fix (returned `undefined` / `false` pre-fix). Standalone repros covering the direct-vs-returned-class and `function returns class with static [Sym] = variance` shapes also pass. + +**Known follow-ups (not addressed here).** The full Effect smoke (`typeof Effect.succeed`) is still blocked downstream on two separate perry gaps surfaced during this investigation: + +* `arguments.length` reads 0 inside a function expression returned from another function whenever the FnExpr has FIXED parameters before the synthesized `…arguments` rest param — perry's call-site bundling only captures TRAILING args after the fixed params, so `function(a, b) { arguments.length }` called with `f(1, 2)` sees `arguments.length === 0`. Effect's `dual(arity, body)` uses `arguments.length` to discriminate data-first vs data-last, so the dispatch always falls to the curried path. Proper fix is to make the synthesized rest param capture ALL passed args (not just trailing) when the body reads `arguments`. + +* Dynamic property access on a class returned from a factory (`make().pipe`) reads undefined for static methods — `class_has_own_method` consults the instance vtable, not the static-method registry. Effect's `Literal(tag).pipe(propertySignature, …)` hits exactly this when `tag` lifts a literal schema and then chains. Proper fix is to add a static-method side table keyed by class_id and consult it from the class-ref property-get path in the runtime. + ## v0.5.938 — fix(codegen+runtime): #894 — `const { EventEmitter } = require('node:events')` no longer throws `Cannot read properties of undefined (reading 'prototype')` (pino smoke test). **Symptom.** The pino smoke test under `perry.compilePackages: ["pino"]` (`mkdir /tmp/perry-pino-x && cd /tmp/perry-pino-x && echo '{"perry":{"compilePackages":["pino"]}}' > package.json; npm install pino; echo 'import pino from "pino";' > main.ts; perry main.ts -o out && PERRY_ALLOW_UNIMPLEMENTED=1 ./out`) threw `TypeError: Cannot read properties of undefined (reading 'prototype')` at module load. The throw site is pino's `node_modules/pino/lib/proto.js:77`: `Object.setPrototypeOf(prototype, EventEmitter.prototype)`, where `EventEmitter` is the destructured binding from `const { EventEmitter } = require('node:events')` at line 5. **Root cause (two layered gaps).** (1) The CJS-wrap pass at `crates/perry/src/commands/compile/cjs_wrap.rs` synthesizes a `function require(specifier)` whose body returns native modules via `NativeModuleRef()` directly (e.g. `if (specifier === "node:events") return _req_0;` where `_req_0` is the imported binding lowered to `NativeModuleRef("events")`). The `Expr::NativeModuleRef(_) => Ok(double_literal(0.0))` value-form arm in `crates/perry-codegen/src/expr.rs:10317` returned the literal f64 `0.0` — NOT the NaN-boxed `undefined` tag, but plain numeric zero. So `Let { id: 6, init: Call(require, "node:events") }` produced `0.0`, and the subsequent `Let { id: 7, init: PropertyGet { LocalGet(6), "EventEmitter" } }` slow-pathed through `js_object_get_field_by_name` with a null receiver, hit `obj.is_null()`, and returned `undefined`. The `(2) Let { id: 8, ... } ; ObjectSetPrototypeOf(LocalGet(8), PropertyGet { LocalGet(7), "prototype" })` then read `.prototype` on `undefined` — that's the `js_throw_type_error_property_access` site (#462). The direct AST form `import * as fs from "node:fs"; fs.constants.F_OK` doesn't hit this because the codegen fast path at `expr.rs:3615` matches `Expr::NativeModuleRef(_)` literally as the PropertyGet's object and short-circuits through `js_native_module_property_by_name`. The destructured-from-require shape walks through a local-id boundary that breaks the AST pattern match. (2) Even after materializing the namespace via `js_create_native_module_namespace`, the `NATIVE_MODULE_CLASS_ID` arm of `js_object_get_field_by_name` (object.rs:3821) only consulted `get_native_module_constant` — which has no entry for `("events", "EventEmitter")` — and returned `undefined`. The direct-AST fast path's `js_native_module_property_by_name` ALSO checks `is_native_module_callable_export` (tty.isatty, tty.ReadStream, tty.WriteStream) before returning undefined, and synthesizes a BOUND_METHOD_FUNC_PTR closure for those — but that mirror check was missing in the slow-path arm. **Fix.** Three coordinated changes: (a) `crates/perry-codegen/src/expr.rs` — replace the value-form `Expr::NativeModuleRef(_) => Ok(double_literal(0.0))` with a real `js_create_native_module_namespace()` runtime call so the require-result is a NATIVE_MODULE_CLASS_ID-tagged ObjectHeader (same shape the direct-AST fast path produces). (b) `crates/perry-codegen/src/runtime_decls.rs` — declare the new FFI. (c) `crates/perry-runtime/src/object.rs` — extend `is_native_module_callable_export` with `("events", "EventEmitter")` so a property-read on the events namespace produces a callable closure (typeof "function" matching Node), and mirror that synthesis into the `NATIVE_MODULE_CLASS_ID` arm of `js_object_get_field_by_name` so the destructure-from-require shape converges with the direct-AST shape. **Why callable instead of an INT32-tagged class ref.** A proper class-ref would let `EventEmitter.prototype` return the class itself (line 3394 reads `.prototype` on registered class IDs and returns the class ref). But we don't yet register `EventEmitter` in `CLASS_VTABLE_REGISTRY` for the namespace import path — that's a bigger surface to design. A callable closure satisfies the narrow contract: `EventEmitter` is truthy (closure pointer is neither null nor undefined), `typeof EventEmitter === "function"` matches Node, `EventEmitter.prototype` returns `undefined` (no closure-prototype dispatch), and `Object.setPrototypeOf(prototype, undefined)` is a no-op (`js_object_set_prototype_of` ignores its second argument — same documented limitation as chalk's path, see #893). `new EventEmitter()` still goes through the dedicated `lower_call/builtin.rs` path that allocates a real `EventEmitterHandle`, so dispatch coherence is preserved. **Validation.** Pino smoke test (`echo 'import pino from "pino"; console.log("smoke:", typeof (pino as any));' > main.ts && perry main.ts -o out && PERRY_ALLOW_UNIMPLEMENTED=1 ./out`) no longer trips the prototype TypeError — module init advances past `lib/proto.js` and now hits a different downstream gap (`Cannot read properties of undefined (reading 'ASC')` from `SORTING_ORDER.ASC` in `pino.js:55`, separate issue tracking pino's `./lib/constants` symbol propagation). New regression test `test-files/test_issue_pino_prototype_undefined.ts` covers the minimal verbatim shape — destructured EventEmitter, `.prototype` read as a value, the `Object.setPrototypeOf(prototype, EventEmitter.prototype)` chain, and `Object.create(prototype)` factory — all four checkpoints produce non-error output. `cargo fmt --all` clean. `cargo build --release -p perry` clean. **Files touched.** `crates/perry-codegen/src/expr.rs` (value-form NativeModuleRef arm), `crates/perry-codegen/src/runtime_decls.rs` (FFI declaration), `crates/perry-runtime/src/object.rs` (callable-export whitelist + slow-path arm), `test-files/test_issue_pino_prototype_undefined.ts` (regression). Refs #793 (Node.js + TypeScript compatibility roadmap), #805 (npm sweep), #890 (the family of cross-module undefined-import bugs). ## v0.5.937 — fix(runtime): `js_is_truthy` no longer SIGSEGVs when a plain f64 bit pattern lands in the legacy raw-string-pointer range. **Symptom.** dayjs (`mkdir /tmp/perry-dayjs-x && echo '{"perry":{"compilePackages":["dayjs"]}}' > package.json && npm install && perry main.ts -o out && ./out`) exited 139 (SIGSEGV) with empty stdout. Crashed inside `js_is_truthy` at `crates/perry-runtime/src/value.rs:1762` on a value with `bits=0x646e` — a small integer dereferenced as `*StringHeader`. Surfaced when dayjs's `parse()` constructor body called `b.u(e)` on a utility-object closure; the call's result (or some adjacent stack slot) carried the offending bit pattern through the closure's outer truthy gate. **Root cause.** The "raw pointer bits (from bitcast of string literal)" fallback at line 1826 matched everything in the range `0x1000 < bits < 0x0001_0000_0000_0000` and then dereferenced as `*StringHeader` to read its length. `0x646e` (25710) sat squarely inside that window — it can be the bit pattern of an f64 denormal (`f64::from_bits(0x646e)` ≈ 1.27e-319), an unboxed-integer storage shape, or any other plain f64 whose bits happen to land below the 4 GiB mark. The 4 KiB lower bound was set when this branch was written assuming the only callers were string literals lowered as raw bitcasts — that assumption stopped holding once arbitrary `any`-typed values flowed through truthy gates. **Fix.** Three coordinated tightening changes in `js_is_truthy`: (1) raise the raw-pointer lower bound from `0x1000` (4 KiB — let any small integer through) to `0x10_0000` (1 MiB — well below any realistic userspace heap address on macOS/Linux but well above any small-integer or denormal bit pattern that could survive an `any`-typed call); (2) require 8-byte alignment (`bits & 0x7 == 0`) — `StringHeader` is `repr(C)` with usize-aligned fields, so a valid pointer must have its low 3 bits clear, which most small-integer-shaped patterns fail; (3) add an explicit `SHORT_STRING_TAG` branch above the raw-pointer fallback so inline-SSO empties are correctly classified as falsy via `(bits & SHORT_STRING_LEN_MASK) >> SHORT_STRING_LEN_SHIFT == 0` rather than punted to the f64 catch-all (which would have reported them as truthy because the bit pattern is non-zero non-NaN). The legacy raw-string-bitcast path is preserved for real heap pointers — only false positives are now rejected. **Validation.** The user's dayjs repro no longer SIGSEGVs (advances to a separate, downstream `TypeError: (number).getDay is not a function` from a different lowering path — out of scope here, tracked separately). New regression test `test-files/test_issue_dayjs_is_truthy_smallint.ts` covers (a) a small `0x646e` integer truthy gate, (b) `0.0` falsy gate, (c) a `1` integer truthy gate — all three byte-for-byte parity with `node --experimental-strip-types`. Build clean (`cargo build --release -p perry-runtime -p perry-stdlib -p perry`). **Files touched.** `crates/perry-runtime/src/value.rs` (~30 lines in `js_is_truthy` — SSO branch + raw-pointer-bounds tightening + alignment check), `test-files/test_issue_dayjs_is_truthy_smallint.ts` (regression). Refs #793 (Node.js + TypeScript compatibility roadmap), #805 (npm sweep — dayjs). diff --git a/CLAUDE.md b/CLAUDE.md index 2bc916e82e..4f4e5a9b29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,11 @@ 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.938 +<<<<<<< HEAD +**Current Version:** 0.5.940 +======= +**Current Version:** 0.5.940 +>>>>>>> 408d8acb (fix(codegen): #894 — class-expression static [Symbol] sees populated module lets) ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c579d8d985..a135256a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,9 +459,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -469,9 +469,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -3023,9 +3023,9 @@ dependencies = [ [[package]] name = "hstr" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa57007c3c9dab34df2fa4c1fb52fe9c34ec5a27ed9d8edea53254b50cd7887" +checksum = "c1b94e40256e78ddd4e30490aa931bec17e65e9413a6ad11f64ec67815da9323" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", @@ -3620,9 +3620,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -3666,11 +3666,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] @@ -3795,7 +3795,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.5", ] [[package]] @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.938" +version = "0.5.940" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.938" +version = "0.5.940" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.938" +version = "0.5.940" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.938" +version = "0.5.940" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.938" +version = "0.5.940" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.938" +version = "0.5.940" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.938" +version = "0.5.940" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.938" +version = "0.5.940" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.938" +version = "0.5.940" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.938" +version = "0.5.940" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.938" +version = "0.5.940" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.938" +version = "0.5.940" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.938" +version = "0.5.940" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.938" +version = "0.5.940" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.938" +version = "0.5.940" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.938" +version = "0.5.940" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.938" +version = "0.5.940" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.938" +version = "0.5.940" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.938" +version = "0.5.940" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.938" +version = "0.5.940" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.938" +version = "0.5.940" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.938" +version = "0.5.940" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.938" +version = "0.5.940" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.938" +version = "0.5.940" [[package]] name = "perry-ui-android" -version = "0.5.938" +version = "0.5.940" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.938" +version = "0.5.940" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.938" +version = "0.5.940" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.938" +version = "0.5.940" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.938" +version = "0.5.940" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.938" +version = "0.5.940" [[package]] name = "perry-ui-tvos" -version = "0.5.938" +version = "0.5.940" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.938" +version = "0.5.940" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.938" +version = "0.5.940" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.938" +version = "0.5.940" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.938" +version = "0.5.940" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.938" +version = "0.5.940" dependencies = [ "wasmi", ] @@ -5705,18 +5705,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -6278,9 +6278,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -6880,9 +6880,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "serde_core", "serde_with_macros", @@ -6890,9 +6890,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7444,9 +7444,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swc_atoms" -version = "9.0.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ccbe2ecad10ad7432100f878a107b1d972a8aee83ca53184d00c23a078bb8a" +checksum = "ecbd7177c71140b23dc6b1b9c1a9f16f96d0337cb999f5a79bb49ca4d82eded0" dependencies = [ "hstr", "once_cell", @@ -8478,11 +8478,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -8491,7 +8491,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -8502,9 +8502,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -8516,9 +8516,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8526,9 +8526,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8536,9 +8536,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -8549,9 +8549,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -8600,9 +8600,9 @@ dependencies = [ [[package]] name = "wasmi" -version = "0.51.4" +version = "0.51.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0120da23bc519b1d573e64ffe2a91390468293242db4edc5517f481d7e495d" +checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" dependencies = [ "spin", "wasmi_collections", @@ -8669,9 +8669,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -9284,9 +9284,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -9306,6 +9306,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index f5c36e4c16..cf4cc7cc1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.938" +version = "0.5.940" 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 65ae0d92bd..81cdf9b16b 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -1233,6 +1233,21 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> method_has_rest.insert((cls.name.clone(), m.name.clone()), true); } } + // Issue #894: track static methods too. Effect's `static pipe()` / + // `static annotations()` synthesize a trailing `...arguments` rest + // param when the body reads `arguments`. The StaticMethodCall + // lowering at `expr.rs::Expr::StaticMethodCall` reads + // `method_has_rest` to decide whether to bundle trailing args into + // a rest array; without this, `Cls.pipe(a, b)` calls the method + // with 2 scalar args while the signature expects (rest_array), + // and `arguments.length` reads garbage / undefined. + for sm in &cls.static_methods { + method_param_counts.insert((cls.name.clone(), sm.name.clone()), sm.params.len()); + let has_rest = sm.params.iter().any(|p| p.is_rest); + if has_rest { + method_has_rest.insert((cls.name.clone(), sm.name.clone()), true); + } + } } for ic in &opts.imported_classes { let effective_name = ic.local_alias.as_deref().unwrap_or(&ic.name).to_string(); @@ -1665,8 +1680,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> for c in &hir.classes { for sf in &c.static_fields { // Computed-key static fields (`static [Symbol.for(...)] = init`) - // are stored in a runtime side table by `init_static_fields`; - // they don't get a string-named global. Refs #420. + // are stored in a runtime side table by + // `init_static_fields_late`; they don't get a string-named + // global. Refs #420, #894. if sf.key_expr.is_some() { continue; } @@ -4731,9 +4747,16 @@ fn compile_module_entry( register_module_globals_as_gc_roots(&mut ctx, module_globals); // Initialize static class fields with their declared init // expressions. Runs once at the top of main, before user code. - init_static_fields(&mut ctx, hir)?; + // + // Split into two phases (#894): early emits the bits that don't + // read user-let values (Error-extending class registry, well- + // known symbol method hooks); late runs AFTER user init so + // computed-Symbol-key static fields whose key/init reference + // module-level lets see populated slots. + init_static_fields_early(&mut ctx, hir)?; stmt::lower_stmts(&mut ctx, &hir.init) .with_context(|| format!("lowering init statements of module '{}'", hir.name))?; + init_static_fields_late(&mut ctx, hir)?; // Issue #100: populate `@__perry_ns_` from the // namespace_entries list AFTER user init has run (so every @@ -5095,13 +5118,18 @@ fn compile_module_entry( // right after js_gc_init, so by the time any user code executes // every module's globals are already GC-rooted. register_module_globals_as_gc_roots(&mut ctx, module_globals); - init_static_fields(&mut ctx, hir)?; + // Issue #894: split into early/late around `lower_stmts` so a + // computed-Symbol-key static field whose key/init reference + // top-level module lets (e.g. effect's `make()` factory: + // `static [TypeId] = variance`) sees populated globals. + init_static_fields_early(&mut ctx, hir)?; stmt::lower_stmts(&mut ctx, &hir.init).with_context(|| { format!( "lowering init statements of non-entry module '{}'", hir.name ) })?; + init_static_fields_late(&mut ctx, hir)?; // Issue #100: populate `@__perry_ns_` from the // namespace_entries list at the tail of the non-entry __init. @@ -5919,12 +5947,17 @@ fn register_module_globals_as_gc_roots( } } -/// Initialize each class's static fields with their declared init -/// expressions. Called at the top of compile_module_entry's main / -/// __init function. The static field globals were registered in -/// compile_module — this just emits the per-field "store init value -/// to global" sequence. -fn init_static_fields(ctx: &mut crate::expr::FnCtx<'_>, hir: &HirModule) -> Result<()> { +/// Early static-field setup: registrations that don't read any +/// module-level binding's value (Error-extending classes, well-known +/// symbol method hooks). Safe to emit before `stmt::lower_stmts` — +/// values referenced are either compile-time constants (class ids, +/// function pointers) or computed entirely from `hir` metadata. +/// +/// The split (early vs. late) was introduced for issue #894 (effect's +/// `make()` factory's `static [TypeId] = variance` — both the key and +/// the init reference module-level lets that haven't been initialized +/// at the point the old combined `init_static_fields` ran). +fn init_static_fields_early(ctx: &mut crate::expr::FnCtx<'_>, hir: &HirModule) -> Result<()> { // Phase C.3: register user classes that extend the built-in Error // (or any of its subclasses) with the runtime, so `instanceof Error` // walks the chain and returns true. Without this, `new HttpError(...) @@ -6002,6 +6035,27 @@ fn init_static_fields(ctx: &mut crate::expr::FnCtx<'_>, hir: &HirModule) -> Resu &[(crate::types::I32, &cid_str), (I64, &func_ptr_i64)], ); } + Ok(()) +} + +/// Late static-field setup: per-class static-field initializer evaluation, +/// computed-Symbol-key registration, and static-block invocation. Must +/// run AFTER `stmt::lower_stmts` so module-level lets referenced by +/// these initializers (e.g. `static [TypeId] = variance` where both +/// `TypeId` and `variance` are top-level `const`s) read their populated +/// global slots rather than the zero default. +/// +/// Issue #894: effect's `function make(ast) { return class { static +/// [TypeId] = variance } }` factory pattern hit this; the `TypeId` +/// symbol and `variance` value were both top-level module lets, and +/// the pre-#894 combined `init_static_fields` ran before user init, +/// so `js_class_register_static_symbol(class_id, 0.0, 0.0)` registered +/// nothing reachable. `isSchema(C)` then returned false on a class +/// returned from `make`, dual()'s predicate failed, and the failing +/// `.annotations({...})` chain eventually fed `undefined` to a `make` +/// call that read `ast._tag` → `TypeError: Cannot read properties of +/// undefined (reading '_tag')` during Schema.ts module init. +fn init_static_fields_late(ctx: &mut crate::expr::FnCtx<'_>, hir: &HirModule) -> Result<()> { // Issue #685: nested classes (declared as expressions inside a // factory function body, e.g. `return class X extends Y { static // params = params.slice() }` in effect's `TemplateLiteralParser`) diff --git a/crates/perry-codegen/src/collectors.rs b/crates/perry-codegen/src/collectors.rs index 146c0f027e..a3b149fb1a 100644 --- a/crates/perry-codegen/src/collectors.rs +++ b/crates/perry-codegen/src/collectors.rs @@ -1127,6 +1127,23 @@ pub(crate) fn collect_ref_ids_in_expr(e: &perry_hir::Expr, out: &mut HashSet { + walk(key_expr, out); + walk(value_expr, out); + } + Expr::RegisterClassParentDynamic { parent_expr, .. } => { + walk(parent_expr, out); + } _ => {} } } diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index 51ba95fa4d..739933484f 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -6331,6 +6331,48 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { for a in args { lowered.push(lower_expr(ctx, a)?); } + // Issue #894: static methods with synthetic `...arguments` + // rest params (or any user-declared rest param) need their + // trailing args bundled into an array. Without this, + // `Cls.pipe(a, b)` on a body that reads `arguments` + // emits a 2-scalar call against a 1-rest-array signature, + // leaving `arguments` bound to whichever scalar landed + // in the rest slot — `arguments.length` then reads garbage + // or hits the codegen-fallback undefined. + let has_rest = ctx.method_has_rest.get(&key).copied().unwrap_or(false); + if has_rest { + let declared_count = ctx.method_param_counts.get(&key).copied().unwrap_or(0); + if declared_count > 0 { + let fixed = declared_count.saturating_sub(1); + if lowered.len() >= fixed { + let trailing: Vec = lowered.split_off(fixed); + let arr_handle = ctx.block().call( + I64, + "js_array_alloc", + &[(I32, &trailing.len().to_string())], + ); + // js_array_push_f64 may realloc and return a + // possibly-new handle; thread it. + let mut handle_cur = arr_handle; + for v in &trailing { + handle_cur = ctx.block().call( + I64, + "js_array_push_f64", + &[(I64, &handle_cur), (DOUBLE, v)], + ); + } + let arr_box = nanbox_pointer_inline(ctx.block(), &handle_cur); + lowered.push(arr_box); + } + // Pad fixed slots with undefined when caller under-supplied. + while lowered.len() < declared_count { + // Insert undefined at the rest-slot's predecessor. + let undef = double_literal(f64::from_bits(0x7FFC_0000_0000_0001)); + let idx = lowered.len().saturating_sub(1); + lowered.insert(idx, undef); + } + } + } let arg_slices: Vec<(crate::types::LlvmType, &str)> = lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); return Ok(ctx.block().call(DOUBLE, &fn_name, &arg_slices)); @@ -10214,6 +10256,37 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // observable to user code. Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001))) } + // Issue #894: `static [Symbol.for("k")] = init` inside a + // class expression returned from a factory function. Emitted + // by HIR lowering as a `Sequence([…, RegisterClassStaticSymbol, + // ClassRef])` so each factory invocation re-registers the + // (class_id, sym_key) → value entry. Without this, the + // registration would only happen at module-init time when + // referenced free variables may not yet be assigned, and + // `isSchema(C)` (which checks `TypeId in C`) returns false on + // a freshly-returned class. + Expr::RegisterClassStaticSymbol { + class_name, + key_expr, + value_expr, + } => { + let key_v = lower_expr(ctx, key_expr)?; + let val_v = lower_expr(ctx, value_expr)?; + if let Some(&class_id) = ctx.class_ids.get(class_name) { + if class_id != 0 { + let cid_str = class_id.to_string(); + ctx.block().call_void( + "js_class_register_static_symbol", + &[ + (crate::types::I32, &cid_str), + (DOUBLE, &key_v), + (DOUBLE, &val_v), + ], + ); + } + } + Ok(double_literal(f64::from_bits(0x7FFC_0000_0000_0001))) + } // Issue #711 part 2: `.prototype = ` pattern. // Calls `js_set_function_prototype(func, proto)`, which (when // func is a closure and proto is an object) allocates a diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index 2e1d88010f..75708ac5b6 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -1128,6 +1128,22 @@ pub enum Expr { parent_expr: Box, }, + /// Issue #894: `class C { static [keyExpr] = initExpr }` where the + /// class is returned from a factory function body. The static-Symbol + /// registration must re-run each time the factory is called, with + /// the key/init evaluated against the current scope (closure + /// captures + module lets that may have been assigned by user code + /// between the class's HIR hoisting and the factory call). + /// Sequenced in front of the `ClassRef` returned from the + /// `ast::Expr::Class` lowering, parallel to + /// `RegisterClassParentDynamic`. Codegen emits a call to + /// `js_class_register_static_symbol(class_id, key, value)`. + RegisterClassStaticSymbol { + class_name: String, + key_expr: Box, + value_expr: Box, + }, + // Issue #711 part 2: `.prototype = ` pattern, // used by Effect's effectable.ts to declare prototype-based // classes. Codegen emits a call to `js_set_function_prototype` diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index f59b383b59..f5eeb47122 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -8997,16 +8997,47 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< // Sequence yields its last element, so the value remains the // ClassRef the call site expects. let parent_expr = class.extends_expr.clone(); + // Issue #894: collect computed-Symbol-key static fields so + // codegen emits a `RegisterClassStaticSymbol` registration + // sequenced in front of the ClassRef. Without this, the + // registration happens at module init via + // `init_static_fields_late` — but the values referenced by + // the key/init may not be valid yet (the factory hasn't been + // called, so any function-local captures are zero) or the + // class lookup may happen BEFORE module init's late phase + // (within the same module's top-level expressions). Effect's + // `make()` factory's `static [TypeId] = variance` is the + // canonical case: `isSchema(C)` was called from Schema.ts's + // own top-level `class extends transform(...)` chains, which + // run before the module's `init_static_fields_late`. + let static_symbol_registrations: Vec<(Expr, Expr)> = class + .static_fields + .iter() + .filter_map(|sf| match (sf.key_expr.as_ref(), sf.init.as_ref()) { + (Some(k), Some(v)) => Some((k.clone(), v.clone())), + _ => None, + }) + .collect(); ctx.pending_classes.push(class); - match parent_expr { - Some(p) => Ok(Expr::Sequence(vec![ - Expr::RegisterClassParentDynamic { - class_name: synthetic_name.clone(), - parent_expr: p, - }, - Expr::ClassRef(synthetic_name), - ])), - None => Ok(Expr::ClassRef(synthetic_name)), + let mut seq: Vec = Vec::new(); + if let Some(p) = parent_expr { + seq.push(Expr::RegisterClassParentDynamic { + class_name: synthetic_name.clone(), + parent_expr: p, + }); + } + for (k, v) in static_symbol_registrations { + seq.push(Expr::RegisterClassStaticSymbol { + class_name: synthetic_name.clone(), + key_expr: Box::new(k), + value_expr: Box::new(v), + }); + } + if seq.is_empty() { + Ok(Expr::ClassRef(synthetic_name)) + } else { + seq.push(Expr::ClassRef(synthetic_name)); + Ok(Expr::Sequence(seq)) } } ast::Expr::JSXElement(jsx) => lower_jsx_element(ctx, jsx), diff --git a/crates/perry-hir/src/stable_hash.rs b/crates/perry-hir/src/stable_hash.rs index 443de1146e..9ae745b03f 100644 --- a/crates/perry-hir/src/stable_hash.rs +++ b/crates/perry-hir/src/stable_hash.rs @@ -3571,6 +3571,16 @@ impl SH for Expr { class_name.hash(h); parent_expr.as_ref().hash(h); } + Expr::RegisterClassStaticSymbol { + class_name, + key_expr, + value_expr, + } => { + tag(h, 465); + class_name.hash(h); + key_expr.as_ref().hash(h); + value_expr.as_ref().hash(h); + } Expr::SetFunctionPrototype { func, proto } => { tag(h, 448); func.as_ref().hash(h); diff --git a/crates/perry-hir/src/walker.rs b/crates/perry-hir/src/walker.rs index fa766735b8..14a56f34ea 100644 --- a/crates/perry-hir/src/walker.rs +++ b/crates/perry-hir/src/walker.rs @@ -405,6 +405,14 @@ where Expr::RegisterClassParentDynamic { parent_expr, .. } => { f(parent_expr); } + Expr::RegisterClassStaticSymbol { + key_expr, + value_expr, + .. + } => { + f(key_expr); + f(value_expr); + } Expr::SetFunctionPrototype { func, proto } => { f(func); f(proto); @@ -1680,6 +1688,14 @@ where Expr::RegisterClassParentDynamic { parent_expr, .. } => { f(parent_expr); } + Expr::RegisterClassStaticSymbol { + key_expr, + value_expr, + .. + } => { + f(key_expr); + f(value_expr); + } Expr::SetFunctionPrototype { func, proto } => { f(func); f(proto); diff --git a/crates/perry-transform/src/inline.rs b/crates/perry-transform/src/inline.rs index bfe91801cc..08d28e9031 100644 --- a/crates/perry-transform/src/inline.rs +++ b/crates/perry-transform/src/inline.rs @@ -1066,6 +1066,28 @@ fn specialize_captured_class_factories(module: &mut Module) { base_class_counter_seed, ); } + Expr::RegisterClassStaticSymbol { + key_expr, + value_expr, + .. + } => { + rewrite_call_init( + key_expr, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + rewrite_call_init( + value_expr, + factory_targets, + classes, + new_classes, + next_class_counter, + base_class_counter_seed, + ); + } Expr::New { args, .. } => { for a in args.iter_mut() { rewrite_call_init( @@ -1966,6 +1988,7 @@ fn body_references_class_in_set(stmts: &[Stmt], set: &HashSet) -> bool { | Expr::StaticFieldSet { class_name, .. } | Expr::ClassStaticSymbolSet { class_name, .. } | Expr::RegisterClassParentDynamic { class_name, .. } + | Expr::RegisterClassStaticSymbol { class_name, .. } | Expr::StaticMethodCall { class_name, .. } => { if set.contains(class_name) { return true; diff --git a/test-files/test_issue_effect_tag_undefined.ts b/test-files/test_issue_effect_tag_undefined.ts new file mode 100644 index 0000000000..55250cd5f0 --- /dev/null +++ b/test-files/test_issue_effect_tag_undefined.ts @@ -0,0 +1,85 @@ +// Issue #894: effect's Schema.ts crashed during module init with +// `TypeError: Cannot read properties of undefined (reading '_tag')`. +// +// Root cause: a class returned from a factory function with a static +// computed-Symbol-key field (`static [TypeId] = variance`) lost that +// field after the factory call. The pre-fix codegen emitted the +// `js_class_register_static_symbol` registration once at module-init +// time, BEFORE the module's top-level lets had been assigned. Both +// the key (`Symbol.for("…")`) and the value (`variance = {…}`) were +// read from their then-uninitialised module globals — registration +// recorded `(class_id, 0, 0)`, which `class_static_symbol_lookup` +// silently couldn't match against the real key at call-time. +// +// Effect's `function make(ast) { return class { static [TypeId] = +// variance } }` factory is the canonical case — the class is returned +// from `make()` and used as a class-extends parent throughout Schema.ts. +// `isSchema(C) = hasProperty(C, TypeId) && isObject(C[TypeId])` then +// returned false on the freshly-returned class. Effect's `dual` +// dispatch fell to the curried path, downstream `class extends +// transform(...)` etc. unwound through unexpected shapes, and a +// `make()` call eventually received `undefined` as its `ast` argument, +// producing the `_tag` TypeError. +// +// The fix has two parts: +// +// 1. Move the per-class static-field initialisation from +// `init_static_fields` (pre-user-init) to a new +// `init_static_fields_late` (post-user-init). This handles +// TOP-LEVEL classes — their static-field globals see populated +// module lets when the late phase emits the store. +// +// 2. For class EXPRESSIONS returned from factory functions, sequence +// a new `Expr::RegisterClassStaticSymbol` in front of the +// `Expr::ClassRef` so each factory invocation re-emits the +// `js_class_register_static_symbol(class_id, key, value)` with +// CURRENT values of the captured/module-level free variables. +// +// The standalone shape below mirrors effect's `make()` factory. +// +// Note: the full effect smoke (`import { Effect } from "effect"; +// console.log(typeof Effect.succeed)`) is still blocked downstream +// on (a) `arguments.length` reading 0 inside FnExpr closures returned +// from another function (perry's synthetic `...arguments` rest param +// only captures TRAILING args, not all args — so `function(a, b) { +// arguments.length }` called with two args sees `arguments.length === +// 0`), and (b) static methods on classes returned from a factory not +// being routable via dynamic property access (`(make()).pipe` reads +// `undefined`). Both are separate gaps to file as follow-ups. + +const TypeId: unique symbol = Symbol.for("perry/test/issue_894") as any; + +const variance = { + _A: (x: any) => x, + _I: (x: any) => x, +}; + +function makeSchemaClass(ast: any) { + return class SchemaClass { + static ast = ast; + static [TypeId] = variance; + }; +} + +const isObject = (x: any) => + (typeof x === "object" && x !== null) || typeof x === "function"; + +const hasProperty = (u: any, prop: any): boolean => + isObject(u) && (prop in u); + +const isSchemaLike = (u: any) => + hasProperty(u, TypeId) && isObject((u as any)[TypeId]); + +const FakeAst1 = { _tag: "Alpha" }; +const A = makeSchemaClass(FakeAst1); + +console.log("typeof A:", typeof A); +console.log("TypeId in A:", TypeId in A); +console.log("A[TypeId] is object:", isObject((A as any)[TypeId])); +console.log("isSchemaLike(A):", isSchemaLike(A)); + +// Each factory invocation must re-register so a SECOND call with a +// different ast still passes the same isSchema check. +const FakeAst2 = { _tag: "Beta" }; +const B = makeSchemaClass(FakeAst2); +console.log("isSchemaLike(B):", isSchemaLike(B));