From fe75fb9e94079e6a668c0261878fb2648869ab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 08:27:03 +0200 Subject: [PATCH 1/3] fix: #741 path gaps + #742 tty exports/isTTY + partial #740 class-expr (v0.5.901) and toNamespacedPath added; new HIR variants + runtime helpers; 5-line issue repro matches Node, parity diff drops from 15 to 2 lines. bound-method closure so typeof reports "function"; process.std*.isTTY returns undefined (not boolean false) when not a TTY, per Node spec; parity test now matches Node byte-for-byte. instead of an empty-arg New, so `const C = class {...}; new C(args)` constructs with the supplied args. Standalone Effect repro moves from "TypeError" to running through with undefined fields. Full fix needs runtime constructor dispatch for class refs read from object fields. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 136 +++++++++---------- Cargo.toml | 2 +- crates/perry-api-manifest/src/entries.rs | 2 + crates/perry-codegen-js/src/emit.rs | 20 +++ crates/perry-codegen-wasm/src/emit.rs | 17 +++ crates/perry-codegen/src/collectors.rs | 10 +- crates/perry-codegen/src/expr.rs | 33 +++++ crates/perry-codegen/src/runtime_decls.rs | 3 + crates/perry-codegen/src/type_analysis.rs | 6 +- crates/perry-hir/src/analysis.rs | 3 +- crates/perry-hir/src/ir.rs | 3 + crates/perry-hir/src/js_transform.rs | 4 +- crates/perry-hir/src/lower.rs | 18 ++- crates/perry-hir/src/lower/expr_call.rs | 50 ++++++- crates/perry-hir/src/monomorph.rs | 21 ++- crates/perry-hir/src/stable_hash.rs | 14 ++ crates/perry-hir/src/walker.rs | 6 + crates/perry-runtime/src/object.rs | 33 +++++ crates/perry-runtime/src/path.rs | 152 ++++++++++++++++++++-- crates/perry-runtime/src/tty.rs | 22 +++- 22 files changed, 453 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d042b6c3..0cea5da758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.901 — fix(stdlib/path,tty,classexpr): close #741 + #742, partial #740. **Version-bump note.** Renumbered from v0.5.896 → v0.5.901 because #735 (v0.5.895–v0.5.898 #665 follow-ups) and #744 (v0.5.899 toml/deno_core bump) landed on main, and #743 (v0.5.900 #733 self-recursive inliner) is queued ahead of this PR. **#741 path module gaps.** Four sibling bugs in `node:path` that were the entire diff in `test-files/test_parity_path.ts` against Node 22. (1) `path.dirname("/")` returned `""` because Rust's `Path::new("/").parent()` is `None`; Node spec says the root's dirname is the root itself. Added an explicit POSIX-root short-circuit in `crates/perry-runtime/src/path.rs::js_path_dirname` — any all-`/` input returns `"/"`, empty input returns `"."`, parent-not-found falls back to `"."` (Node's "no separator" semantics). (2) `path.join("/foo/", "/bar/", "baz")` returned `/bar/baz` because `js_path_join` was implemented via Rust's `PathBuf::join` which resets on absolute segments — that's `path.resolve` semantics, not `path.join` semantics. Per Node docs, `path.join` concatenates all arguments with `/` then normalizes; an absolute middle segment is just another segment. Rewrote `js_path_join` to do raw `"{a}/{b}"` concat (with empty-arg short-circuits) followed by `normalize_str`. (3) `path.matchesGlob(path, pattern)` (Node 22.5+) wasn't implemented and threw the hard manifest-gate error. Added a runtime helper `js_path_matches_glob` that converts the glob to a regex (`*` → `[^/]*`, `**` → `.*`, `?` → `[^/]`, `[...]` classes pass through, special regex chars get escaped) and tests via the existing `regex` crate. (4) `path.toNamespacedPath(path)` is Windows-only on Node — POSIX returns the input unchanged. Perry was unimplemented, threw `TypeError: value is not a function`, and that throw terminated the entire script (so the parity test's lines 38+ silently never ran). Added `js_path_to_namespaced_path` as a POSIX no-op (returns input verbatim). **Plumbing.** Two new HIR variants `PathToNamespacedPath(Box)` and `PathMatchesGlob(Box, Box)` plus a third `PathResolveJoin(Box, Box)` introduced as a side fix: changing `js_path_join` to the correct concat semantics broke the existing `path.resolve(a, b, c)` lowering, which chained `PathJoin` for the multi-arg path — `PathJoin` was inadvertently doing double duty as the resolve-style "reset on absolute" join. Split the two: the HIR lowering for `path.resolve(a, b, c)` now chains `PathResolveJoin` (which does have reset-on-absolute, via a new `js_path_resolve_join` runtime helper) and the chain wraps a final `PathResolve`. `path.join(a, b, c)` still chains plain `PathJoin` (now correct concat semantics). New entries added to walker.rs, stable_hash.rs (tags 449/450/451), analysis.rs, monomorph.rs, js_transform.rs, type_analysis.rs, collectors.rs, codegen-js/emit.rs, codegen-wasm/emit.rs, runtime_decls.rs, and expr.rs. Also added `method("path", "toNamespacedPath", …)` and `method("path", "matchesGlob", …)` to `perry_api_manifest::entries` so the unimplemented gate stops firing. **Validation.** Issue's 5-line repro matches Node byte-for-byte. Full `test-files/test_parity_path.ts` diff drops from 15 lines to 2 (`posix.join` / `win32.join` — separate sub-namespace work, not in scope for #741). **#742 tty exports + isTTY shape.** Three bugs that were the entire diff in `test-files/test_parity_tty.ts`. (1) `typeof tty.ReadStream`, `typeof tty.WriteStream`, and `typeof tty.isatty` all returned `"undefined"` because reading them as PropertyGet on the `NativeModuleRef("tty")` namespace went through `js_native_module_property_by_name` which only consults the constants dispatcher (none of them are constants). The call-site form `tty.isatty(0)` worked via a dedicated HIR lowering, but the property-read form (`typeof`, `const f = tty.isatty`) returned `undefined`. Fix: at the tail of `js_native_module_property_by_name` (`crates/perry-runtime/src/object.rs`), check `is_native_module_callable_export(module, prop)` — a whitelist currently of `("tty","isatty")`, `("tty","ReadStream")`, `("tty","WriteStream")` — and if matched, synthesize a `BOUND_METHOD_FUNC_PTR` closure capturing the namespace object + method name (same shape `js_native_module_bind_method` creates for ordinary method access). Closures NaN-box with the magic header that `js_value_typeof` recognizes as `"function"`. Whitelist deliberately narrow so `typeof tty.bogusName` still returns `"undefined"`. (2) `process.std{in,out,err}.isTTY` returned `false` (typeof `"boolean"`) when stdout was piped, but Node's docs spec it as `true` when TTY, **`undefined` otherwise** — `isTTY` is a presence-test, not a boolean. Many libraries do `if ("isTTY" in process.stdout)` or `if (process.stdout.isTTY !== undefined)` and got the wrong answer under Perry. Changed `js_process_stdin_isatty` / `js_process_stdout_isatty` / `js_process_stderr_isatty` to return `TAG_UNDEFINED` (not `TAG_FALSE`) when the corresponding fd isn't a TTY. `tty.isatty(fd)` itself still returns a real boolean — that's the documented Node contract. **Validation.** Issue's 5-line repro matches Node byte-for-byte. Full `test-files/test_parity_tty.ts` parity test now passes byte-for-byte (was 6 lines off). **#740 Effect ParseResult.ts crash (partial fix).** The actual root issue tracks `class X extends Factory()<...>` (a class extending a factory-call result) which is the Effect blocker, and requires runtime constructor dispatch the compiler doesn't yet have. What landed here is the *narrower* compiler fix that moves the standalone repro from "throws `TypeError: value is not a function`" to "runs to completion (instance has undefined fields)": class expressions used as values were lowering to `Expr::New { class_name, args: vec![] }` — i.e. creating a zero-arg instance — rather than to `Expr::ClassRef(class_name)`. So `const C = class { ... }` bound `C` to a stillborn instance, and `O.Inner` inside `{ Inner: class { ... } }` likewise. Changed `crates/perry-hir/src/lower.rs`'s `ast::Expr::Class` arm to emit `Expr::ClassRef(synthetic_name)` instead. With the alias-chain propagation already in `Stmt::Let` (codegen/stmt.rs), this makes `const C = class {...}; new C(args)` and `function f() { const C = class {...}; return C; } new f()(args)` work end-to-end (constructor runs with the supplied args). The remaining `O.Inner`-via-PropertyGet case still falls through to the empty-object placeholder in `NewDynamic` because the codegen has no runtime constructor dispatch — the value at the property is the class ref, but `new ` doesn't yet route through a registry. That's the same architectural gap that blocks `class X extends Factory()`, and it's the next layer #740 needs. Effect's standalone repro now prints `[1] before` / `[2] after class def` / `[3] pe._tag: undefined` (no crash, partial fields) rather than the original throw. **Validation.** All 26 / 28 `test_gap_*` tests still pass — same 2 pre-existing failures as main (`test_gap_console_methods.ts`, `test_gap_regexp_advanced.ts` — known categorical gaps in CLAUDE.md). All 20 / 23 `test_*class*.ts` tests still pass — same 3 pre-existing differences as main. No regressions. `cargo test -p perry-runtime --lib` 250 passed, 0 failed. **Out of scope.** (a) Runtime constructor dispatch — `js_new_dynamic(callee_value, args_vec)` that inspects the callee's NaN tag and routes to the right constructor when callee is an INT32-tagged class-id; needed for `new O.Inner(args)` (where `O.Inner` is a class ref read from an object field) and `class X extends Factory()` (where the parent class is a runtime value). Tracked as #740 follow-up. (b) `path.posix.X(...)` / `path.win32.X(...)` sub-namespace method calls — these still print `undefined` because the `is_sub_namespace` flag in expr_call.rs:519 short-circuits the NativeMethodCall lowering and the fall-through path doesn't dispatch back to the corresponding `path.X(...)` for POSIX. Not in scope for #741's stated acceptance (the 5-line repro), but is the last diff in `test_parity_path.ts`. (c) The `BaseEffectError`-via-object-literal pattern that Effect uses for `TaggedError` — needs (a) to dispatch the constructor when reading the class ref back from the object field. + ## v0.5.900 — fix(transform): #733 perry-main stack overflow on self-recursive functions whose body is `return self(...)`. **Symptom (Windows).** The compiler crashed during HIR processing with `thread 'perry-main' (NNNNN) has overflowed its stack` and no other diagnostic. User's repro was `import {} from '@piying/valibot-visit'` with valibot-visit in `compilePackages`; bisected down to a 2-line minimal: `function foo(): any { return foo(); }; foo();` — any program compiling a function whose body is a single `return (...)` blew the 64 MB perry-main stack. (More elaborate triggers — self-recursive function called from inside a loop, or a recursive caller of a self-recursive helper — were all variations on the same root cause. Surface area is broad: valibot-visit's `getSchemaByIssuePath` recursing through `findAsControlAction` → `schemaForEach` was the field repro.) **Root cause.** `crates/perry-transform/src/inline.rs::try_inline_simple_call` matches the "single Return(expr)" pattern by substituting the body's return expression at the call site — for `function foo() { return foo(); }` the substituted result is *another* call to `foo()`. The enclosing `inline_calls_in_expr` then recurses on the substituted result (line 2204 — "subsequent layers of small-function calls collapse cleanly"), sees `foo()` again, looks `foo` up in `func_candidates` (still present), inlines again, recurses, inlines, recurses... unbounded. `is_inlinable` had eight rejection criteria — async, generator, captures, rest params, body size, simple-control-flow, closure-capturing-params, super-call — but no self-recursion check. A self-recursive function with `body.len() == 1` and shape `[Return(Some())]` passed every gate. Surface comment at the inliner's outer recurse said "Termination relies on `is_inlinable` rejecting recursive functions in practice (cyclic call chains either don't form or get filtered out by other criteria)" — that assumption held for mutual recursion (because the called function's pattern usually didn't match) but missed the direct-self-recursive case where both the caller pattern AND the callee's body are the same `return self(...)` shape. Fixed-call-graph analyses for fibonacci-shaped recursion (`if base; return self(n-1) + self(n-2)`) survived because the body has more than one statement — `body.len() > 1` and `has_simple_control_flow` rejecting If-with-Return-in-both-branches both removed those from `func_candidates` indirectly. The catastrophic case was specifically the single-statement `return self(...)` shape, which is what valibot-visit's transpiled `getSchemaByIssuePath` reduces to through SWC's `function foo(x): any { return foo(x); }` synthesis path. **Fix.** Add `body_calls_func(&func.body, func.id)` to `is_inlinable`. The check walks statements + expressions recursively, returning true the moment it finds an `Expr::Call { callee: FuncRef(target_id) }` where `target_id == func.id`. Closure bodies are deliberately skipped — a self-reference inside a nested closure is a value-position read (the closure body becomes its own codegen unit, not an inlinable expression in the outer frame). Self-recursive functions are now never added to `func_candidates`; the inliner leaves them as ordinary calls and codegen emits a normal `call` instruction. **Validation.** Minimal repro (`function foo(): any { return foo(); }; foo();`) pre-fix: 64 MB stack overflow on perry-main during transform; post-fix: links and runs. User's exact reproduction (`import * as v from "valibot"; import {} from "@piying/valibot-visit"; v.parse(v.string(), "123")` with both packages in `compilePackages`) pre-fix: stack overflow after the two `Intl` HIR warnings; post-fix: compiles to a 3.6 MB binary. `fib(10) === 55` still inlines/dispatches correctly because fib's body is `[If { ... }, Return(self(n-1)+self(n-2))]` — body.len() > 1 path, and the addition contains two self-calls that the new check also rejects, so the inliner skips fib entirely (and codegen handles the recursion natively, which it always did for fib regardless). `cargo build --release -p perry-transform -p perry` clean. **Out of scope.** Mutual recursion between two functions (`f → g → f`) where both `f` and `g` happen to match the single-Return-of-Call pattern: theoretically possible but never observed in practice (mutual single-Return-of-Call cycles are vanishingly rare in real codebases because at least one side typically has a base case). If it surfaces, the fix is to graduate `body_calls_func` into a `body_in_call_cycle` SCC analysis on `func_candidates`. Tracked but not built yet. **Version-bump note.** Renumbered from v0.5.893 → v0.5.900 because the original v0.5.893 slot was taken by #736 (HarmonyOS Chart + TreeView) and main has since advanced to v0.5.899 via #734/#737/#735/#744 while this PR was in review. ## v0.5.899 — deps: bump `toml` 0.8 → 1.1.2+spec-1.1.0 (#727) and `deno_core` 0.311 → 0.400 (#729). The two dependabot bumps that the v0.5.894 batch deferred as "real breakage" — addressed here with focused migrations rather than dropping them. **toml 1.x.** `` (called implicitly by `.parse::()`) is no longer a document parser in 1.x — it's an inline-value parser that rejects anything with leading comments or whitespace ("unexpected content, expected nothing" at line 1, column 1 of `well_known_bindings.toml`, on every TOML file in the codebase that opens with the standard banner). Four callsites updated: `crates/perry/src/commands/compile/well_known.rs:100` (the well-known native-bindings registry — the loud one), `crates/perry/src/commands/run.rs:343`/`829`/`1453` (`perry.toml` reads for the `publish.exclude` list, icon source, and iOS bundle id), `crates/perry/src/commands/native/validate.rs:164` (`Cargo.toml` `[package].name` lookup for `perry native validate`). All swapped from `.parse::()` to `toml::from_str::(s)` — the crate-level `toml::from_str` still goes through the document deserializer + returns a `Value::Table`, which is the shape every callsite already expected to walk. Callsites that already use `.parse::()` (the rest of `commands/setup.rs`, `commands/compile.rs`, `commands/i18n.rs`) are unaffected — `Table::from_str` still calls `crate::from_str` internally. Restores the 7 previously-failing tests (`shipped_toml_parses`, `dotenv_is_registered`, `node_prefix_stripped_on_lookup`, `unknown_package_returns_none`, `every_entry_references_a_workspace_crate`, `parser_rejects_missing_crate_field`, `read_crate_name_works`) and fixes the 31 compile-smoke regressions that were all the same panic surfacing from a different test path. **deno_core 0.400.** 89-patch jump that pulls in v8 0.106 → 147.4.0, a fundamental V8 binding revision: `v8::HandleScope` is replaced by `v8::PinScope` in nearly every signature; `JsRuntime::handle_scope()` is gone in favor of the `deno_core::scope!(scope, &mut runtime)` macro (which pins on the stack frame); `v8::TryCatch::new(scope)` returns a `ScopeStorage` that needs `v8::tc_scope!(tc_scope, scope)` to pin into a usable `&mut PinScope`; `Local::write_utf8` is replaced by `write_utf8_v2(buf, WriteFlags)` (different signature: drops the processed-chars `&mut usize` slot from the middle and uses `WriteFlags` instead of `WriteOptions`); `anyhow::Error` no longer implements `JsErrorClass` — op2-returning functions need `deno_error::JsErrorBox` or a typed wrapper; `extension!`'s `init_ops()` is renamed `init()`; `ModuleLoader::load` grew a 5th param via the new `ModuleLoadOptions` struct and `ModuleLoadReferrer` shape; `ModuleLoader::resolve` returns `ModuleLoaderError` (= `JsErrorBox`) instead of `AnyError`. Migration touches 5 source files in perry-jsruntime (`bridge.rs`, `interop.rs`, `lib.rs`, `modules.rs`, `ops.rs`) plus its `Cargo.toml` (adds `deno_error = "0.7"` as a direct dep so `JsErrorBox` is importable — was a transitive in 0.311). All `#[no_mangle] pub extern "C"` FFI surfaces keep identical signatures so perry-runtime, perry-codegen, and perry/src/commands keep their existing call shapes unchanged. The issue #255 re-entrancy escape hatch (`stash_trampoline_scope` / `try_trampoline_scope` — the `REENTRY_SCOPE_PTR` raw-pointer stash mechanism for crossing the V8 ↔ native trampoline) is preserved byte-for-byte at the memory level; only the typed view of the pointer migrates from `HandleScope` to `PinScope`. **Stack-limit override removed.** Pre-bump `JsRuntimeState::new` called `Isolate::SetStackLimit` (via the Itanium-mangled `_ZN2v87Isolate13SetStackLimitEm` because the v8 0.106 Rust bindings didn't expose it) right after `JsRuntime::new` returned, to fix arm64 SIGBUS on deep call chains. After the bump, calling that same exported symbol while the isolate is not entered (no `Isolate::Scope` on the stack) silently exits the process with code 0 — v8 147's stack-guard internals abort cleanly instead of crashing. The manifest of the silent-exit was the three parity tests (`test_issue_248_phase2_js_interop`, `test_issue_248_phase2b_js_callback`, `test_issue_255_jsruntime_reentrancy`) producing no output: every test that loaded a `.js` module called `js_runtime_init` (which constructed `JsRuntimeState` which hit the stack-limit setter and exited). Removed the manual override entirely — v8 147 picks a sane default stack limit from the calling thread's stack bounds and `deno_core::scope!` pins the isolate properly for each work scope, so the override is no longer needed. **Validation.** `cargo build --release -p perry-jsruntime -p perry-runtime -p perry-stdlib -p perry` clean. `cargo test --release -p perry --bin perry` 152 passed, 0 failed (was failing 7 tests pre-fix on the toml side). All three previously-failing parity tests now byte-equal Node. **Version-bump note.** Renumbered from v0.5.895 to v0.5.899 after #735 landed as v0.5.895/896/897/898 on main while this PR was in CI (four-patch sequence for #665 follow-ups). diff --git a/CLAUDE.md b/CLAUDE.md index 13b7bd08ce..0de47a2ee1 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.900 +**Current Version:** 0.5.901 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a105fccff9..64f9396fc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.900" +version = "0.5.901" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.900" +version = "0.5.901" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.900" +version = "0.5.901" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.900" +version = "0.5.901" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.900" +version = "0.5.901" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.900" +version = "0.5.901" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.900" +version = "0.5.901" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.900" +version = "0.5.901" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.900" +version = "0.5.901" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.900" +version = "0.5.901" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.900" +version = "0.5.901" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.900" +version = "0.5.901" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.900" +version = "0.5.901" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.900" +version = "0.5.901" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.900" +version = "0.5.901" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.900" +version = "0.5.901" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.900" +version = "0.5.901" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.900" +version = "0.5.901" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.900" +version = "0.5.901" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.900" +version = "0.5.901" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.900" +version = "0.5.901" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.900" +version = "0.5.901" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.900" +version = "0.5.901" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.900" +version = "0.5.901" [[package]] name = "perry-ui-android" -version = "0.5.900" +version = "0.5.901" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.900" +version = "0.5.901" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.900" +version = "0.5.901" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.900" +version = "0.5.901" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.900" +version = "0.5.901" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.900" +version = "0.5.901" [[package]] name = "perry-ui-tvos" -version = "0.5.900" +version = "0.5.901" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.900" +version = "0.5.901" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.900" +version = "0.5.901" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.900" +version = "0.5.901" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.900" +version = "0.5.901" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.900" +version = "0.5.901" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e925395435..a828eb635c 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.900" +version = "0.5.901" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 2c99834953..4040c402df 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -1565,6 +1565,8 @@ pub static API_MANIFEST: &[ApiEntry] = &[ method("path", "normalize", false, None), method("path", "parse", false, None), method("path", "format", false, None), + method("path", "toNamespacedPath", false, None), + method("path", "matchesGlob", false, None), property("path", "sep"), property("path", "delimiter"), property("path", "posix"), diff --git a/crates/perry-codegen-js/src/emit.rs b/crates/perry-codegen-js/src/emit.rs index 71292960a5..9b161574db 100644 --- a/crates/perry-codegen-js/src/emit.rs +++ b/crates/perry-codegen-js/src/emit.rs @@ -1383,6 +1383,26 @@ impl JsEmitter { Expr::PathDelimiter => { self.output.push_str("__perry.path.delimiter"); } + Expr::PathToNamespacedPath(p) => { + self.output.push_str("__perry.path.toNamespacedPath("); + self.emit_expr(p); + self.output.push(')'); + } + Expr::PathMatchesGlob(p, pat) => { + self.output.push_str("__perry.path.matchesGlob("); + self.emit_expr(p); + self.output.push_str(", "); + self.emit_expr(pat); + self.output.push(')'); + } + Expr::PathResolveJoin(a, b) => { + // Match Node's path.resolve(a, b) two-arg behavior. + self.output.push_str("__perry.path.resolve("); + self.emit_expr(a); + self.output.push_str(", "); + self.emit_expr(b); + self.output.push(')'); + } // --- WeakRef and FinalizationRegistry --- Expr::WeakRefNew(target) => { diff --git a/crates/perry-codegen-wasm/src/emit.rs b/crates/perry-codegen-wasm/src/emit.rs index fbdbe4fb57..780a368e00 100644 --- a/crates/perry-codegen-wasm/src/emit.rs +++ b/crates/perry-codegen-wasm/src/emit.rs @@ -8629,6 +8629,23 @@ impl<'a> FuncEmitCtx<'a> { Expr::PathDelimiter => { self.emit_memcall(func, "path_delimiter", 0); } + Expr::PathToNamespacedPath(p) => { + self.emit_frame_begin(func, 1); + self.emit_store_arg(func, 0, p); + self.emit_memcall(func, "path_to_namespaced_path", 1); + } + Expr::PathMatchesGlob(p, pat) => { + self.emit_frame_begin(func, 2); + self.emit_store_arg(func, 0, p); + self.emit_store_arg(func, 1, pat); + self.emit_memcall(func, "path_matches_glob", 2); + } + Expr::PathResolveJoin(a, b) => { + self.emit_frame_begin(func, 2); + self.emit_store_arg(func, 0, a); + self.emit_store_arg(func, 1, b); + self.emit_memcall(func, "path_resolve_join", 2); + } // --- WeakRef and FinalizationRegistry (stub: routes to host runtime) --- Expr::WeakRefNew(target) => { self.emit_frame_begin(func, 1); diff --git a/crates/perry-codegen/src/collectors.rs b/crates/perry-codegen/src/collectors.rs index 6a5386f995..cbf1e734a4 100644 --- a/crates/perry-codegen/src/collectors.rs +++ b/crates/perry-codegen/src/collectors.rs @@ -634,6 +634,7 @@ pub(crate) fn collect_ref_ids_in_expr(e: &perry_hir::Expr, out: &mut HashSet { + Expr::PathBasenameExt(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { walk(a, out); walk(b, out); } @@ -3059,6 +3060,7 @@ fn collect_localset_ids_in_expr_filtered( | Expr::PathNormalize(operand) | Expr::PathFormat(operand) | Expr::PathParse(operand) + | Expr::PathToNamespacedPath(operand) | Expr::DateToISOString(operand) | Expr::DateParse(operand) | Expr::EnvGetDynamic(operand) @@ -3253,7 +3255,7 @@ fn collect_localset_ids_in_expr_filtered( walk(a, out); walk(b, out); } - Expr::PathBasenameExt(a, b) => { + Expr::PathBasenameExt(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { walk(a, out); walk(b, out); } @@ -4896,7 +4898,9 @@ fn check_escapes_in_expr( reviver: b, } | Expr::JsonParseWithReviver(a, b) - | Expr::PathRelative(a, b) => { + | Expr::PathRelative(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) => { check_escapes_in_expr(a, candidates, classes, escaped); check_escapes_in_expr(b, candidates, classes, escaped); } diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index 978ce48408..7dfac51867 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -7063,6 +7063,39 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let result = blk.call(I64, "js_path_format", &[(DOUBLE, &obj_box)]); Ok(nanbox_string_inline(blk, &result)) } + Expr::PathToNamespacedPath(p) => { + let p_box = lower_expr(ctx, p)?; + let blk = ctx.block(); + let p_handle = unbox_to_i64(blk, &p_box); + let result = blk.call(I64, "js_path_to_namespaced_path", &[(I64, &p_handle)]); + Ok(nanbox_string_inline(blk, &result)) + } + Expr::PathMatchesGlob(p, pat) => { + let p_box = lower_expr(ctx, p)?; + let pat_box = lower_expr(ctx, pat)?; + let blk = ctx.block(); + let p_handle = unbox_to_i64(blk, &p_box); + let pat_handle = unbox_to_i64(blk, &pat_box); + let i32_v = blk.call( + I32, + "js_path_matches_glob", + &[(I64, &p_handle), (I64, &pat_handle)], + ); + Ok(i32_bool_to_nanbox(blk, &i32_v)) + } + Expr::PathResolveJoin(a, b) => { + let a_box = lower_expr(ctx, a)?; + let b_box = lower_expr(ctx, b)?; + let blk = ctx.block(); + let a_handle = unbox_to_i64(blk, &a_box); + let b_handle = unbox_to_i64(blk, &b_box); + let result = blk.call( + I64, + "js_path_resolve_join", + &[(I64, &a_handle), (I64, &b_handle)], + ); + Ok(nanbox_string_inline(blk, &result)) + } Expr::ProcessVersion => { let blk = ctx.block(); let handle = blk.call(I64, "js_process_version", &[]); diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index 72ccefdc51..818009b250 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -732,6 +732,9 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_path_join", I64, &[I64, I64]); module.declare_function("js_path_dirname", I64, &[I64]); module.declare_function("js_path_relative", I64, &[I64, I64]); + module.declare_function("js_path_to_namespaced_path", I64, &[I64]); + module.declare_function("js_path_matches_glob", I32, &[I64, I64]); + module.declare_function("js_path_resolve_join", I64, &[I64, I64]); module.declare_function("js_object_from_entries", DOUBLE, &[DOUBLE]); module.declare_function("js_string_match", I64, &[I64, I64]); module.declare_function("js_string_match_all", I64, &[I64, I64]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index e99b73958e..bd512ad036 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -768,6 +768,8 @@ pub(crate) fn is_definitely_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { | Expr::PathExtname(_) | Expr::PathResolve(_) | Expr::PathNormalize(_) + | Expr::PathToNamespacedPath(_) + | Expr::PathResolveJoin(..) | Expr::ProcessVersion | Expr::ProcessCwd | Expr::OsArch @@ -871,7 +873,9 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { | Expr::PathBasename(_) | Expr::PathExtname(_) | Expr::PathResolve(_) - | Expr::PathNormalize(_) => true, + | Expr::PathNormalize(_) + | Expr::PathToNamespacedPath(_) + | Expr::PathResolveJoin(..) => true, // String.fromCodePoint(...) / String.fromCharCode(...) / str.at(i) // / RegExp.source|flags — all produce string handles. Expr::StringFromCodePoint(_) diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 5c6c14e883..df45d9c297 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -442,7 +442,7 @@ pub(crate) fn collect_assigned_locals_expr(expr: &Expr, assigned: &mut Vec { + Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { collect_assigned_locals_expr(a, assigned); collect_assigned_locals_expr(b, assigned); } @@ -451,6 +451,7 @@ pub(crate) fn collect_assigned_locals_expr(expr: &Expr, assigned: &mut Vec { collect_assigned_locals_expr(path, assigned); } diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index 837d83c709..614ba58c75 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -1228,6 +1228,9 @@ pub enum Expr { PathFormat(Box), // path.format({ dir, base }) -> string PathSep, // path.sep constant PathDelimiter, // path.delimiter constant + PathToNamespacedPath(Box), // path.toNamespacedPath(path) -> string (POSIX: no-op) + PathMatchesGlob(Box, Box), // path.matchesGlob(path, pattern) -> boolean + PathResolveJoin(Box, Box), // internal: join with reset-on-absolute (multi-arg resolve) // WeakRef and FinalizationRegistry WeakRefNew(Box), // new WeakRef(obj) -> WeakRef diff --git a/crates/perry-hir/src/js_transform.rs b/crates/perry-hir/src/js_transform.rs index 3254e0e9c2..205067ad36 100644 --- a/crates/perry-hir/src/js_transform.rs +++ b/crates/perry-hir/src/js_transform.rs @@ -899,11 +899,11 @@ fn transform_expr( Expr::FsReadFileSync(e) | Expr::FsExistsSync(e) | Expr::FsMkdirSync(e) | Expr::FsUnlinkSync(e) => { transform_expr(e, js_imports, extern_func_to_js, local_name_to_js, tracker); } - Expr::FsWriteFileSync(a, b) | Expr::FsAppendFileSync(a, b) | Expr::PathJoin(a, b) | Expr::MathPow(a, b) | Expr::MathImul(a, b) => { + Expr::FsWriteFileSync(a, b) | Expr::FsAppendFileSync(a, b) | Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) | Expr::MathPow(a, b) | Expr::MathImul(a, b) => { transform_expr(a, js_imports, extern_func_to_js, local_name_to_js, tracker); transform_expr(b, js_imports, extern_func_to_js, local_name_to_js, tracker); } - Expr::PathDirname(e) | Expr::PathBasename(e) | Expr::PathExtname(e) | Expr::PathResolve(e) | Expr::PathIsAbsolute(e) => { + Expr::PathDirname(e) | Expr::PathBasename(e) | Expr::PathExtname(e) | Expr::PathResolve(e) | Expr::PathIsAbsolute(e) | Expr::PathToNamespacedPath(e) => { transform_expr(e, js_imports, extern_func_to_js, local_name_to_js, tracker); } Expr::JsonParse(e) | Expr::JsonStringify(e) => { diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index 969889c413..5d00d893f0 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -8075,19 +8075,23 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< type_args: vec![], }) } - // Class expression used as a value (not in `new` context) + // Class expression used as a value (not in `new` context) — + // refs #740. JS semantics: a class expression evaluates to the + // class constructor itself. Previously we emitted an empty `new` + // here, which bound the local to a zero-arg instance instead of + // the class — so `const C = class { ... }; new C(args)` ran the + // ctor with no args, and `O.Inner` inside an object literal held + // a stillborn instance instead of a constructor. Lower to a + // `ClassRef` so the constructor identity survives the value path + // and `new` site rerouting (via `local_class_aliases`) picks it + // back up. ast::Expr::Class(class_expr) => { let ident_name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); let synthetic_name = ident_name.unwrap_or_else(|| format!("__anon_class_{}", ctx.fresh_class())); let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?; ctx.pending_classes.push(class); - // Return as a New expression with no args (creates the class object reference) - Ok(Expr::New { - class_name: synthetic_name, - args: vec![], - type_args: vec![], - }) + Ok(Expr::ClassRef(synthetic_name)) } ast::Expr::JSXElement(jsx) => lower_jsx_element(ctx, jsx), ast::Expr::JSXFragment(jsx) => lower_jsx_fragment(ctx, jsx), diff --git a/crates/perry-hir/src/lower/expr_call.rs b/crates/perry-hir/src/lower/expr_call.rs index 6ac2e97b1f..b06709c87b 100644 --- a/crates/perry-hir/src/lower/expr_call.rs +++ b/crates/perry-hir/src/lower/expr_call.rs @@ -1748,13 +1748,15 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } "resolve" => { if !args.is_empty() { - // path.resolve(a, b, c) => resolve(join(a, b, c)) - // For single arg, just resolve directly + // path.resolve(a, b, c): per Node, a later + // absolute segment resets the accumulation — + // distinct from path.join. Use PathResolveJoin + // (reset-on-absolute) for the chain. let mut iter = args.into_iter(); let first = iter.next().unwrap(); let mut joined = first; for next_arg in iter { - joined = Expr::PathJoin( + joined = Expr::PathResolveJoin( Box::new(joined), Box::new(next_arg), ); @@ -1801,6 +1803,24 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res ))); } } + "toNamespacedPath" => { + if !args.is_empty() { + return Ok(Expr::PathToNamespacedPath(Box::new( + args.into_iter().next().unwrap(), + ))); + } + } + "matchesGlob" => { + if args.len() >= 2 { + let mut iter = args.into_iter(); + let path_arg = iter.next().unwrap(); + let pattern = iter.next().unwrap(); + return Ok(Expr::PathMatchesGlob( + Box::new(path_arg), + Box::new(pattern), + )); + } + } _ => {} // Fall through to generic handling } } @@ -5536,8 +5556,10 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res let first = iter.next().unwrap(); let mut joined = first; for next_arg in iter { - joined = - Expr::PathJoin(Box::new(joined), Box::new(next_arg)); + joined = Expr::PathResolveJoin( + Box::new(joined), + Box::new(next_arg), + ); } return Ok(Expr::PathResolve(Box::new(joined))); } @@ -5578,6 +5600,24 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res ))); } } + "toNamespacedPath" => { + if !args.is_empty() { + return Ok(Expr::PathToNamespacedPath(Box::new( + args.into_iter().next().unwrap(), + ))); + } + } + "matchesGlob" => { + if args.len() >= 2 { + let mut iter = args.into_iter(); + let path_arg = iter.next().unwrap(); + let pattern = iter.next().unwrap(); + return Ok(Expr::PathMatchesGlob( + Box::new(path_arg), + Box::new(pattern), + )); + } + } _ => {} // Fall through } } diff --git a/crates/perry-hir/src/monomorph.rs b/crates/perry-hir/src/monomorph.rs index bcbddcdba0..2dd627944d 100644 --- a/crates/perry-hir/src/monomorph.rs +++ b/crates/perry-hir/src/monomorph.rs @@ -1255,6 +1255,17 @@ fn substitute_expr(expr: &Expr, substitutions: &HashMap) -> Expr { Expr::PathIsAbsolute(path) => { Expr::PathIsAbsolute(Box::new(substitute_expr(path, substitutions))) } + Expr::PathToNamespacedPath(path) => { + Expr::PathToNamespacedPath(Box::new(substitute_expr(path, substitutions))) + } + Expr::PathMatchesGlob(a, b) => Expr::PathMatchesGlob( + Box::new(substitute_expr(a, substitutions)), + Box::new(substitute_expr(b, substitutions)), + ), + Expr::PathResolveJoin(a, b) => Expr::PathResolveJoin( + Box::new(substitute_expr(a, substitutions)), + Box::new(substitute_expr(b, substitutions)), + ), // Array methods Expr::ArrayPush { array_id, value } => Expr::ArrayPush { @@ -2398,7 +2409,7 @@ fn collect_instantiations_in_expr( collect_instantiations_in_expr(path, ctx, module, idx); collect_instantiations_in_expr(content, ctx, module, idx); } - Expr::PathJoin(a, b) => { + Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { collect_instantiations_in_expr(a, ctx, module, idx); collect_instantiations_in_expr(b, ctx, module, idx); } @@ -2406,7 +2417,8 @@ fn collect_instantiations_in_expr( | Expr::PathBasename(p) | Expr::PathExtname(p) | Expr::PathResolve(p) - | Expr::PathIsAbsolute(p) => { + | Expr::PathIsAbsolute(p) + | Expr::PathToNamespacedPath(p) => { collect_instantiations_in_expr(p, ctx, module, idx); } Expr::ArrayPush { value, .. } @@ -2916,7 +2928,7 @@ fn update_call_sites_in_expr( update_call_sites_in_expr(path, ctx, lookup); update_call_sites_in_expr(content, ctx, lookup); } - Expr::PathJoin(a, b) => { + Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { update_call_sites_in_expr(a, ctx, lookup); update_call_sites_in_expr(b, ctx, lookup); } @@ -2924,7 +2936,8 @@ fn update_call_sites_in_expr( | Expr::PathBasename(p) | Expr::PathExtname(p) | Expr::PathResolve(p) - | Expr::PathIsAbsolute(p) => { + | Expr::PathIsAbsolute(p) + | Expr::PathToNamespacedPath(p) => { update_call_sites_in_expr(p, ctx, lookup); } Expr::ArrayPush { value, .. } diff --git a/crates/perry-hir/src/stable_hash.rs b/crates/perry-hir/src/stable_hash.rs index 2d81a5c8f9..82af2a0636 100644 --- a/crates/perry-hir/src/stable_hash.rs +++ b/crates/perry-hir/src/stable_hash.rs @@ -1745,6 +1745,20 @@ impl SH for Expr { } Expr::PathSep => tag(h, 101), Expr::PathDelimiter => tag(h, 102), + Expr::PathToNamespacedPath(e) => { + tag(h, 449); + e.as_ref().hash(h); + } + Expr::PathMatchesGlob(a, b) => { + tag(h, 450); + a.as_ref().hash(h); + b.as_ref().hash(h); + } + Expr::PathResolveJoin(a, b) => { + tag(h, 451); + a.as_ref().hash(h); + b.as_ref().hash(h); + } Expr::WeakRefNew(e) => { tag(h, 103); e.as_ref().hash(h); diff --git a/crates/perry-hir/src/walker.rs b/crates/perry-hir/src/walker.rs index 7b41a252b9..286903690c 100644 --- a/crates/perry-hir/src/walker.rs +++ b/crates/perry-hir/src/walker.rs @@ -156,6 +156,7 @@ where | Expr::PathNormalize(v) | Expr::PathParse(v) | Expr::PathFormat(v) + | Expr::PathToNamespacedPath(v) | Expr::FileURLToPath(v) | Expr::WeakRefNew(v) | Expr::WeakRefDeref(v) @@ -432,6 +433,8 @@ where | Expr::PathJoin(a, b) | Expr::PathRelative(a, b) | Expr::PathBasenameExt(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) | Expr::ObjectGetOwnPropertyDescriptor(a, b) | Expr::ObjectIs(a, b) | Expr::ObjectHasOwn(a, b) @@ -1349,6 +1352,7 @@ where | Expr::PathNormalize(v) | Expr::PathParse(v) | Expr::PathFormat(v) + | Expr::PathToNamespacedPath(v) | Expr::FileURLToPath(v) | Expr::WeakRefNew(v) | Expr::WeakRefDeref(v) @@ -1625,6 +1629,8 @@ where | Expr::PathJoin(a, b) | Expr::PathRelative(a, b) | Expr::PathBasenameExt(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) | Expr::ObjectGetOwnPropertyDescriptor(a, b) | Expr::ObjectIs(a, b) | Expr::ObjectHasOwn(a, b) diff --git a/crates/perry-runtime/src/object.rs b/crates/perry-runtime/src/object.rs index d097362da2..21940a5135 100644 --- a/crates/perry-runtime/src/object.rs +++ b/crates/perry-runtime/src/object.rs @@ -7377,9 +7377,42 @@ pub unsafe extern "C" fn js_native_module_property_by_name( if let Some(val) = get_native_module_constant(module_name, property_name, 0.0) { return val; } + // For native modules whose surface includes known callable methods or + // class exports, return a bound-method closure so `typeof` and property + // capture (`const f = tty.isatty`) match Node's "function" shape. The + // closure routes back through js_native_call_method when invoked. Kept + // narrow to specific (module, property) pairs so a typo'd access still + // returns undefined. + if is_native_module_callable_export(module_name, property_name) { + let heap_name = { + let layout = + std::alloc::Layout::from_size_align(property_name_len.max(1), 1).unwrap(); + let ptr = std::alloc::alloc(layout); + std::ptr::copy_nonoverlapping(property_name_ptr, ptr, property_name_len); + ptr + }; + let closure = crate::closure::js_closure_alloc(crate::closure::BOUND_METHOD_FUNC_PTR, 3); + let ns = js_create_native_module_namespace(module_name_ptr, module_name_len); + crate::closure::js_closure_set_capture_f64(closure, 0, ns); + crate::closure::js_closure_set_capture_ptr(closure, 1, heap_name as i64); + crate::closure::js_closure_set_capture_ptr(closure, 2, property_name_len as i64); + return crate::value::js_nanbox_pointer(closure as i64); + } f64::from_bits(crate::value::TAG_UNDEFINED) } +/// Whitelist of (module, property) pairs for which property-read should +/// produce a callable handle (a bound-method closure) rather than undefined. +/// Needed so `typeof tty.ReadStream === "function"` matches Node — the +/// method-call form (`tty.isatty(0)`) is already handled by a dedicated +/// codegen path, this just keeps the property-read form coherent. +fn is_native_module_callable_export(module: &str, prop: &str) -> bool { + matches!( + (module, prop), + ("tty", "isatty") | ("tty", "ReadStream") | ("tty", "WriteStream") + ) +} + /// Access a property on a native module namespace object. /// For method references (e.g., `fs.existsSync`), creates a bound method closure. /// For constant properties (e.g., `path.sep`, `fs.constants`), returns the value directly. diff --git a/crates/perry-runtime/src/path.rs b/crates/perry-runtime/src/path.rs index c1ec9662d0..84ac853863 100644 --- a/crates/perry-runtime/src/path.rs +++ b/crates/perry-runtime/src/path.rs @@ -21,9 +21,10 @@ fn string_to_js(s: &str) -> *mut StringHeader { js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } -/// Join two path segments. Node's `path.join` normalizes the result, so we -/// run the joined path through the same normalization helper as -/// `path.normalize`. +/// Join two path segments. Node's `path.join` concatenates with `/` and +/// normalizes — it does NOT reset on an absolute segment (that's +/// `path.resolve`'s job). We can't use Rust's `Path::join` because it +/// resets on absolute segments. #[no_mangle] pub extern "C" fn js_path_join( a_ptr: *const StringHeader, @@ -33,25 +34,49 @@ pub extern "C" fn js_path_join( let a = string_from_header(a_ptr).unwrap_or_default(); let b = string_from_header(b_ptr).unwrap_or_default(); - let joined = Path::new(&a).join(&b); - let normalized = normalize_str(&joined.to_string_lossy()); + let joined = if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + format!("{}/{}", a, b) + }; + let normalized = normalize_str(&joined); string_to_js(&normalized) } } -/// Get directory name from path +/// Get directory name from path. Per Node spec, the root's dirname is the +/// root itself (`/` → `/`), not an empty string — Rust's `Path::parent` +/// returns `None` there, which we treat as "stay at root". #[no_mangle] pub extern "C" fn js_path_dirname(path_ptr: *const StringHeader) -> *mut StringHeader { unsafe { let path_str = match string_from_header(path_ptr) { Some(s) => s, - None => return string_to_js(""), + None => return string_to_js("."), }; + if path_str.is_empty() { + return string_to_js("."); + } + + // POSIX root: dirname("/") = "/", dirname("///") = "/" + if path_str.chars().all(|c| c == '/') { + return string_to_js("/"); + } + let path = Path::new(&path_str); match path.parent() { - Some(parent) => string_to_js(&parent.to_string_lossy()), - None => string_to_js(""), + Some(parent) => { + let s = parent.to_string_lossy(); + if s.is_empty() { + string_to_js(".") + } else { + string_to_js(&s) + } + } + None => string_to_js("."), } } } @@ -348,3 +373,112 @@ pub extern "C" fn js_path_sep_get() -> *mut StringHeader { pub extern "C" fn js_path_delimiter_get() -> *mut StringHeader { string_to_js(":") } + +/// Internal helper for `path.resolve(a, b)` — like `js_path_join` but with +/// reset-on-absolute semantics (Node's `path.resolve` rule: when a later +/// segment is absolute, prior segments are discarded). Normalizes the +/// result. Used by the multi-arg `path.resolve` lowering to chain pairs. +#[no_mangle] +pub extern "C" fn js_path_resolve_join( + a_ptr: *const StringHeader, + b_ptr: *const StringHeader, +) -> *mut StringHeader { + unsafe { + let a = string_from_header(a_ptr).unwrap_or_default(); + let b = string_from_header(b_ptr).unwrap_or_default(); + + let joined = if b.starts_with('/') { + b + } else if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + format!("{}/{}", a, b) + }; + string_to_js(&normalize_str(&joined)) + } +} + +/// `path.toNamespacedPath(path)` — Windows-only effect on Node. On POSIX +/// it is a no-op that returns the input unchanged. Perry's path module +/// is POSIX-shaped, so we match that. +#[no_mangle] +pub extern "C" fn js_path_to_namespaced_path(path_ptr: *const StringHeader) -> *mut StringHeader { + unsafe { + let s = string_from_header(path_ptr).unwrap_or_default(); + string_to_js(&s) + } +} + +/// Convert a glob pattern (`*`, `?`, `[abc]`, `**`) into a regex, anchored +/// at both ends. Mirrors Node's `path.matchesGlob` semantics, which Node +/// documents as identical to `picomatch` defaults: `*` matches any chars +/// except `/`, `**` matches across `/`, `?` matches a single char except +/// `/`, character classes `[...]` work like regex. +fn glob_to_regex(pattern: &str) -> String { + let mut out = String::from("^"); + let bytes = pattern.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + match c { + '*' => { + if i + 1 < bytes.len() && bytes[i + 1] as char == '*' { + out.push_str(".*"); + i += 2; + continue; + } else { + out.push_str("[^/]*"); + } + } + '?' => out.push_str("[^/]"), + '[' => { + out.push('['); + i += 1; + while i < bytes.len() && bytes[i] as char != ']' { + let ch = bytes[i] as char; + if ch == '!' && out.ends_with('[') { + out.push('^'); + } else { + out.push(ch); + } + i += 1; + } + out.push(']'); + } + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '\\' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + i += 1; + } + out.push('$'); + out +} + +/// `path.matchesGlob(path, pattern)` — Node 22.5+ API. Returns whether the +/// given path matches the given glob pattern. +#[no_mangle] +pub extern "C" fn js_path_matches_glob( + path_ptr: *const StringHeader, + pattern_ptr: *const StringHeader, +) -> i32 { + unsafe { + let path_str = string_from_header(path_ptr).unwrap_or_default(); + let pattern = string_from_header(pattern_ptr).unwrap_or_default(); + let regex_src = glob_to_regex(&pattern); + match regex::Regex::new(®ex_src) { + Ok(re) => { + if re.is_match(&path_str) { + 1 + } else { + 0 + } + } + Err(_) => 0, + } + } +} diff --git a/crates/perry-runtime/src/tty.rs b/crates/perry-runtime/src/tty.rs index e204c5ee53..1e3b56d9e6 100644 --- a/crates/perry-runtime/src/tty.rs +++ b/crates/perry-runtime/src/tty.rs @@ -127,18 +127,32 @@ pub extern "C" fn js_tty_isatty(fd: f64) -> f64 { } /// `process.stdin.isTTY` / `process.stdout.isTTY` / `process.stderr.isTTY`. -/// Each takes the corresponding fd implicitly. +/// Per Node's docs these are `true` when the stream is a TTY and +/// `undefined` otherwise (intentionally — it's a presence test). This +/// differs from `tty.isatty(fd)`, which always returns a boolean. #[no_mangle] pub extern "C" fn js_process_stdin_isatty() -> f64 { - js_tty_isatty(0.0) + if isatty_impl(0) { + f64::from_bits(0x7FFC_0000_0000_0004) // TAG_TRUE + } else { + f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED + } } #[no_mangle] pub extern "C" fn js_process_stdout_isatty() -> f64 { - js_tty_isatty(1.0) + if isatty_impl(1) { + f64::from_bits(0x7FFC_0000_0000_0004) + } else { + f64::from_bits(0x7FFC_0000_0000_0001) + } } #[no_mangle] pub extern "C" fn js_process_stderr_isatty() -> f64 { - js_tty_isatty(2.0) + if isatty_impl(2) { + f64::from_bits(0x7FFC_0000_0000_0004) + } else { + f64::from_bits(0x7FFC_0000_0000_0001) + } } /// `process.stdout.columns` — terminal width in cells, or `undefined` From 48b5666afeb53bb0b1e18e0a263a94385832bb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 09:01:31 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(codegen):=20#740=20follow-up=20?= =?UTF-8?q?=E2=80=94=20object-literal=20class-field=20aliases=20(v0.5.902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on v0.5.901's class-expr→ClassRef change. Two new shapes now resolve a class-ref read out of an object-literal field back to the underlying class: const O = { Inner: class extends Base {…} }; new O.Inner(args) // direct const C = O.Inner; new C(args) // through an intermediate let Both share a new per-function side table FnCtx.local_class_field_aliases populated when Stmt::Let sees `init = New { __AnonShape, args }` and walks the class's field order against args — any Expr::ClassRef arg becomes a (local_id, field_name) → class_name entry. The map is also propagated through `let O2 = O`. Effect DoD still blocked on runtime parent-constructor dispatch. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++++++++-------------- Cargo.toml | 2 +- crates/perry-codegen/src/codegen.rs | 6 ++ crates/perry-codegen/src/expr.rs | 29 ++++++ crates/perry-codegen/src/stmt.rs | 49 ++++++++++ 7 files changed, 156 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cea5da758..349315eb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.902 — fix(codegen): #740 follow-up — object-literal class-field aliases. Builds on v0.5.901's class-expr→ClassRef change. Two new shapes now correctly resolve a class-ref read out of an object-literal field back to the underlying class, instead of falling through to the empty-object placeholder. (1) `const O = { Inner: class extends Base {…} }; new O.Inner(args)` — the `NewDynamic { callee: PropertyGet { LocalGet(O), "Inner" }, args }` case in `crates/perry-codegen/src/expr.rs`. (2) `const O = { Inner: class … }; const C = O.Inner; new C(args)` — same shape but with an intermediate `let` binding (the `Stmt::Let { init: Some(PropertyGet { LocalGet(other), prop }) }` arm in `crates/perry-codegen/src/stmt.rs`). Both share a new per-function side table `FnCtx.local_class_field_aliases: HashMap>` populated when `Stmt::Let` sees `init = New { class_name (an __AnonShape), args }` and walks the class's declared field order in parallel with the args — any `Expr::ClassRef(name)` arg becomes a `(local_id, field_name) → class_name` entry. The map is also propagated through `let O2 = O` (LocalGet of a known-shape local) so a re-binding doesn't lose the class info. **Validation.** Minimal repro `const O = { Inner: class extends Base { _tag = "X" } }; new O.Inner({issue:"x"}).issue` now prints `"x"`, matches Node. test740_b (function returns class via O.Inner, called outside) prints `inst._tag: X` correctly. Same 26/28 `test_gap_*` pass, same 20/23 `test_*class*` pass — no regressions. **Still open.** The full Effect DoD (#321) needs runtime parent-constructor dispatch: `class ParseError extends TaggedError("ParseError")` reaches `[3] pe._tag: undefined` because perry's `lower_new` only walks the static `extends_name` parent chain — `RegisterClassParentDynamic` registers the parent at runtime but no code path consults that registry to run the parent's field initializers / constructor. Fixing that means emitting each class's constructor as a separately addressable function and adding a runtime constructor-registry lookup, which is the same shape of work that would unblock `class X extends Factory()` generally. **Version-bump note.** Renumbered v0.5.897 → v0.5.902 to follow this PR's first commit (v0.5.901) after the main-side bumps that intervened. + ## v0.5.901 — fix(stdlib/path,tty,classexpr): close #741 + #742, partial #740. **Version-bump note.** Renumbered from v0.5.896 → v0.5.901 because #735 (v0.5.895–v0.5.898 #665 follow-ups) and #744 (v0.5.899 toml/deno_core bump) landed on main, and #743 (v0.5.900 #733 self-recursive inliner) is queued ahead of this PR. **#741 path module gaps.** Four sibling bugs in `node:path` that were the entire diff in `test-files/test_parity_path.ts` against Node 22. (1) `path.dirname("/")` returned `""` because Rust's `Path::new("/").parent()` is `None`; Node spec says the root's dirname is the root itself. Added an explicit POSIX-root short-circuit in `crates/perry-runtime/src/path.rs::js_path_dirname` — any all-`/` input returns `"/"`, empty input returns `"."`, parent-not-found falls back to `"."` (Node's "no separator" semantics). (2) `path.join("/foo/", "/bar/", "baz")` returned `/bar/baz` because `js_path_join` was implemented via Rust's `PathBuf::join` which resets on absolute segments — that's `path.resolve` semantics, not `path.join` semantics. Per Node docs, `path.join` concatenates all arguments with `/` then normalizes; an absolute middle segment is just another segment. Rewrote `js_path_join` to do raw `"{a}/{b}"` concat (with empty-arg short-circuits) followed by `normalize_str`. (3) `path.matchesGlob(path, pattern)` (Node 22.5+) wasn't implemented and threw the hard manifest-gate error. Added a runtime helper `js_path_matches_glob` that converts the glob to a regex (`*` → `[^/]*`, `**` → `.*`, `?` → `[^/]`, `[...]` classes pass through, special regex chars get escaped) and tests via the existing `regex` crate. (4) `path.toNamespacedPath(path)` is Windows-only on Node — POSIX returns the input unchanged. Perry was unimplemented, threw `TypeError: value is not a function`, and that throw terminated the entire script (so the parity test's lines 38+ silently never ran). Added `js_path_to_namespaced_path` as a POSIX no-op (returns input verbatim). **Plumbing.** Two new HIR variants `PathToNamespacedPath(Box)` and `PathMatchesGlob(Box, Box)` plus a third `PathResolveJoin(Box, Box)` introduced as a side fix: changing `js_path_join` to the correct concat semantics broke the existing `path.resolve(a, b, c)` lowering, which chained `PathJoin` for the multi-arg path — `PathJoin` was inadvertently doing double duty as the resolve-style "reset on absolute" join. Split the two: the HIR lowering for `path.resolve(a, b, c)` now chains `PathResolveJoin` (which does have reset-on-absolute, via a new `js_path_resolve_join` runtime helper) and the chain wraps a final `PathResolve`. `path.join(a, b, c)` still chains plain `PathJoin` (now correct concat semantics). New entries added to walker.rs, stable_hash.rs (tags 449/450/451), analysis.rs, monomorph.rs, js_transform.rs, type_analysis.rs, collectors.rs, codegen-js/emit.rs, codegen-wasm/emit.rs, runtime_decls.rs, and expr.rs. Also added `method("path", "toNamespacedPath", …)` and `method("path", "matchesGlob", …)` to `perry_api_manifest::entries` so the unimplemented gate stops firing. **Validation.** Issue's 5-line repro matches Node byte-for-byte. Full `test-files/test_parity_path.ts` diff drops from 15 lines to 2 (`posix.join` / `win32.join` — separate sub-namespace work, not in scope for #741). **#742 tty exports + isTTY shape.** Three bugs that were the entire diff in `test-files/test_parity_tty.ts`. (1) `typeof tty.ReadStream`, `typeof tty.WriteStream`, and `typeof tty.isatty` all returned `"undefined"` because reading them as PropertyGet on the `NativeModuleRef("tty")` namespace went through `js_native_module_property_by_name` which only consults the constants dispatcher (none of them are constants). The call-site form `tty.isatty(0)` worked via a dedicated HIR lowering, but the property-read form (`typeof`, `const f = tty.isatty`) returned `undefined`. Fix: at the tail of `js_native_module_property_by_name` (`crates/perry-runtime/src/object.rs`), check `is_native_module_callable_export(module, prop)` — a whitelist currently of `("tty","isatty")`, `("tty","ReadStream")`, `("tty","WriteStream")` — and if matched, synthesize a `BOUND_METHOD_FUNC_PTR` closure capturing the namespace object + method name (same shape `js_native_module_bind_method` creates for ordinary method access). Closures NaN-box with the magic header that `js_value_typeof` recognizes as `"function"`. Whitelist deliberately narrow so `typeof tty.bogusName` still returns `"undefined"`. (2) `process.std{in,out,err}.isTTY` returned `false` (typeof `"boolean"`) when stdout was piped, but Node's docs spec it as `true` when TTY, **`undefined` otherwise** — `isTTY` is a presence-test, not a boolean. Many libraries do `if ("isTTY" in process.stdout)` or `if (process.stdout.isTTY !== undefined)` and got the wrong answer under Perry. Changed `js_process_stdin_isatty` / `js_process_stdout_isatty` / `js_process_stderr_isatty` to return `TAG_UNDEFINED` (not `TAG_FALSE`) when the corresponding fd isn't a TTY. `tty.isatty(fd)` itself still returns a real boolean — that's the documented Node contract. **Validation.** Issue's 5-line repro matches Node byte-for-byte. Full `test-files/test_parity_tty.ts` parity test now passes byte-for-byte (was 6 lines off). **#740 Effect ParseResult.ts crash (partial fix).** The actual root issue tracks `class X extends Factory()<...>` (a class extending a factory-call result) which is the Effect blocker, and requires runtime constructor dispatch the compiler doesn't yet have. What landed here is the *narrower* compiler fix that moves the standalone repro from "throws `TypeError: value is not a function`" to "runs to completion (instance has undefined fields)": class expressions used as values were lowering to `Expr::New { class_name, args: vec![] }` — i.e. creating a zero-arg instance — rather than to `Expr::ClassRef(class_name)`. So `const C = class { ... }` bound `C` to a stillborn instance, and `O.Inner` inside `{ Inner: class { ... } }` likewise. Changed `crates/perry-hir/src/lower.rs`'s `ast::Expr::Class` arm to emit `Expr::ClassRef(synthetic_name)` instead. With the alias-chain propagation already in `Stmt::Let` (codegen/stmt.rs), this makes `const C = class {...}; new C(args)` and `function f() { const C = class {...}; return C; } new f()(args)` work end-to-end (constructor runs with the supplied args). The remaining `O.Inner`-via-PropertyGet case still falls through to the empty-object placeholder in `NewDynamic` because the codegen has no runtime constructor dispatch — the value at the property is the class ref, but `new ` doesn't yet route through a registry. That's the same architectural gap that blocks `class X extends Factory()`, and it's the next layer #740 needs. Effect's standalone repro now prints `[1] before` / `[2] after class def` / `[3] pe._tag: undefined` (no crash, partial fields) rather than the original throw. **Validation.** All 26 / 28 `test_gap_*` tests still pass — same 2 pre-existing failures as main (`test_gap_console_methods.ts`, `test_gap_regexp_advanced.ts` — known categorical gaps in CLAUDE.md). All 20 / 23 `test_*class*.ts` tests still pass — same 3 pre-existing differences as main. No regressions. `cargo test -p perry-runtime --lib` 250 passed, 0 failed. **Out of scope.** (a) Runtime constructor dispatch — `js_new_dynamic(callee_value, args_vec)` that inspects the callee's NaN tag and routes to the right constructor when callee is an INT32-tagged class-id; needed for `new O.Inner(args)` (where `O.Inner` is a class ref read from an object field) and `class X extends Factory()` (where the parent class is a runtime value). Tracked as #740 follow-up. (b) `path.posix.X(...)` / `path.win32.X(...)` sub-namespace method calls — these still print `undefined` because the `is_sub_namespace` flag in expr_call.rs:519 short-circuits the NativeMethodCall lowering and the fall-through path doesn't dispatch back to the corresponding `path.X(...)` for POSIX. Not in scope for #741's stated acceptance (the 5-line repro), but is the last diff in `test_parity_path.ts`. (c) The `BaseEffectError`-via-object-literal pattern that Effect uses for `TaggedError` — needs (a) to dispatch the constructor when reading the class ref back from the object field. ## v0.5.900 — fix(transform): #733 perry-main stack overflow on self-recursive functions whose body is `return self(...)`. **Symptom (Windows).** The compiler crashed during HIR processing with `thread 'perry-main' (NNNNN) has overflowed its stack` and no other diagnostic. User's repro was `import {} from '@piying/valibot-visit'` with valibot-visit in `compilePackages`; bisected down to a 2-line minimal: `function foo(): any { return foo(); }; foo();` — any program compiling a function whose body is a single `return (...)` blew the 64 MB perry-main stack. (More elaborate triggers — self-recursive function called from inside a loop, or a recursive caller of a self-recursive helper — were all variations on the same root cause. Surface area is broad: valibot-visit's `getSchemaByIssuePath` recursing through `findAsControlAction` → `schemaForEach` was the field repro.) **Root cause.** `crates/perry-transform/src/inline.rs::try_inline_simple_call` matches the "single Return(expr)" pattern by substituting the body's return expression at the call site — for `function foo() { return foo(); }` the substituted result is *another* call to `foo()`. The enclosing `inline_calls_in_expr` then recurses on the substituted result (line 2204 — "subsequent layers of small-function calls collapse cleanly"), sees `foo()` again, looks `foo` up in `func_candidates` (still present), inlines again, recurses, inlines, recurses... unbounded. `is_inlinable` had eight rejection criteria — async, generator, captures, rest params, body size, simple-control-flow, closure-capturing-params, super-call — but no self-recursion check. A self-recursive function with `body.len() == 1` and shape `[Return(Some())]` passed every gate. Surface comment at the inliner's outer recurse said "Termination relies on `is_inlinable` rejecting recursive functions in practice (cyclic call chains either don't form or get filtered out by other criteria)" — that assumption held for mutual recursion (because the called function's pattern usually didn't match) but missed the direct-self-recursive case where both the caller pattern AND the callee's body are the same `return self(...)` shape. Fixed-call-graph analyses for fibonacci-shaped recursion (`if base; return self(n-1) + self(n-2)`) survived because the body has more than one statement — `body.len() > 1` and `has_simple_control_flow` rejecting If-with-Return-in-both-branches both removed those from `func_candidates` indirectly. The catastrophic case was specifically the single-statement `return self(...)` shape, which is what valibot-visit's transpiled `getSchemaByIssuePath` reduces to through SWC's `function foo(x): any { return foo(x); }` synthesis path. **Fix.** Add `body_calls_func(&func.body, func.id)` to `is_inlinable`. The check walks statements + expressions recursively, returning true the moment it finds an `Expr::Call { callee: FuncRef(target_id) }` where `target_id == func.id`. Closure bodies are deliberately skipped — a self-reference inside a nested closure is a value-position read (the closure body becomes its own codegen unit, not an inlinable expression in the outer frame). Self-recursive functions are now never added to `func_candidates`; the inliner leaves them as ordinary calls and codegen emits a normal `call` instruction. **Validation.** Minimal repro (`function foo(): any { return foo(); }; foo();`) pre-fix: 64 MB stack overflow on perry-main during transform; post-fix: links and runs. User's exact reproduction (`import * as v from "valibot"; import {} from "@piying/valibot-visit"; v.parse(v.string(), "123")` with both packages in `compilePackages`) pre-fix: stack overflow after the two `Intl` HIR warnings; post-fix: compiles to a 3.6 MB binary. `fib(10) === 55` still inlines/dispatches correctly because fib's body is `[If { ... }, Return(self(n-1)+self(n-2))]` — body.len() > 1 path, and the addition contains two self-calls that the new check also rejects, so the inliner skips fib entirely (and codegen handles the recursion natively, which it always did for fib regardless). `cargo build --release -p perry-transform -p perry` clean. **Out of scope.** Mutual recursion between two functions (`f → g → f`) where both `f` and `g` happen to match the single-Return-of-Call pattern: theoretically possible but never observed in practice (mutual single-Return-of-Call cycles are vanishingly rare in real codebases because at least one side typically has a base case). If it surfaces, the fix is to graduate `body_calls_func` into a `body_in_call_cycle` SCC analysis on `func_candidates`. Tracked but not built yet. **Version-bump note.** Renumbered from v0.5.893 → v0.5.900 because the original v0.5.893 slot was taken by #736 (HarmonyOS Chart + TreeView) and main has since advanced to v0.5.899 via #734/#737/#735/#744 while this PR was in review. diff --git a/CLAUDE.md b/CLAUDE.md index 0de47a2ee1..0d9cd01ae2 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.901 +**Current Version:** 0.5.902 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 64f9396fc0..cbdc3b75bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.901" +version = "0.5.902" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.901" +version = "0.5.902" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.901" +version = "0.5.902" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.901" +version = "0.5.902" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.901" +version = "0.5.902" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.901" +version = "0.5.902" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.901" +version = "0.5.902" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.901" +version = "0.5.902" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.901" +version = "0.5.902" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.901" +version = "0.5.902" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.901" +version = "0.5.902" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.901" +version = "0.5.902" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.901" +version = "0.5.902" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.901" +version = "0.5.902" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.901" +version = "0.5.902" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.901" +version = "0.5.902" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.901" +version = "0.5.902" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.901" +version = "0.5.902" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.901" +version = "0.5.902" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.901" +version = "0.5.902" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.901" +version = "0.5.902" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.901" +version = "0.5.902" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.901" +version = "0.5.902" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.901" +version = "0.5.902" [[package]] name = "perry-ui-android" -version = "0.5.901" +version = "0.5.902" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.901" +version = "0.5.902" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.901" +version = "0.5.902" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.901" +version = "0.5.902" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.901" +version = "0.5.902" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.901" +version = "0.5.902" [[package]] name = "perry-ui-tvos" -version = "0.5.901" +version = "0.5.902" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.901" +version = "0.5.902" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.901" +version = "0.5.902" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.901" +version = "0.5.902" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.901" +version = "0.5.902" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.901" +version = "0.5.902" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index a828eb635c..76dc242e77 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.901" +version = "0.5.902" 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 7a7fc6250d..4893149155 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -2949,6 +2949,7 @@ fn compile_function( strictly_i32_bounded_locals: &strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, @@ -3328,6 +3329,7 @@ fn compile_closure( strictly_i32_bounded_locals: &strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, @@ -3552,6 +3554,7 @@ fn compile_method( strictly_i32_bounded_locals: &strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, @@ -3983,6 +3986,7 @@ fn compile_module_entry( strictly_i32_bounded_locals: &main_strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, @@ -4244,6 +4248,7 @@ fn compile_module_entry( strictly_i32_bounded_locals: &init_strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, @@ -4954,6 +4959,7 @@ fn compile_static_method( strictly_i32_bounded_locals: &strictly_i32_bounded_locals, i18n: &cross_module.i18n, local_class_aliases: HashMap::new(), + local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), imported_vars: &cross_module.imported_vars, compile_time_constants: &cross_module.compile_time_constants, diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index 7dfac51867..b212da8a4d 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -533,6 +533,15 @@ pub(crate) struct FnCtx<'a> { /// binding's scope ends with the function. pub local_class_aliases: std::collections::HashMap, + /// Refs #740: when an object literal embeds a class reference in a + /// field (`const O = { Inner: class extends Base {…} }`), record + /// `local_id_of_O → { "Inner" → "__anon_class_N" }` so subsequent + /// `new O.Inner(args)` and `let C = O.Inner; new C(args)` reads can + /// resolve back to the underlying class. Without this, both fall + /// through to the empty-object placeholder. + pub local_class_field_aliases: + std::collections::HashMap>, + /// `LocalId → name` lookup table for chained class alias /// resolution. The HIR's `Stmt::Let { name, .. }` gives us the /// (id, name) pair at lowering time, but the rest of FnCtx tracks @@ -4740,6 +4749,26 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return lower_new(ctx, name, args); } + // Refs #740: `new O.Inner(args)` where `O` is an object + // literal whose `Inner` field was initialized from a class + // expression. The Stmt::Let lowering populates + // `local_class_field_aliases[O_id]["Inner"] = "__anon_class_N"` + // when it sees the original literal — read it back here and + // dispatch to `lower_new` instead of the empty-object + // fallback. + if let Expr::PropertyGet { object, property } = callee.as_ref() { + if let Expr::LocalGet(obj_id) = object.as_ref() { + if let Some(class_name) = ctx + .local_class_field_aliases + .get(obj_id) + .and_then(|f| f.get(property)) + .cloned() + { + return lower_new(ctx, &class_name, args); + } + } + } + // Case 3: callee is a ternary. Synthesize a NewDynamic for // each branch and emit a runtime if/else with phi. The inner // NewDynamics fall through this same handler — if they're diff --git a/crates/perry-codegen/src/stmt.rs b/crates/perry-codegen/src/stmt.rs index 2893fc6529..7a79bdde8c 100644 --- a/crates/perry-codegen/src/stmt.rs +++ b/crates/perry-codegen/src/stmt.rs @@ -177,10 +177,59 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { ctx.local_class_aliases.insert(name.clone(), resolved); } } + // Also propagate the per-object field-class map: `let + // O2 = O` should carry `O`'s known field→class + // bindings forward (otherwise `new O2.Inner(...)` + // can't resolve back to the class). Refs #740. + if let Some(fields) = ctx.local_class_field_aliases.get(other_id).cloned() { + ctx.local_class_field_aliases.insert(*id, fields); + } + } + // Refs #740: `let X = O.Inner` where `O` is an object + // literal that holds a class ref under "Inner" — promote + // X to a class alias so `new X(args)` dispatches to the + // real class instead of the empty-object placeholder. + Some(perry_hir::Expr::PropertyGet { object, property }) => { + if let perry_hir::Expr::LocalGet(other_id) = object.as_ref() { + if let Some(fields) = ctx.local_class_field_aliases.get(other_id) { + if let Some(class_name) = fields.get(property) { + ctx.local_class_aliases + .insert(name.clone(), class_name.clone()); + } + } + } } _ => {} } + // Refs #740: object literal embeds class refs. When `init` is + // `Expr::New { class_name (an __AnonShape), args }`, walk the + // class's fields and the args in parallel — any `ClassRef` + // arg becomes a `(local_id, field_name) → class_name` entry + // in `local_class_field_aliases`. This lets later reads + // (`O.Inner` / `let C = O.Inner`) recover the underlying + // class. Mirrors the shape-fields ordering produced by + // `synthesize_anon_shape_class` in the HIR lowering. + if let Some(perry_hir::Expr::New { + class_name: shape_name, + args, + .. + }) = init.as_ref() + { + if let Some(class) = ctx.classes.get(shape_name).copied() { + let mut field_map: std::collections::HashMap = + std::collections::HashMap::new(); + for (field, arg) in class.fields.iter().zip(args.iter()) { + if let perry_hir::Expr::ClassRef(class_name_ref) = arg { + field_map.insert(field.name.clone(), class_name_ref.clone()); + } + } + if !field_map.is_empty() { + ctx.local_class_field_aliases.insert(*id, field_map); + } + } + } + // Issue #50: row-alias detection. When `let krow = X[i]` where // `X` is a folded flat-const 2D int array, record // `krow_id → (X_id, i)` so a later `krow[j]` can lower through From 7e2c2faa2d20548a34e5b38b7f9affb004087942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 13 May 2026 12:45:17 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20fix=20CI=20=E2=80=94=20cargo=20fmt?= =?UTF-8?q?=20+=20regen=20API=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo fmt collapsed a 2-line `let layout = …Layout::from_size_align(...)` in object.rs:7385 (lint job) - regen docs/api/perry.d.ts and docs/src/api/reference.md for the new path.matchesGlob / path.toNamespacedPath entries added in v0.5.901 (api-docs-drift job) --- crates/perry-runtime/src/object.rs | 3 +-- docs/api/perry.d.ts | 6 +++++- docs/src/api/reference.md | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/object.rs b/crates/perry-runtime/src/object.rs index 21940a5135..790e8987c7 100644 --- a/crates/perry-runtime/src/object.rs +++ b/crates/perry-runtime/src/object.rs @@ -7385,8 +7385,7 @@ pub unsafe extern "C" fn js_native_module_property_by_name( // returns undefined. if is_native_module_callable_export(module_name, property_name) { let heap_name = { - let layout = - std::alloc::Layout::from_size_align(property_name_len.max(1), 1).unwrap(); + let layout = std::alloc::Layout::from_size_align(property_name_len.max(1), 1).unwrap(); let ptr = std::alloc::alloc(layout); std::ptr::copy_nonoverlapping(property_name_ptr, ptr, property_name_len); ptr diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 6f60a56f69..772c7c6368 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 820 entries across 70 modules +// Coverage: 822 entries across 70 modules declare module "argon2" { /** stdlib */ @@ -525,6 +525,8 @@ declare module "path" { /** stdlib */ export function join(...args: any[]): any; /** stdlib */ + export function matchesGlob(...args: any[]): any; + /** stdlib */ export function normalize(...args: any[]): any; /** stdlib */ export function parse(...args: any[]): any; @@ -532,6 +534,8 @@ declare module "path" { export function relative(...args: any[]): any; /** stdlib */ export function resolve(...args: any[]): any; + /** stdlib */ + export function toNamespacedPath(...args: any[]): any; } declare module "perry/background" { diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 7e82204894..fe4c297f71 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 820 entries across 70 modules. +Total: 822 entries across 70 modules. ## Modules @@ -763,10 +763,12 @@ Total: 820 entries across 70 modules. - `format` — module - `isAbsolute` — module - `join` — module +- `matchesGlob` — module - `normalize` — module - `parse` — module - `relative` — module - `resolve` — module +- `toNamespacedPath` — module ### Properties