diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bed74b..f1863b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.914 — fix: #809 — object literal with computed-key props + methods + cross-module spread now compiles correctly; `Object.create(proto)` actually wires the prototype. **Symptom (reported as #809).** The Effect end-to-end repro (#321) advanced past `ParseResult.ts` and threw `TypeError: value is not a function` in `effect/src/HashRing.ts__init`. The 20-line standalone repro (`Proto = { [TypeId]: TypeId, [Symbol.iterator]() {...}, pipe() {...}, ...Inspectable.BaseProto, toJSON() {...} }` then `Object.create(Proto)`) printed `keys: 2` (should be 4 — the issue's "5" predates checking against node, which excludes the `Symbol.iterator` key from `Object.keys`), `inst.toJSON()` → `"1970-01-01T00:00:00.000Z"`, `inst[TypeId]` → `undefined`. **Root cause — four independent bugs in the chain, all required for the repro to pass:** (1) **Object-literal `has_spread` lowering dropped entries.** `crates/perry-hir/src/lower/expr_object.rs`'s spread path lowered the literal to `Expr::ObjectSpread { parts }`, whose `parts` can only carry static-string `KeyValue` + spreads. `Prop::Method` fell into `_ => {}` and computed `KeyValue` keys into `_ => continue` — so `[Symbol.iterator]()`, `pipe()`, the `[TypeId]` computed string key, and the trailing `toJSON()` were all silently discarded, leaving only the spread (hence `keys: 2` = `BaseProto`'s two keys). (2) **Over-eager `DateToJSON` lowering.** `inst.toJSON()` where `inst`'s static class is unknown was rewritten to `Expr::DateToJSON(inst)`; `js_date_to_json` then read the object pointer's NaN-box bits as a millisecond timestamp → epoch string. (3) **`js_object_create` ignored its argument.** It allocated a bare empty object and dropped `proto` entirely (`_proto_value`), so `Object.create(P)` had no prototype link — `inst.x` / `inst.m()` saw nothing. (4) **Two prototype-walk gaps exposed once (3) was fixed:** the codegen PIC fast path for `obj.prop` spuriously "hit" when `obj->keys_array == null` (an `Object.create` result) because the zero-initialized per-site cache matched, returning the empty slot[0] without ever invoking the runtime miss handler; and `js_object_get_field_by_name` early-returned `undefined` on `keys_array == null` *before* its `class_id` prototype-walk, while `js_native_call_method`'s prototype-object walk was gated inside `if let Some(ref reg) = *CLASS_VTABLE_REGISTRY` (skipped entirely for a program with no user classes). **Fix.** (1) Rewrote the `has_spread` path as a fully **source-ordered IIFE** (`((__o) => { …ordered ops…; return __o })({})`) so static props, computed keys, `this`-binding methods, and spreads interleave in source order (a later prop/spread overrides an earlier same key, matching JS — the non-spread fast paths can't be used because they apply all static props before any post-init, which would let a trailing `...src` clobber the literal's own `toJSON()`). Spreads emit `js_object_assign_one(__o, src)`; static `this`-methods a new `js_object_set_method_by_name` runtime helper (string-key analog of `js_object_set_symbol_method` — sets the field AND patches the closure's reserved last `this` capture slot); computed `this`-methods reuse `js_object_set_symbol_method`; everything else `IndexSet`. Key-resolution and method-lowering were extracted into shared `resolve_keyvalue_key` / `lower_method_prop` helpers used by both the spread and non-spread paths (behavior-identical for the non-spread path). (2) `static_receiver_class` now returns `Some("Object")` for object-literal / `Object.create(...)` receivers and for locals tracked in a new `LoweringContext.plain_object_locals` set (populated in `lower_var_decl_with_destructuring` when the initializer is an object literal or `Object.create(...)`); the ambiguous-Date-method gate treats `Some("Object")` like `Some("URL")` and skips the Date arms. (3) `js_object_create` now allocates a synthetic class_id, maps it to `proto` in `CLASS_PROTOTYPE_OBJECTS`, and stamps the new object with it (reusing the #711 prototype-object machinery); `Object.create(null)` / non-object / builtin-backed sources fall back to the original prototype-less object. (4) Added a non-null `keys_array` requirement to the PIC hit predicate so keyless receivers fall to the slow path; `js_object_get_field_by_name`'s `keys_array == null` branch now consults the prototype chain before returning undefined (via a new shared `resolve_proto_chain_field` helper, which also de-duplicates the existing inline #711 walk); and `js_native_call_method` runs an independent `resolve_proto_chain_field` resolution after the registry walk so `Object.create(objLiteral).method()` works even when `CLASS_VTABLE_REGISTRY` is `None`. **Validation.** The exact #809 repro (`entry.ts` / `insp.ts`) is now byte-identical to `node --experimental-strip-types`: `keys: 4`, `inst.toJSON: {"_id":"HashRing","x":1}`, `inst[TypeId]: ~test/HashRing`. Gap suite 34/36 (the 2 failures — `test_gap_console_methods`, `test_gap_regexp_advanced` — are the pre-existing known categorical gaps, console.dir/group + lookbehind regex, unchanged). All `cargo test` for perry-hir / perry-codegen / perry-runtime green (0 failed). 12-file curated smoke over spread/object/prototype test-files (incl. `test_issue_711_function_prototype`, `test_spread`, `test_edge_rest_spread_defaults`, `test_get_prototype_of_instance`) byte-identical to node. Targeted regression run confirms real `new Date(0).toJSON()`/`toISOString()` still work, spread override ordering (`{a:1,...{a:2}}`→`{a:2}`, `{...{a:2},a:1}`→`{a:1}`), `Object.create(null)`, empty-object missing-prop, and `Object.create(proto).method()` with `this`-binding all match node. **Known remaining gap (pre-existing, not a regression, out of #809 scope).** Symbol-keyed property *inheritance* through an `Object.create` prototype chain (`o[sym]` where `sym` is on the proto) still returns `undefined` — the symbol side-table is keyed by the receiver pointer and the prototype walk only resolves string keys. Not exercised by #809's DoD (`Object.keys` excludes symbols; `inst[TypeId]` is a string key) and `Object.create` returned nothing before this commit, so symbol inheritance through it never worked. Tracked separately. `Object.getPrototypeOf(Object.create(p)) === p` likewise still returns the class-ref shim, not `p`. Closes #809. Refs #321, #711. + ## v0.5.913 — fix(runtime): #748 follow-up — `new Date(NaN)` / Invalid Date is now a recognizable Date object (`typeof === "object"`, `instanceof Date === true`) instead of an untagged bare NaN that `typeof` reported as `"number"`. **Symptom (per #748 comment).** After the v0.5.912 condvar fix, the shop-admin signup still failed: `typeof new Date(NaN)` returned `"number"` under perry vs `"object"` under tsx/node, so `MyDateTime.toDate(): Date { … if (zero) return new Date(NaN); … }` from `@perryts/mysql` produced an untagged bare NaN that downstream `dtToIso` / dynamic `.getTime()` dispatched as a number method → `getTime is not a function` → fastify route promise resolved to the same `0.0` symptom as the original #748. The deterministic 3-line repro from the comment: `function w5(y: number) { return { toDate(): Date { return new Date(NaN); } }; } console.log(typeof w5(2026).toDate());` — perry: `"number"`; node: `"object"`. **Root cause.** Perry stores `Date` as a raw f64 timestamp with no NaN-box tag and consults a thread-local `DATE_REGISTRY: HashSet` of registered bit patterns from `typeof` / `instanceof Date` / JSON / promise side-table dispatch. `js_date_new_from_value` skipped registration entirely when `result.is_nan()` (`crates/perry-runtime/src/date.rs:156` pre-fix), so `new Date(NaN)` flowed out as a bare unregistered NaN — and the two `instanceof` sites in `crates/perry-runtime/src/object.rs` (`CLASS_ID_DATE` line 5051, `CLASS_ID_OBJECT` line 5098) both gated their registry lookup behind `!value.is_nan() && value.is_finite()` so even if the bits *were* registered, Invalid Date couldn't possibly match. Net: ECMA-262 §21.4.1.1 says `new Date(NaN)` is an Invalid Date — `typeof` "object", `instanceof Date` true, time value NaN — and perry got all three wrong. The seven string formatters (`js_date_to_iso_string`, `js_date_to_date_string`, `js_date_to_time_string`, `js_date_to_locale_*_string`) also lacked NaN guards and cast NaN-as-i64 → `0`, emitting bogus `1970-01-01T00:00:00.000Z` (the `` zero-branch output in the bug report). **Fix.** Single canonical Invalid-Date sentinel `DATE_NAN_BITS = 0x7FF8_0000_0000_0DA7` — a quiet NaN (exponent all ones, mantissa MSB set per IEEE-754 §6.2.1) in the 0x7FF8 space, which `JSValue::is_number` already treats as a plain number rather than a NaN-box tag, so the value flows through arithmetic and every existing `if timestamp.is_nan() { return f64::NAN }` getter exactly like a bare NaN. Recognition is by exact bit pattern, *globally*, so the sentinel works across the socket-thread / main-thread boundary without registration — no thread-local lookup required. (1) `is_registered_date_bits` short-circuits `bits == DATE_NAN_BITS` before consulting the existing HashSet; the finite-Date registry path is byte-identical to before, which is what diverges from the reverted attempt the issue comment describes — that earlier try also touched finite registration ("stop value-registering finite millis") and regressed `Date.UTC(...)` valid dates, so we deliberately do not touch the finite path. (2) `js_date_new_from_timestamp` / `js_date_new_from_value` now route NaN through `date_or_invalid` → the sentinel. (3) Both `js_instanceof` arms (`CLASS_ID_DATE`, `CLASS_ID_OBJECT`) match `value.to_bits() == DATE_NAN_BITS` *before* the `!is_nan()` guard so Invalid Date is still a Date and still an Object per spec. (4) `typeof` is already correct for free because `js_value_typeof`'s f64 fallthrough calls `is_registered_date_bits(bits)` unconditionally (no NaN gate at that site). (5) Each of the six string formatters now early-returns `"Invalid Date"` for NaN — matches `String(new Date(NaN))` / `.toDateString()` etc. and replaces the `1970-…` garbage. Promise assimilation (`promise.rs:2381`) and JSON `write_number` (`json.rs:1558`) are unchanged: both gate on `is_pointer()` / `is_finite()` so the sentinel falls through their early-return arms as a NaN, which is correct — `await new Date(NaN)` resolves with itself, `JSON.stringify(new Date(NaN))` serializes as `null` (Date.prototype.toJSON-of-invalid spec behavior, satisfied here by the existing NaN → "null" arm in `write_number`). Roughly 50 lines of net code change across two files (`crates/perry-runtime/src/{date.rs,object.rs}`). **Out of scope.** Options 1 and 2-second-half from the issue comment — tagging Dates with a dedicated NaN-box tag space, or eliminating the value-keyed thread-local registry altogether — are correct soundness fixes for the broader `100 instanceof Date` false-positive and the cross-thread loss cases, but require a workspace-wide refactor of every Date method signature, codegen call site, and runtime side-table. This patch closes the specific #748 follow-up (`new Date(NaN)` representation) and the `` 1970 strings; the false-positive/cross-thread cases stay tracked separately. **Validation.** `test-files/test_issue_748_invalid_date.ts` mirrors the comment's minimal `w5` repro plus the `makeMyDateTime` / `dtToIso` shop-admin shape and a valid-Date regression block. Compiled + run against the patched runtime: `typeof w5(2026).toDate()` is `"object"` (was `"number"`), `new Date(NaN) instanceof Date` is `true`, `instanceof Object` is `true`, `.getTime()` is `NaN`, `String(invalid)` is `"Invalid Date"` (was the `1970-01-01…` garbage), `JSON.stringify(invalid)` is `"null"`, `dtToIso(makeMyDateTime(2026, 5, 15))` is `"2026-05-15T07:47:04.192Z"`, `dtToIso(makeMyDateTime(0,0,0))` is `null`. Regression block: `Date.UTC(2026,4,15,…)` round-trips through `getTime` / `toUTCFullYear` / `toISOString` / `JSON.stringify`, plain numbers still report `typeof "number"`, `Date.now()` still reports `typeof "number"` (it returns a number, not a Date — matches JS), `new Date()` still `instanceof Date`. Closes the #748 follow-up. ## v0.5.912 — fix(fastify): #748 — replace 1-second polling timeout in `wait_for_promise` with a condvar wait, and surface rejected promises as `HTTP 500` instead of body `0`. **Symptom (reported as #748).** A native-compiled Fastify route doing `await checkSignup(); await getUserByEmail(); await hashPassword(); await createUser(); await createAccount(); await addMember(); await issueSession(); …` against `@perryts/mysql` returned the wire byte `0x30` (literal ASCII `0`) with HTTP 200 instead of the explicit `return { accessToken, … }`. The first INSERT committed; every subsequent `await pool.exec(...)` silently no-op'd (no error, no log, no DB row). The `tsx` baseline returned the correct JSON with HTTP 201 against the same code + same DB. **Root cause.** Both `crates/perry-ext-fastify/src/server.rs::wait_for_promise()` and the in-tree mirror at `crates/perry-stdlib/src/fastify/server.rs::wait_for_promise()` polled `js_promise_state` 10000 × 100 µs (≈ 1 s) and then returned regardless of state. After return, `js_promise_value(ptr)` (initialized to `0.0` in `Promise::new` per `crates/perry-runtime/src/promise.rs`) gave Rust `f64 0.0` for any Pending or Rejected promise. `build_response_body` then serialized that as wire byte `0x30` with the default `status_code = 200` (the route's `reply.code(201)` never ran). The orphaned chain was also abandoned: once the dispatcher returned, microtasks stopped pumping, so every queued INSERT after the timeout silently no-op'd — matching the "first INSERT commits, rest don't" symptom. The bug only surfaced once the await chain exceeded the 1 s budget, which is why the user's argon2 + JWT + multiple mysql writes hit it but minimal Fastify probes (object/string/number returns, two sequential `await delay()`s) didn't. **Fix.** (1) Replace the polling/sleep loop with a condvar-based wait via `js_wait_for_event` — the same primitive the codegen-emitted `await` body already uses. No fixed iteration limit; wakes the moment any stdlib worker calls `js_notify_main_thread` (which `js_promise_resolve` / `reject` already do). Drives timer ticks + stdlib pump + microtasks every iteration so chained timers / mysql / fetch awaits all keep moving. (2) After `wait_for_promise` returns, branch on `js_promise_state`: state == 2 (Rejected) now produces an HTTP 500 response with a `{ "error": }` JSON envelope (via new `render_rejection_body`) instead of letting `js_promise_value` return `0.0` and masquerade as a successful response. **Validation.** `/tmp/repro748_fastify.ts` — Fastify handler with 8 × 300 ms sequential awaits (2.4 s total chain). Pre-fix would return HTTP 200 body `0`; post-fix returns the full JSON body + HTTP 201 after ≈ 2.46 s. Existing `test_fastify_integration.ts` still passes; 10 fastify-ext unit tests + 22 fastify-stdlib unit tests all pass. Gap suite unchanged (33 pass / 2 pre-existing failures). **Caveat.** Could not exactly reproduce the user's "first INSERT commits, rest silently no-op" symptom in a synthetic repro without `@perryts/mysql` linkage — the 1 s timeout path is the only place `js_promise_value(0.0_initial)` reaches the response writer and matches every observable symptom (wire byte `0x30`, HTTP 200, dropped explicit return, abandoned subsequent awaits). Worth re-running skelpo-shop-admin against this build to confirm; if it still repros, `PERRY_MT_PROFILE=1` will narrow the stall point. Closes #748. diff --git a/CLAUDE.md b/CLAUDE.md index 5b368ea6..a92384c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.913 +**Current Version:** 0.5.914 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 5e59cf2a..0f6e6466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.913" +version = "0.5.914" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.913" +version = "0.5.914" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.913" +version = "0.5.914" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.913" +version = "0.5.914" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.913" +version = "0.5.914" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.913" +version = "0.5.914" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.913" +version = "0.5.914" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.913" +version = "0.5.914" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.913" +version = "0.5.914" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.913" +version = "0.5.914" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.913" +version = "0.5.914" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.913" +version = "0.5.914" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.913" +version = "0.5.914" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.913" +version = "0.5.914" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.913" +version = "0.5.914" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.913" +version = "0.5.914" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.913" +version = "0.5.914" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.913" +version = "0.5.914" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.913" +version = "0.5.914" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.913" +version = "0.5.914" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.913" +version = "0.5.914" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.913" +version = "0.5.914" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.913" +version = "0.5.914" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.913" +version = "0.5.914" [[package]] name = "perry-ui-android" -version = "0.5.913" +version = "0.5.914" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.913" +version = "0.5.914" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.913" +version = "0.5.914" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.913" +version = "0.5.914" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.913" +version = "0.5.914" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.913" +version = "0.5.914" [[package]] name = "perry-ui-tvos" -version = "0.5.913" +version = "0.5.914" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.913" +version = "0.5.914" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.913" +version = "0.5.914" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.913" +version = "0.5.914" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.913" +version = "0.5.914" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.913" +version = "0.5.914" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 5153b329..f77901ee 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.913" +version = "0.5.914" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index c4fe347d..a29a3e1a 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -4062,7 +4062,19 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cache_keys_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); let cached_keys = ctx.block().load(I64, &cache_keys_ptr); let keys_eq = ctx.block().icmp_eq(I64, &keys_val, &cached_keys); - let hit = ctx.block().and(I1, &is_object, &keys_eq); + // #809: an object with `keys_array == null` (e.g. an + // `Object.create(proto)` result, or any object with no own + // string props) has no cacheable own-slot. The per-site cache + // global is zero-initialized, so `keys_val (0) == cached_keys + // (0)` spuriously "hits" and the hit path returns the empty + // slot[0] — never invoking the miss handler, so the runtime's + // prototype-chain walk in `js_object_get_field_by_name` is + // skipped and `Object.create(P).m()` reads `undefined`. Require + // a non-null keys_array for a hit so keyless receivers fall to + // the slow path (which resolves inherited props correctly). + let keys_nonnull = ctx.block().icmp_ne(I64, &keys_val, "0"); + let hit_keys = ctx.block().and(I1, &is_object, &keys_eq); + let hit = ctx.block().and(I1, &hit_keys, &keys_nonnull); let hit_idx = ctx.new_block("pic.hit"); let miss_idx = ctx.new_block("pic.miss"); diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index 1534476d..1695ab39 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -2504,6 +2504,17 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { DOUBLE, &[DOUBLE, DOUBLE, DOUBLE], ); + // #809: string-key analog of `js_object_set_symbol_method`. Used by the + // ordered-IIFE lowering of object literals that mix a spread with + // `this`-binding methods (Effect `HashRing.ts` `Proto`). Sets the field + // by name AND patches the closure's reserved (last) `this` capture slot + // with the object, so a method written after a `...spread` still sees + // the right receiver. + module.declare_function( + "js_object_set_method_by_name", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); module.declare_function("js_to_primitive", DOUBLE, &[DOUBLE, I32]); module.declare_function("js_register_class_has_instance", VOID, &[I32, I64]); module.declare_function("js_register_class_to_string_tag", VOID, &[I32, I64]); diff --git a/crates/perry-hir/src/destructuring.rs b/crates/perry-hir/src/destructuring.rs index 32bf6463..346b0a78 100644 --- a/crates/perry-hir/src/destructuring.rs +++ b/crates/perry-hir/src/destructuring.rs @@ -1089,6 +1089,34 @@ pub(crate) fn lower_var_decl_with_destructuring( ast::Pat::Ident(ident) => { // Simple binding: let x = expr let name = ident.id.sym.to_string(); + + // #809: tag locals provably bound to a plain object (an object + // literal or `Object.create(...)`). `static_receiver_class` + // consults this so `x.toJSON()` / `.toString()` / `.valueOf()` + // etc. on such a local fall through to generic dynamic dispatch + // instead of the Date intrinsics (which would interpret the + // object pointer's bits as a timestamp). + if let Some(init_expr) = decl.init.as_deref() { + let is_plain_object = match init_expr { + ast::Expr::Object(_) => true, + ast::Expr::Call(call) => { + if let ast::Callee::Expr(callee) = &call.callee { + matches!( + callee.as_ref(), + ast::Expr::Member(m) + if matches!(m.obj.as_ref(), ast::Expr::Ident(o) if o.sym.as_ref() == "Object") + && matches!(&m.prop, ast::MemberProp::Ident(p) if p.sym.as_ref() == "create") + ) + } else { + false + } + } + _ => false, + }; + if is_plain_object { + ctx.plain_object_locals.insert(name.clone()); + } + } let mut ty = ident .type_ann .as_ref() diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index eb476d8a..aa3ad9f4 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -230,6 +230,13 @@ pub struct LoweringContext { /// the receiver is a tracked instance, avoiding false matches against /// CJS-style `module.exports.foo()` patterns. pub(crate) wasm_instance_locals: HashSet, + /// #809: locals whose initializer is an object literal or + /// `Object.create(...)` — i.e. provably a plain object, never a Date. + /// Consulted by `static_receiver_class` so `obj.toJSON()` / + /// `obj.toString()` / `obj.valueOf()` etc. don't get rewritten to the + /// Date intrinsics (which would read the object pointer's bits as a + /// timestamp and print `1970-01-01T00:00:00.000Z`). + pub(crate) plain_object_locals: HashSet, pub(crate) proxy_revoke_locals: HashMap, /// For `const p = new Proxy(ClassName, handler)`, record the class name /// so `new p(args)` can fold to `new ClassName(args)` (pragmatic — lets @@ -377,6 +384,7 @@ impl LoweringContext { regex_exec_locals: HashSet::new(), proxy_locals: HashSet::new(), wasm_instance_locals: HashSet::new(), + plain_object_locals: HashSet::new(), proxy_revoke_locals: HashMap::new(), proxy_target_classes: HashMap::new(), class_expr_aliases: HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_call.rs b/crates/perry-hir/src/lower/expr_call.rs index b9207bb0..02888c1e 100644 --- a/crates/perry-hir/src/lower/expr_call.rs +++ b/crates/perry-hir/src/lower/expr_call.rs @@ -115,8 +115,31 @@ fn static_receiver_class(ctx: &LoweringContext, obj: &ast::Expr) -> Option<&'sta }; } } + // #809: an object literal receiver, or `Object.create(...)`, is + // provably a plain object — never a Date. Returning `Some("Object")` + // makes the ambiguous-Date-method gate skip the Date arms for + // `({...}).toJSON()` / `Object.create(p).toJSON()` the same way it + // does for URL, so the call falls through to generic dynamic dispatch + // and finds the object's own method. + if matches!(obj, ast::Expr::Object(_)) { + return Some("Object"); + } + if let ast::Expr::Call(call) = obj { + if let ast::Callee::Expr(callee) = &call.callee { + if let ast::Expr::Member(m) = callee.as_ref() { + if matches!(m.obj.as_ref(), ast::Expr::Ident(o) if o.sym.as_ref() == "Object") + && matches!(&m.prop, ast::MemberProp::Ident(p) if p.sym.as_ref() == "create") + { + return Some("Object"); + } + } + } + } if let ast::Expr::Ident(ident) = obj { let name = ident.sym.as_ref(); + if ctx.plain_object_locals.contains(name) { + return Some("Object"); + } if let Some(ty) = ctx.lookup_local_type(name) { if let Type::Named(n) = ty { return match n.as_str() { @@ -3055,7 +3078,9 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } else { None }; - let allow_ambiguous_date = recv_class != Some("URL"); + // #809: `Some("Object")` (object literal / `Object.create`) + // joins URL as a "definitely not a Date" receiver. + let allow_ambiguous_date = !matches!(recv_class, Some("URL") | Some("Object")); // Methods we treat as Date-only when the receiver is unambiguously // Date or unknown (current behavior). `toString` / `toJSON` etc. // skip these arms when `recv_class` proves the receiver is NOT a Date. diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index c3d7aaa3..455b1e85 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -27,6 +27,224 @@ use crate::lower_types::{extract_param_type_with_ctx, extract_ts_type_with_ctx}; use super::{lower_expr, LoweringContext}; +/// Resolution of an object-literal `KeyValue` property key. +enum KeyResolution { + /// A statically-known string key (`x:`, `"x":`, `1:`, `[Enum.M]:`, + /// `["lit"]:`, `[1]:`). + Static(String), + /// A computed key whose value is only known at runtime + /// (`[symLocal]:`, `[TypeId]:`, `[expr]:`). + Dynamic(Expr), + /// Key shape we don't model — skip the property. + Skip, +} + +/// Resolve a `KeyValue` property name to a static string, a dynamic key +/// expression, or skip. Extracted (verbatim) from the legacy `lower_object` +/// loop so the spread path can reuse the identical resolution rules. +fn resolve_keyvalue_key(ctx: &mut LoweringContext, key: &ast::PropName) -> KeyResolution { + match key { + ast::PropName::Ident(ident) => KeyResolution::Static(ident.sym.to_string()), + ast::PropName::Str(s) => KeyResolution::Static(s.value.as_str().unwrap_or("").to_string()), + ast::PropName::Num(n) => KeyResolution::Static(n.value.to_string()), + ast::PropName::Computed(computed) => { + // Handle computed property keys like [ChainName.ETHEREUM] + // Try to resolve enum member access to string keys first. + match computed.expr.as_ref() { + ast::Expr::Member(member) => { + if let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + { + let enum_name = obj.sym.to_string(); + let member_name = prop.sym.to_string(); + if let Some(value) = ctx.lookup_enum_member(&enum_name, &member_name) { + match value { + EnumValue::String(s) => KeyResolution::Static(s.clone()), + EnumValue::Number(n) => KeyResolution::Static(n.to_string()), + } + } else { + // Non-enum member access: lower as a dynamic expression. + match lower_expr(ctx, computed.expr.as_ref()) { + Ok(e) => KeyResolution::Dynamic(e), + Err(_) => KeyResolution::Skip, + } + } + } else { + match lower_expr(ctx, computed.expr.as_ref()) { + Ok(e) => KeyResolution::Dynamic(e), + Err(_) => KeyResolution::Skip, + } + } + } + ast::Expr::Lit(ast::Lit::Str(s)) => { + KeyResolution::Static(s.value.as_str().unwrap_or("").to_string()) + } + ast::Expr::Lit(ast::Lit::Num(n)) => KeyResolution::Static(n.value.to_string()), + // Identifier or any other expression — lower it and defer to + // post-init IndexSet so symbol-typed locals like `[symProp]` + // flow through the IndexSet symbol dispatch path. + _ => match lower_expr(ctx, computed.expr.as_ref()) { + Ok(e) => KeyResolution::Dynamic(e), + Err(_) => KeyResolution::Skip, + }, + } + } + _ => KeyResolution::Skip, + } +} + +/// Resolution of an object-literal `Method` property key. +enum MethodKeyKind { + Static(String), + Computed(Expr), +} + +/// Lower an object-literal method (`m() {}`, `[Symbol.x]() {}`) into its +/// value expression (a `FuncRef` for capture-free non-`this` methods, else a +/// `Closure`) plus its key and whether the body uses `this`. +/// +/// Returns `Ok(None)` for key shapes the legacy loop skipped (a `Num`/other +/// non-computed PropName, or a computed key that failed to lower). Extracted +/// verbatim from the legacy `lower_object` loop so both the spread and +/// non-spread paths share one implementation (no behavioral drift). +fn lower_method_prop( + ctx: &mut LoweringContext, + method: &ast::MethodProp, +) -> Result> { + let method_key = match &method.key { + ast::PropName::Ident(ident) => MethodKeyKind::Static(ident.sym.to_string()), + ast::PropName::Str(s) => MethodKeyKind::Static(s.value.as_str().unwrap_or("").to_string()), + ast::PropName::Computed(computed) => match lower_expr(ctx, computed.expr.as_ref()) { + Ok(e) => MethodKeyKind::Computed(e), + Err(_) => return Ok(None), + }, + _ => return Ok(None), + }; + let key_label: String = match &method_key { + MethodKeyKind::Static(s) => s.clone(), + MethodKeyKind::Computed(_) => format!("computed_{}", ctx.next_func_id), + }; + let key: String = key_label.clone(); + let func_id = ctx.fresh_func(); + // Use a unique synthetic name to avoid collisions + let func_name = format!("__obj_method_{}_{}", key, func_id); + + // Snapshot outer locals for capture analysis + let outer_locals: Vec<(String, LocalId)> = ctx + .locals + .iter() + .map(|(name, id, _)| (name.clone(), *id)) + .collect(); + + let scope_mark = ctx.enter_scope(); + let mut params = Vec::new(); + for param in method.function.params.iter() { + let param_name = get_pat_name(¶m.pat)?; + let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); + let param_default = get_param_default(ctx, ¶m.pat)?; + let param_id = ctx.define_local(param_name.clone(), param_type.clone()); + params.push(Param { + id: param_id, + name: param_name, + ty: param_type, + default: param_default, + decorators: Vec::new(), + is_rest: is_rest_param(¶m.pat), + }); + } + let return_type = method + .function + .return_type + .as_ref() + .map(|rt| extract_ts_type_with_ctx(&rt.type_ann, Some(ctx))) + .unwrap_or(Type::Any); + let body = if let Some(ref block) = method.function.body { + lower_block_stmt(ctx, block)? + } else { + Vec::new() + }; + ctx.exit_scope(scope_mark); + + // Capture analysis (same pattern as arrow/function expressions) + let mut all_refs = Vec::new(); + let mut visited_closures = std::collections::HashSet::new(); + for stmt in &body { + collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); + } + let outer_local_ids: std::collections::HashSet = + outer_locals.iter().map(|(_, id)| *id).collect(); + let method_param_ids: std::collections::HashSet = + params.iter().map(|p| p.id).collect(); + let mut captures: Vec = all_refs + .into_iter() + .filter(|id| outer_local_ids.contains(id) && !method_param_ids.contains(id)) + .collect(); + captures.sort(); + captures.dedup(); + captures = ctx.filter_module_level_captures(captures); + + // Check if the method body uses `this` — even with no outer-scope + // captures we must emit a Closure so the object-literal creation code + // can patch capture slot 0 with the object pointer. + let uses_this = closure_uses_this(&body); + + let value_expr: Expr = if captures.is_empty() && !uses_this { + // No captures and no `this`: keep as standalone Function + FuncRef + ctx.register_func(func_name.clone(), func_id); + let defaults: Vec> = params.iter().map(|p| p.default.clone()).collect(); + let param_ids: Vec = params.iter().map(|p| p.id).collect(); + let rest_idx = params.iter().position(|p| p.is_rest); + ctx.func_defaults + .push((func_id, defaults, param_ids, rest_idx)); + ctx.pending_functions.push(Function { + id: func_id, + name: func_name, + type_params: Vec::new(), + params, + return_type, + body, + is_async: method.function.is_async, + is_generator: false, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }); + Expr::FuncRef(func_id) + } else { + // Has captures: emit as Closure + let mut all_assigned = Vec::new(); + for stmt in &body { + collect_assigned_locals_stmt(stmt, &mut all_assigned); + } + let assigned_set: std::collections::HashSet = all_assigned.into_iter().collect(); + let mutable_captures: Vec = captures + .iter() + .filter(|id| assigned_set.contains(id) || ctx.var_hoisted_ids.contains(id)) + .copied() + .collect(); + let captures_this = uses_this; + let enclosing_class = if captures_this { + ctx.current_class.clone() + } else { + None + }; + Expr::Closure { + func_id, + params, + return_type, + body, + captures, + mutable_captures, + captures_this, + enclosing_class, + is_async: method.function.is_async, + } + }; + Ok(Some((method_key, value_expr, uses_this))) +} + pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> Result { // Phase 3: closed-shape object literals lower to `new __AnonShape_N()` // so downstream field access hits the direct-GEP fast path. The @@ -134,35 +352,79 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } // Legacy path — spread, methods/getters/setters, computed keys, // dup keys, or unresolvable shorthand. - // - // Check if any spread elements exist; if so, use ObjectSpread let has_spread = obj .props .iter() .any(|p| matches!(p, ast::PropOrSpread::Spread(_))); if has_spread { - let mut parts: Vec<(Option, Expr)> = Vec::new(); + // #809: an object literal that mixes a `...spread` with computed + // keys, methods, and `this`-binding methods. The old code lowered + // this to `Expr::ObjectSpread { parts }`, whose `parts` list can + // only express static-string `KeyValue` and spreads — it silently + // DROPPED every `Prop::Method` and every computed `KeyValue` (the + // `_ => continue` / `_ => {}` arms). For Effect's `HashRing.ts` + // `Proto` this dropped `[Symbol.iterator]()`, `pipe()`, the + // `[TypeId]` computed string key, and the trailing `toJSON()`, + // leaving only the spread — hence `keys: 2` and the + // `value is not a function` crash on the first method dispatch. + // + // Lower instead to a fully SOURCE-ORDERED IIFE so spreads + // interleave correctly with the other entries (a later property + // or spread overrides an earlier same key, per JS semantics — the + // non-spread fast paths can't be used because they apply every + // static prop before any post-init, which would let a trailing + // `...src` clobber the literal's own `toJSON()`): + // + // ((__o) => { + // __o["k"] = v; // static / computed + // js_object_set_method_by_name(__o, "m", clo); // static this-method + // js_object_set_symbol_method(__o, sym, clo); // computed this-method + // js_object_assign_one(__o, src); // ...src + // return __o; + // })({}) + enum SpreadOp { + /// `__o[key] = value` (key = `String(..)` or a dynamic expr). + Set { key: Expr, value: Expr }, + /// Static-string method whose body uses `this`. + MethodByName { key: String, closure: Expr }, + /// Computed-key method whose body uses `this`. + SymbolMethod { key: Expr, closure: Expr }, + /// `...src` — copy src's own enumerable string+symbol props. + Assign { src: Expr }, + } + + // Pass 1: lower every entry's value (BEFORE the IIFE scope exists, + // mirroring the computed-key path which lowers method bodies + // outside the wrapper scope). + let mut ops: Vec = Vec::new(); for prop in &obj.props { match prop { ast::PropOrSpread::Spread(spread) => { - let spread_expr = lower_expr(ctx, &spread.expr)?; - parts.push((None, spread_expr)); + let src = lower_expr(ctx, &spread.expr)?; + ops.push(SpreadOp::Assign { src }); } ast::PropOrSpread::Prop(prop) => match prop.as_ref() { - ast::Prop::KeyValue(kv) => { - let key = match &kv.key { - ast::PropName::Ident(ident) => ident.sym.to_string(), - ast::PropName::Str(s) => s.value.as_str().unwrap_or("").to_string(), - ast::PropName::Num(n) => n.value.to_string(), - _ => continue, - }; - let value = lower_expr(ctx, &kv.value)?; - parts.push((Some(key), value)); - } + ast::Prop::KeyValue(kv) => match resolve_keyvalue_key(ctx, &kv.key) { + KeyResolution::Skip => {} + KeyResolution::Static(key) => { + let value = lower_expr(ctx, &kv.value)?; + ops.push(SpreadOp::Set { + key: Expr::String(key), + value, + }); + } + KeyResolution::Dynamic(key_expr) => { + let value = lower_expr(ctx, &kv.value)?; + ops.push(SpreadOp::Set { + key: key_expr, + value, + }); + } + }, ast::Prop::Shorthand(ident) => { let name = ident.sym.to_string(); - // Issue #624: same prefer-local order as the closed-shape - // shorthand fold above — see comment there. + // Issue #624: same prefer-local order as the + // closed-shape shorthand fold above. let value = if let Some(local_id) = ctx.lookup_local(&name) { Expr::LocalGet(local_id) } else if let Some(func_id) = ctx.lookup_func(&name) { @@ -172,13 +434,136 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } else { continue; }; - parts.push((Some(name), value)); + ops.push(SpreadOp::Set { + key: Expr::String(name), + value, + }); } + ast::Prop::Method(method) => { + let Some((mkey, value_expr, uses_this)) = lower_method_prop(ctx, method)? + else { + continue; + }; + match mkey { + MethodKeyKind::Static(k) => { + if uses_this { + ops.push(SpreadOp::MethodByName { + key: k, + closure: value_expr, + }); + } else { + ops.push(SpreadOp::Set { + key: Expr::String(k), + value: value_expr, + }); + } + } + MethodKeyKind::Computed(ke) => { + if uses_this { + ops.push(SpreadOp::SymbolMethod { + key: ke, + closure: value_expr, + }); + } else { + ops.push(SpreadOp::Set { + key: ke, + value: value_expr, + }); + } + } + } + } + // Getters/setters in object literals remain a + // categorical gap (matches the non-spread path's + // `_ => {}`); not required by #809. _ => {} }, } } - return Ok(Expr::ObjectSpread { parts }); + + // Pass 2: build the IIFE wrapper. `__o` starts as an empty object + // and each op mutates it in source order. + let iife_func_id = ctx.fresh_func(); + let scope_mark = ctx.enter_scope(); + let param_id = ctx.define_local("__perry_obj_iife".to_string(), Type::Any); + let param = Param { + id: param_id, + name: "__perry_obj_iife".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + }; + let extern_call = |name: &str, args: Vec| Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: name.to_string(), + param_types: Vec::new(), + return_type: Type::Any, + }), + args, + type_args: Vec::new(), + }; + let mut body: Vec = Vec::with_capacity(ops.len() + 1); + for op in ops { + match op { + SpreadOp::Set { key, value } => { + body.push(Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(param_id)), + index: Box::new(key), + value: Box::new(value), + })); + } + SpreadOp::MethodByName { key, closure } => { + body.push(Stmt::Expr(extern_call( + "js_object_set_method_by_name", + vec![Expr::LocalGet(param_id), Expr::String(key), closure], + ))); + } + SpreadOp::SymbolMethod { key, closure } => { + body.push(Stmt::Expr(extern_call( + "js_object_set_symbol_method", + vec![Expr::LocalGet(param_id), key, closure], + ))); + } + SpreadOp::Assign { src } => { + body.push(Stmt::Expr(extern_call( + "js_object_assign_one", + vec![Expr::LocalGet(param_id), src], + ))); + } + } + } + body.push(Stmt::Return(Some(Expr::LocalGet(param_id)))); + ctx.exit_scope(scope_mark); + + // Capture analysis — identical to the computed-key IIFE below. + let mut all_refs = Vec::new(); + let mut visited_closures = std::collections::HashSet::new(); + for stmt in &body { + collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); + } + let mut captures: Vec = + all_refs.into_iter().filter(|id| *id != param_id).collect(); + captures.sort(); + captures.dedup(); + captures = ctx.filter_module_level_captures(captures); + let body_uses_this = body.iter().any(uses_this_stmt); + let closure = Expr::Closure { + func_id: iife_func_id, + params: vec![param], + return_type: Type::Any, + body, + captures, + mutable_captures: Vec::new(), + captures_this: body_uses_this, + enclosing_class: None, + is_async: false, + }; + return Ok(Expr::Call { + callee: Box::new(closure), + args: vec![Expr::Object(Vec::new())], + type_args: vec![], + }); } let mut props = Vec::new(); // Computed keys whose value can't be folded to a string at HIR time @@ -199,86 +584,20 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R for prop in &obj.props { if let ast::PropOrSpread::Prop(prop) = prop { match prop.as_ref() { - ast::Prop::KeyValue(kv) => { - enum KeyResolution { - Static(String), - Dynamic(Expr), - Skip, + ast::Prop::KeyValue(kv) => match resolve_keyvalue_key(ctx, &kv.key) { + KeyResolution::Skip => continue, + KeyResolution::Static(key) => { + let value = lower_expr(ctx, &kv.value)?; + props.push((key, value)); } - let key_resolution: KeyResolution = match &kv.key { - ast::PropName::Ident(ident) => KeyResolution::Static(ident.sym.to_string()), - ast::PropName::Str(s) => { - KeyResolution::Static(s.value.as_str().unwrap_or("").to_string()) - } - ast::PropName::Num(n) => KeyResolution::Static(n.value.to_string()), - ast::PropName::Computed(computed) => { - // Handle computed property keys like [ChainName.ETHEREUM] - // Try to resolve enum member access to string keys first. - match computed.expr.as_ref() { - ast::Expr::Member(member) => { - if let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = - (member.obj.as_ref(), &member.prop) - { - let enum_name = obj.sym.to_string(); - let member_name = prop.sym.to_string(); - if let Some(value) = - ctx.lookup_enum_member(&enum_name, &member_name) - { - match value { - EnumValue::String(s) => { - KeyResolution::Static(s.clone()) - } - EnumValue::Number(n) => { - KeyResolution::Static(n.to_string()) - } - } - } else { - // Non-enum member access: lower as a dynamic expression. - match lower_expr(ctx, computed.expr.as_ref()) { - Ok(e) => KeyResolution::Dynamic(e), - Err(_) => KeyResolution::Skip, - } - } - } else { - match lower_expr(ctx, computed.expr.as_ref()) { - Ok(e) => KeyResolution::Dynamic(e), - Err(_) => KeyResolution::Skip, - } - } - } - ast::Expr::Lit(ast::Lit::Str(s)) => KeyResolution::Static( - s.value.as_str().unwrap_or("").to_string(), - ), - ast::Expr::Lit(ast::Lit::Num(n)) => { - KeyResolution::Static(n.value.to_string()) - } - // Identifier or any other expression — lower it - // and defer to post-init IndexSet so symbol-typed - // locals like `[symProp]` flow through the - // IndexSet symbol dispatch path. - _ => match lower_expr(ctx, computed.expr.as_ref()) { - Ok(e) => KeyResolution::Dynamic(e), - Err(_) => KeyResolution::Skip, - }, - } - } - _ => KeyResolution::Skip, - }; - match key_resolution { - KeyResolution::Skip => continue, - KeyResolution::Static(key) => { - let value = lower_expr(ctx, &kv.value)?; - props.push((key, value)); - } - KeyResolution::Dynamic(key_expr) => { - let value = lower_expr(ctx, &kv.value)?; - computed_post_init.push(PostInit::SetValue { - key: key_expr, - value, - }); - } + KeyResolution::Dynamic(key_expr) => { + let value = lower_expr(ctx, &kv.value)?; + computed_post_init.push(PostInit::SetValue { + key: key_expr, + value, + }); } - } + }, ast::Prop::Shorthand(ident) => { // Shorthand property: { help } → { help: help } let name = ident.sym.to_string(); @@ -294,161 +613,21 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R props.push((name, value)); } ast::Prop::Method(method) => { - // Inline method: { help(): string { ... } } - // Computed keys (e.g. `[Symbol.toPrimitive](hint) {}`) - // get routed through the IIFE wrapper's - // SetMethodWithThis post-init, which emits a - // `js_object_set_symbol_method` call that also - // patches the closure's reserved `this` slot. - enum MethodKey { - Static(String), - Computed(Expr), - } - let method_key = match &method.key { - ast::PropName::Ident(ident) => MethodKey::Static(ident.sym.to_string()), - ast::PropName::Str(s) => { - MethodKey::Static(s.value.as_str().unwrap_or("").to_string()) - } - ast::PropName::Computed(computed) => { - match lower_expr(ctx, computed.expr.as_ref()) { - Ok(e) => MethodKey::Computed(e), - Err(_) => continue, - } - } - _ => continue, - }; - let key_label: String = match &method_key { - MethodKey::Static(s) => s.clone(), - MethodKey::Computed(_) => format!("computed_{}", ctx.next_func_id), - }; - let key: String = key_label.clone(); - let func_id = ctx.fresh_func(); - // Use a unique synthetic name to avoid collisions - let func_name = format!("__obj_method_{}_{}", key, func_id); - - // Snapshot outer locals for capture analysis - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - - let scope_mark = ctx.enter_scope(); - let mut params = Vec::new(); - for param in method.function.params.iter() { - let param_name = get_pat_name(¶m.pat)?; - let param_type = extract_param_type_with_ctx(¶m.pat, Some(ctx)); - let param_default = get_param_default(ctx, ¶m.pat)?; - let param_id = ctx.define_local(param_name.clone(), param_type.clone()); - params.push(Param { - id: param_id, - name: param_name, - ty: param_type, - default: param_default, - decorators: Vec::new(), - is_rest: is_rest_param(¶m.pat), - }); - } - let return_type = method - .function - .return_type - .as_ref() - .map(|rt| extract_ts_type_with_ctx(&rt.type_ann, Some(ctx))) - .unwrap_or(Type::Any); - let body = if let Some(ref block) = method.function.body { - lower_block_stmt(ctx, block)? - } else { - Vec::new() - }; - ctx.exit_scope(scope_mark); - - // Capture analysis (same pattern as arrow/function expressions) - let mut all_refs = Vec::new(); - let mut visited_closures = std::collections::HashSet::new(); - for stmt in &body { - collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); - } - let outer_local_ids: std::collections::HashSet = - outer_locals.iter().map(|(_, id)| *id).collect(); - let method_param_ids: std::collections::HashSet = - params.iter().map(|p| p.id).collect(); - let mut captures: Vec = all_refs - .into_iter() - .filter(|id| outer_local_ids.contains(id) && !method_param_ids.contains(id)) - .collect(); - captures.sort(); - captures.dedup(); - captures = ctx.filter_module_level_captures(captures); - - // Check if the method body uses `this` — even with no - // outer-scope captures we must emit a Closure so the - // object-literal creation code can patch capture slot 0 - // with the object pointer. - let uses_this = closure_uses_this(&body); - - let value_expr: Expr = if captures.is_empty() && !uses_this { - // No captures and no `this`: keep as standalone Function + FuncRef - ctx.register_func(func_name.clone(), func_id); - let defaults: Vec> = - params.iter().map(|p| p.default.clone()).collect(); - let param_ids: Vec = params.iter().map(|p| p.id).collect(); - let rest_idx = params.iter().position(|p| p.is_rest); - ctx.func_defaults - .push((func_id, defaults, param_ids, rest_idx)); - ctx.pending_functions.push(Function { - id: func_id, - name: func_name, - type_params: Vec::new(), - params, - return_type, - body, - is_async: method.function.is_async, - is_generator: false, - was_plain_async: false, - was_unrolled: false, - is_exported: false, - captures: Vec::new(), - decorators: Vec::new(), - }); - Expr::FuncRef(func_id) - } else { - // Has captures: emit as Closure - let mut all_assigned = Vec::new(); - for stmt in &body { - collect_assigned_locals_stmt(stmt, &mut all_assigned); - } - let assigned_set: std::collections::HashSet = - all_assigned.into_iter().collect(); - let mutable_captures: Vec = captures - .iter() - .filter(|id| { - assigned_set.contains(id) || ctx.var_hoisted_ids.contains(id) - }) - .copied() - .collect(); - let captures_this = uses_this; - let enclosing_class = if captures_this { - ctx.current_class.clone() - } else { - None - }; - Expr::Closure { - func_id, - params, - return_type, - body, - captures, - mutable_captures, - captures_this, - enclosing_class, - is_async: method.function.is_async, - } + // Inline method: `{ help(): string { ... } }`. Computed + // keys (e.g. `[Symbol.toPrimitive](hint) {}`) get routed + // through the IIFE wrapper's SetMethodWithThis post-init, + // which emits a `js_object_set_symbol_method` call that + // also patches the closure's reserved `this` slot. Shared + // with the spread path via `lower_method_prop`. + let Some((method_key, value_expr, uses_this)) = lower_method_prop(ctx, method)? + else { + continue; }; match method_key { - MethodKey::Static(key_str) => { + MethodKeyKind::Static(key_str) => { props.push((key_str, value_expr)); } - MethodKey::Computed(key_expr) => { + MethodKeyKind::Computed(key_expr) => { if uses_this { computed_post_init.push(PostInit::SetMethodWithThis { key: key_expr, diff --git a/crates/perry-runtime/src/object.rs b/crates/perry-runtime/src/object.rs index 5c3acd89..ce0f2f67 100644 --- a/crates/perry-runtime/src/object.rs +++ b/crates/perry-runtime/src/object.rs @@ -1178,6 +1178,37 @@ pub(crate) fn class_prototype_object(class_id: u32) -> *mut ObjectHeader { std::ptr::null_mut() } +/// #711 / #809: resolve `key` by walking the synthetic-class-id prototype +/// chain (`CLASS_PROTOTYPE_OBJECTS`), recursing into each prototype object +/// as a normal field lookup. Used both when a receiver's own keys miss AND +/// when it has no `keys_array` at all (an `Object.create(proto)` result, or +/// a `Function.prototype = obj` instance with no own props). Returns the +/// first defined, non-null field found on the chain. +unsafe fn resolve_proto_chain_field( + class_id: u32, + key: *const crate::StringHeader, +) -> Option { + let mut cid = class_id; + let mut depth = 0usize; + while depth < 32 { + let proto_obj = class_prototype_object(cid); + if !proto_obj.is_null() { + let field_val = js_object_get_field_by_name(proto_obj as *const _, key); + if !field_val.is_undefined() && !field_val.is_null() { + return Some(field_val); + } + } + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid => { + cid = p; + depth += 1; + } + _ => break, + } + } + None +} + /// Lookup the synthetic class id for a function value, if one was /// registered via `js_set_function_prototype`. #[inline] @@ -3570,6 +3601,17 @@ pub extern "C" fn js_object_get_field_by_name( let keys = (*obj).keys_array; if keys.is_null() { + // #809: an object with no own keys (e.g. an `Object.create(proto)` + // result, or a `Function.prototype = obj` instance) still has to + // resolve inherited props/methods. Pre-fix this returned undefined + // here — BEFORE the `class_id` prototype-walk below — so + // `Object.create(P).m()` threw `TypeError: m is not a function`. + let class_id = (*obj).class_id; + if class_id != 0 { + if let Some(v) = resolve_proto_chain_field(class_id, key) { + return v; + } + } return JSValue::undefined(); } @@ -3762,23 +3804,8 @@ pub extern "C" fn js_object_get_field_by_name( // object — return its value directly. `pipe`, `[Equal.symbol]`, // etc. on Effect's EffectPrototype reach here. { - let mut cid = class_id; - let mut depth = 0usize; - while depth < 32 { - let proto_obj = class_prototype_object(cid); - if !proto_obj.is_null() { - let field_val = js_object_get_field_by_name(proto_obj as *const _, key); - if !field_val.is_undefined() && !field_val.is_null() { - return field_val; - } - } - match get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { - cid = p; - depth += 1; - } - _ => break, - } + if let Some(v) = resolve_proto_chain_field(class_id, key) { + return v; } } @@ -6301,6 +6328,34 @@ pub unsafe extern "C" fn js_native_call_method( } } } + // #809: independent prototype-object resolution. The walk + // above only runs when `CLASS_VTABLE_REGISTRY` is `Some` — + // a program with no user classes that only does + // `Object.create(objLiteral).method()` has an empty/None + // registry, so `inst.method()` never reached + // `class_prototype_object` and threw ` is not a + // function`. Resolve the method off the synthetic-class-id + // prototype chain directly (reuses the same helper as + // `js_object_get_field_by_name`), then invoke it with + // `this` bound to the receiver. + let method_key = crate::string::js_string_from_bytes( + method_name.as_ptr(), + method_name.len() as u32, + ); + if let Some(field_val) = + resolve_proto_chain_field(class_id, method_key as *const crate::StringHeader) + { + if !field_val.is_undefined() && !field_val.is_null() { + let prev_this = IMPLICIT_THIS.with(|c| c.replace(jsval.bits())); + let result = crate::closure::js_native_call_value( + f64::from_bits(field_val.bits()), + args_ptr, + args_len, + ); + IMPLICIT_THIS.with(|c| c.set(prev_this)); + return result; + } + } } } } @@ -9046,8 +9101,46 @@ pub extern "C" fn js_object_get_own_property_names(obj_value: f64) -> f64 { /// Object.create(proto) — create empty object. Perry ignores prototype; Object.create(null) returns {}. #[no_mangle] -pub extern "C" fn js_object_create(_proto_value: f64) -> f64 { - let obj = js_object_alloc(0, 0); +pub extern "C" fn js_object_create(proto_value: f64) -> f64 { + // #809: actually wire up the prototype. Pre-fix this ignored its + // argument entirely, so `Object.create(Proto)` returned a bare empty + // object — `inst.method()` / `inst.prop` saw nothing and threw + // `TypeError: is not a function`. Reuse the #711 prototype-object + // machinery: allocate a synthetic class_id, map it to `proto` in + // CLASS_PROTOTYPE_OBJECTS, and stamp the new object with that id. The + // chain walk in `js_object_get_field_by_name` (the `class_id != 0` + // branch) then resolves missing own props/methods off `proto`. + // + // `Object.create(null)` (or a non-object proto / a builtin-backed + // Set/Map/Regex source Perry can't model as a prototype) falls back + // to the original behavior: a plain prototype-less object. + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + let mut class_id: u32 = 0; + let proto_bits = proto_value.to_bits(); + if (proto_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { + let proto_ptr = crate::value::js_nanbox_get_pointer(proto_value) as *mut ObjectHeader; + if !proto_ptr.is_null() && (proto_ptr as usize) > 0x10000 { + let proto_addr = proto_ptr as usize; + let modellable = !(crate::set::is_registered_set(proto_addr) + || crate::map::is_registered_map(proto_addr) + || crate::regex::is_regex_pointer(proto_ptr as *const u8)); + let valid = modellable && unsafe { is_valid_obj_ptr(proto_ptr as *const u8) }; + if valid { + let cid = + NEXT_SYNTHETIC_CLASS_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + { + let mut write = CLASS_PROTOTYPE_OBJECTS.write().unwrap(); + if write.is_none() { + *write = Some(HashMap::new()); + } + write.as_mut().unwrap().insert(cid, proto_ptr as usize); + } + unsafe { js_register_class_id(cid) }; + class_id = cid; + } + } + } + let obj = js_object_alloc(class_id, 0); // Return NaN-boxed pointer f64::from_bits((obj as u64) | 0x7FFD_0000_0000_0000) } diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 263a5470..c852ef95 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -634,6 +634,57 @@ pub unsafe extern "C" fn js_object_set_symbol_method( js_object_set_symbol_property(obj_f64, sym_f64, closure_f64) } +/// #809: string-key analog of [`js_object_set_symbol_method`]. Sets +/// `obj[key] = closure` by NAME (not the symbol side-table) and ALSO binds +/// the closure's reserved `this` slot to `obj_f64` so a method written +/// AFTER a `...spread` in an object literal still reads the right receiver. +/// +/// Used by the ordered-IIFE lowering of object literals that interleave a +/// spread with `this`-binding methods (Effect `HashRing.ts` `Proto`). The +/// non-spread fast path patches `this` post-build in codegen; this helper +/// is the runtime equivalent for the ordered path where the closure flows +/// in as a call argument. +/// +/// Layout assumption (identical to `js_object_set_symbol_method`): the +/// LAST capture slot is the reserved `this` slot. +#[no_mangle] +pub unsafe extern "C" fn js_object_set_method_by_name( + obj_f64: f64, + key_f64: f64, + closure_f64: f64, +) -> f64 { + // 1) Patch the closure's reserved (last) `this` capture slot with obj. + let c_bits = closure_f64.to_bits(); + let c_tag = c_bits & 0xFFFF_0000_0000_0000; + if c_tag == POINTER_TAG { + let c_ptr = (c_bits & POINTER_MASK) as *mut crate::closure::ClosureHeader; + if !c_ptr.is_null() && (c_ptr as usize) >= 0x1000 { + let type_tag = std::ptr::read_volatile((c_ptr as *const u8).add(12) as *const u32); + if type_tag == crate::closure::CLOSURE_MAGIC { + let raw_count = (*c_ptr).capture_count; + let real_count = crate::closure::real_capture_count(raw_count); + if real_count >= 1 { + let captures_ptr = (c_ptr as *mut u8) + .add(std::mem::size_of::()) + as *mut f64; + *captures_ptr.add((real_count - 1) as usize) = obj_f64; + } + } + } + } + + // 2) Set the field by name. `js_object_set_field_by_name` strips the + // NaN-box tag off `obj` itself, so passing the raw bits is fine; the + // key must be a real `StringHeader*` (tag stripped). + let key_bits = key_f64.to_bits(); + let key_ptr = (key_bits & POINTER_MASK) as *const StringHeader; + let obj_ptr = obj_f64.to_bits() as *mut crate::object::ObjectHeader; + if !key_ptr.is_null() && (key_ptr as usize) >= 0x1000 { + crate::object::js_object_set_field_by_name(obj_ptr, key_ptr, closure_f64); + } + obj_f64 +} + /// `ToPrimitive(value, hint)` — if `value` is an object with a /// `[Symbol.toPrimitive]` method registered in the symbol side-table, call /// it with the appropriate hint string ("number" / "string" / "default")