Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.599
**Current Version:** 0.5.600


## TypeScript Parity Status
Expand Down Expand Up @@ -157,6 +157,8 @@ One-liners only — full detail in CHANGELOG.md.

- **v0.5.598** — Closes #512: the auto-generated `.d.ts` (`docs/api/perry.d.ts`) and markdown reference (`docs/src/api/reference.md`) used to render every stdlib function as `(...args: any[]): any`, so editors and `tsc` accepted obviously wrong calls — `bcrypt.hash(123, "salt")` passed clean despite the runtime expecting a string for the first arg. Three coordinated fixes: **(1)** `crates/perry-api-manifest/src/lib.rs` grows two public types — `enum TypeSpec { String, Number, Bool, BigInt, Buffer, Handle, Void, Any }` mirrors the param/return-type vocabulary in `docs/src/native-libraries/manifest-v1.md` so the in-tree manifest and external `perry.nativeLibrary` manifests share one model, and `enum ParamSpec { Named { name, ty, optional }, Rest { name, ty } }` carries one slot of a method's signature. `ApiEntry` gains `params: &'static [ParamSpec]` + `returns: TypeSpec`; existing const-fn helpers (`method` / `property` / `class`) default both to `&[]` / `Any`, and a new `method_sig` helper takes the typed shape so backfilled rows stay terse. **(2)** `crates/perry-api-manifest/src/entries.rs` backfills 110 module-level (no-receiver, no class_filter) rows from `NATIVE_MODULE_TABLE`. The mapping is `NA_STR → String`, `NA_F64`/`NA_PTR`/`NA_JSV → Any` (these slots accept arbitrary NaN-boxed JSValues — narrowing them would lie), `NA_VARARGS → Rest(Any)`; returns map `NR_STR → String`, `NR_BIGINT → BigInt`, `NR_PTR`/`NR_F64 → Any` (Promise-typed and handle returns vary too widely to claim a concrete type), `NR_I32`/`NR_VOID → Void`. Hand-curated overrides for the rows where the dispatch table's `NA_F64` actually means "a string is what the user passes" — `bcrypt.hash` / `bcrypt.compare` / `argon2.hash` / `argon2.verify` / `jsonwebtoken.{sign,verify,decode}` / `cron.{validate,schedule,describe}` / `validator.is*` / `uuid.{v4,v1,v7,validate}` / `nanoid.nanoid` get tightened to `string` first-args, and `validator.is*` / `cron.validate` / `uuid.validate` get `boolean` returns. Hand-listed entries (crypto / os / path / process / classes / properties / perry/*) stay on the `&[] / Any` default since their dispatch goes through custom `Expr::*` HIR variants whose param types vary per call site — separate followup. **(3)** `crates/perry-api-manifest/src/emit.rs::emit_dts` renders real signatures via a new `render_signature(entry)` helper that walks `entry.params` + `entry.returns`. The fallback shape is preserved: when `params` is empty AND `returns` is `Any`, the emitter still prints `(...args: any[]): any` so un-typed entries don't regress. Two new tests gate the behavior — `dts_bcrypt_hash_has_real_signature` asserts the bcrypt block contains `export function hash(password: string` and does NOT contain the loose `(...args: any[])` fallback; `dts_uuid_v4_has_no_args` asserts `uuid.v4` renders as `(): string`. **(4)** `crates/perry-codegen/tests/manifest_consistency.rs` gains a parallel `manifest_param_counts_match_dispatch_table` test that walks the auto-derivable rows and asserts the manifest's `params.len()` matches the dispatch table's args arity, AND that no manifest entry tries to NARROW an `NA_STR` slot onto something other than `String` / `Any` (which would let `tsc` accept calls codegen would mis-coerce). Narrowing in the OTHER direction (`NA_F64`/`PTR`/`JSV` slot declared as `String`) is allowed since those FFI paths accept any NaN-boxed JSValue. The test uses the new `arg_kinds` / `ret_kind` fields on `NativeMethodRef` (returned as opaque `"NA_STR"` / `"NR_F64"` tag strings so perry-api-manifest doesn't have to depend on perry-codegen's internal enums). **Acceptance test verified end-to-end**: `bcrypt.hash(123, "salt")` against the regenerated `docs/api/perry.d.ts` now produces `error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'`, exit 1; the same file accepts the correct `bcrypt.hash("password123", 10)` cleanly. Docs regenerated via `scripts/regen_api_docs.sh` so the api-docs-drift CI gate stays green. **Deferred followups**: instance methods (has_receiver=true) and class-filtered rows still hang off `[key: string]: any;` on the bare class — narrowing those needs HIR work to thread receiver types into the manifest. Hand-curated entries (crypto.subtle, os.cpus, path.join, ...) still render with the loose fallback; tightening them is a per-entry pass that depends on what real signatures the runtime functions accept.

- **v0.5.600** — **Closes #513** (followup to #463): API manifest now enumerates every entry in `NATIVE_MODULES` and every routed binding in `well_known_bindings.toml`, so the unimplemented-API gate flips into strict mode for every module Perry advertises support for. Pre-#513 coverage was **397 entries across 45 modules**; post-#513 is **638 entries across 64 modules** — the gap audit found ~20 modules with zero entries (`fs`, `util`, `stream`, `child_process`, `tty`, `http`, `https`, `axios`, `node-fetch`, `bignumber.js`, `node-cron`, `perry/ui`, `perry/system`, `perry/i18n`, `perry/updater`, `perry/media`, `perry/plugin`, `perry/widget`, plus the well-known aliases `redis` / `date-fns` / `streams` / `rate-limiter-flexible` / `fetch` that weren't even registered as native modules). The pre-#513 gate at `crates/perry-hir/src/lower/expr_member.rs:519` is `module_has_any_entries(M) && module_has_symbol(M, prop).is_none()` — modules with zero entries silently fell through to the old permissive behaviour, so e.g. `axios.foo` compiled cleanly and returned `undefined` at runtime, the same class of bug Justin originally hit on `crypto.subtle` in the #455 / #463 thread. Two coordinated changes ship the fix: **(1)** `crates/perry-api-manifest/src/entries.rs` — backfill of ~250 manifest entries covering the gap modules. perry/ui, perry/system, perry/i18n, perry/updater, perry/media are auto-derivable from the matching `PERRY_*_TABLE` in `crates/perry-dispatch/src/lib.rs` (mechanical; the parallel `every_dispatch_entry_has_manifest_counterpart` test guards against drift in either direction now). perry/plugin entries from `PERRY_PLUGIN_TABLE` in `crates/perry-codegen/src/lower_call.rs`. The Node-builtin gap modules (`fs` / `util` / `stream` / `child_process` / `tty` / `buffer` / `url`) have method/class entries reflecting what perry-runtime + perry-stdlib actually implement — `fs` covers the sync surface lowered to `Expr::Fs*` plus the async wrappers + stream constructors; `util` covers `inspect` / `format` / `promisify` / `callbackify` / `deprecate` / `inherits` / `isDeepStrictEqual` plus the TextEncoder / TextDecoder classes; `stream` covers Readable / Writable / Duplex / Transform / PassThrough plus `pipeline` / `finished`; `child_process` covers `exec` / `execSync` / `execFile` / `execFileSync` / `spawn` / `spawnSync` / `fork`; `tty` covers `isatty` + ReadStream / WriteStream classes. The HTTP-client trio (`axios` / `node-fetch` / `http` / `https`) covers the standard verb methods plus class constructors. The well-known aliases (`redis` / `date-fns` / `bignumber.js` / `node-cron` / `streams` / `rate-limiter-flexible` / `fetch`) get entries that mirror their canonical-module surface so `import { Redis } from 'redis'` works the same way as `import { Redis } from 'ioredis'`. **(2)** `crates/perry-codegen/tests/manifest_consistency.rs` — two new reverse-direction drift tests. `every_native_module_has_at_least_one_manifest_entry` walks `NATIVE_MODULES` and asserts each one has at least one manifest entry (allow-list: `dotenv/config`, side-effect-only sub-path with no value binding); `every_well_known_binding_has_manifest_entry` parses `crates/perry/well_known_bindings.toml` and asserts each routed module name has manifest coverage. Both tests fail loudly with the missing module names + the fix recipe, so adding a future native module without manifest entries breaks CI before the PR ships. **(3)** `crates/perry-hir/tests/unimplemented_api_check.rs` — new `every_supported_module_rejects_bogus_member` parity sweep that compiles `import * as m from "<module>"; const x = m.__perry_known_bogus_member_513__;` for every entry in `NATIVE_MODULES` and asserts the R005 / #463 error fires (skip list: `dotenv/config` + the external `tursodb` / `iroh` bindings whose value-binding shape doesn't trigger the gate in the isolated HIR test). The previously-permissive `module_with_no_manifest_entries_is_permissive` test that documented the pre-#513 fall-through is replaced with `supported_module_with_unknown_member_is_rejected`, asserting the new strict behaviour. End-to-end smoke verified: `import * as fs from "fs"; const x = fs.bogusMethod;` errors with `R005`-style message naming the offending property; `import * as ui from "perry/ui"; ui.bogusWidget;` errors identically; legitimate calls like `fs.writeFileSync(p, "ok")` + `path.join("/tmp", "x")` continue to compile + run unchanged. `docs/src/api/reference.md` (now 638 entries / 64 modules) and `docs/api/perry.d.ts` regenerated through `scripts/regen_api_docs.sh` — the `api-docs-drift` CI gate from v0.5.560 keeps these in sync going forward. Parity sweep clean: no regressions vs main (the manifest backfill is additive — modules previously in strict mode keep their existing entries; modules previously in permissive mode flip strict only for `module.unknown_property` shapes, which no parity test legitimately exercises). Closes the credibility gap from #513's "v0.5.585 release implies completeness it doesn't have yet" framing — `--print-api-manifest` now genuinely reflects the supported surface, and a user on `async_hooks` / `dgram` / etc. gets the same #463 compile-time error every other module produces.

- **v0.5.597** — **Closes #511** (followup to #462): `x.foo()` where `x` is `undefined` or `null` now throws `TypeError: Cannot read properties of <undefined|null> (reading '<method>')` and exits 1, instead of silently no-op'ing and exiting 0. Audit for #511's "all uncaught throws should exit non-zero" concern: `js_throw_type_error_property_access`, `js_throw_type_error_not_a_function`, and `exception::js_throw` already call `std::process::exit(1)` on uncaught — and the issue's exact repro (`obj.foo` read on undefined) already exits 1 via the v0.5.526 helper. Hole found: `Call { callee: PropertyGet }` in `crates/perry-codegen/src/lower_call.rs::2208` shortcuts straight to `js_native_call_method(recv, method, ...)` without re-evaluating the receiver through `Expr::PropertyGet`, so the codegen-side nullish gate (`crates/perry-codegen/src/expr.rs::3320+`, issue #462) never fires for the call form. Fix: new arm at `crates/perry-runtime/src/object.rs::js_native_call_method` (between the `match method_name { "toString"/"bind"/"push"/... }` block and the existing `primitive_kind` Issue #510 catch-all) — when `jsval.is_undefined() || jsval.is_null()` and no earlier dispatch fired, call `js_throw_type_error_property_access(is_null, method_name_ptr, method_name_len)` (the same helper #462 / v0.5.526 uses, so message format and exit-code path are identical). The check intentionally lives AFTER the toString/bind/push/pop/length match arms — Perry's existing Perry-ism of `undefined.toString()` → `"undefined"` / `null.toString()` → `"null"` continues to work unchanged (Node throws there too, but a wider tightening would break unrelated callers; this fix surfaces only the catch-all typo case `x.foo()` that previously exited 0). Try/catch interaction inherits `js_throw_type_error_property_access`'s pre-existing `process::exit(1)` direct-call (the helper bypasses the `js_throw` longjmp path even in the v0.5.526 PropertyGet shape — separate, pre-existing limitation tracked outside this issue). End-to-end smoke: `const x: any = undefined; x.foo()` → `TypeError: Cannot read properties of undefined (reading 'foo')`, exit 1; `const y: any = null; y.bar` → unchanged (existing #462 path); `undefined.toString()` → `"undefined"` (Perry-ism preserved); user-level `throw new Error("kaboom")` without try/catch → `Uncaught exception: Error: kaboom`, exit 1 (existing `exception::js_throw` path); legitimate method calls on real objects unchanged. Out-of-scope but related (separate followups): `arr[0]` / `arr[0]()` on undefined still silently returns `undefined` / no-ops via `js_dyn_index_get` and the IndexGet call shortcut — those need an analogous nullish gate in `Expr::IndexGet` lowering and a runtime-side check; not blocking #511 since the issue's stated repro and most-common typo case (method call on undefined) are now fixed. Parity sweep: 209 pass / 3 known fails / 1 known compile fail / 13 skipped (98.5%, no regressions vs main).

- **v0.5.596** — Refs #519 (partial — vtable `this` NaN-boxing): `call_vtable_method` in `crates/perry-runtime/src/object.rs` was bit-casting the raw pointer `this: i64` directly to f64, producing a subnormal float (no NaN-box tag). The dispatched method body's `this` parameter then arrived as a "number" — every nested method call inside the body fell through to the issue #510 catch-all and threw `TypeError: (number).<method> is not a function`. The fix wraps the raw pointer with `POINTER_TAG` (via `JSValue::pointer(ptr).bits()`) before casting to f64 when the bits look like a raw pointer (`0 < bits ≤ 0x0000_FFFF_FFFF_FFFF`); already-NaN-boxed values pass through unchanged. End-to-end with `app.router.match('GET', '/')`: the OUTER call now correctly dispatches to SmartRouter.match with a real instance pointer as `this`, but the INNER `router.match(method, path)` call inside SmartRouter's body (where `router` is `routers[i]` from a class field) still trips a different path that doesn't reach `call_vtable_method` — only the outer dispatch is unblocked, hono's `app.fetch(req)` still throws. The remaining cross-module class-method dispatch issue is tracked in #519. Parity: 208 pass / 3 fail / 2 compile-fail / 13 skipped — the 2 compile-fails are the parallel-codegen race (#509) which gets timing-shifted by every codegen-touching commit; the 3 mismatches are the long-standing pre-existing knowns. Updated `test-parity/known_failures.json` to add `test_issue_310_namespace_reexport` alongside `test_issue_446_import_type_method_typeof` for #509's tracking.
Expand Down
Loading
Loading