## 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<Expr>)` and `PathMatchesGlob(Box<Expr>, Box<Expr>)` plus a third `PathResolveJoin(Box<Expr>, Box<Expr>)` 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 <local-with-tagged-class-id>` 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.
0 commit comments