From 0debbcbd29d20372ef757068320189f0c9b72d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 23 Apr 2026 02:51:23 +0200 Subject: [PATCH 1/2] fix(hir): #144 reject TypeScript decorators instead of silently dropping (v0.5.165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decorators were parsed into the HIR (lower_decorators in lower_types.rs, Decorator field in ir.rs) but no codegen consumer read them. Programs using @Component / @log / @Module compiled clean and the decorator body never executed. Added reject_decorators helper in lower_decl.rs that walks every decoration point on ast::Class (class, method, private-method, prop, private-prop, constructor param, method param) and bail!s with a message naming the decorator and linking to the limitations doc. Called from lower_class_decl and lower_class_from_ast. Same failure-mode reasoning as v0.5.119's warn→bail upgrade: silent no-ops are the worst compile outcome. Also flipped README.md's "Decorators | ✅" row to ❌ with a link to limitations.md, deleted test-files/test_decorators.ts (its header claimed "Perry implements @log" — never true), and stripped the decorator block from test-files/test_integration_app.ts. Separate followup: example-code/nestjs-typeorm relies on decorators and was never actually working — now fails loudly at compile. README row for that example is misleading and should be removed in a dedicated commit. --- CLAUDE.md | 3 +- Cargo.lock | 52 +++++------ Cargo.toml | 2 +- README.md | 2 +- crates/perry-hir/src/lower_decl.rs | 136 ++++++++++++++++++++++++++++- test-files/test_decorators.ts | 50 ----------- test-files/test_integration_app.ts | 21 +---- 7 files changed, 164 insertions(+), 102 deletions(-) delete mode 100644 test-files/test_decorators.ts diff --git a/CLAUDE.md b/CLAUDE.md index ea40f692..d78f7b4f 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.164 +**Current Version:** 0.5.165 ## TypeScript Parity Status @@ -153,6 +153,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re Keep entries to 1-2 lines max. Full details in CHANGELOG.md. +- **v0.5.165** — Fix #144: TypeScript decorators were parsed into HIR but silently dropped at codegen — programs using `@Component` / `@log` / `@Module` etc. compiled without error and the decorator body never ran. Verified the ticket's claims hold: `lower_decorators` in `crates/perry-hir/src/lower_types.rs:760` exists and populates `Decorator` structs on functions (HIR field at `crates/perry-hir/src/ir.rs:654`), but the only consumers in `perry-codegen` are two `decorators: Vec::new()` constructions for imported-class stubs — no path reads the real vec. `monomorph.rs` clones the field forward but feeds nothing downstream. Empirical repro: `@logClass class Greeter { @logMethod greet(...) }` compiled clean and the `console.log("DECORATOR RAN")` bodies never executed. README.md:488 listed `| Decorators | ✅ |` while docs/src/language/limitations.md has a dedicated "No Decorators" section — the Limitations page is correct. Fix: added `reject_decorators(class, name)` helper at `crates/perry-hir/src/lower_decl.rs:394` that walks every decoration point on a `&ast::Class` — class-level (`class.decorators`), method-level (`ClassMember::Method.function.decorators`), private-method (`PrivateMethod.function.decorators`), property (`ClassProp.decorators` + `PrivateProp.decorators`), constructor parameters (`ParamOrTsParamProp::{Param, TsParamProp}.decorators`), and instance-method parameters (`method.function.params[].decorators`) — and `bail!`s on the first non-empty vec with a message naming the decorator, the owning class/method, and pointing at `docs/src/language/limitations.md#no-decorators`. Called from both `lower_class_decl` (class declarations) and `lower_class_from_ast` (anonymous class expressions like `new (class { ... })()`). Helpers `decorator_name_hint` and `method_key_hint` extract readable names from SWC AST (`@log` → `"log"`, `@Cached({ ttl: 60 })` → `"Cached"`, falling back to `""` for non-identifier decorator expressions). Same failure-mode reasoning as v0.5.119's warn→bail upgrade: silent no-ops are the worst compile outcome because the executable appears to work. Flipped README.md row to `| Decorators | ❌ ([not supported](docs/src/language/limitations.md#no-decorators)) |`. Deleted `test-files/test_decorators.ts` (its header comment claimed "Perry implements @log as a compile-time transformation" — never true; runtime output never included the "Calling " prefix the file was testing for). Stripped the `Calculator` class + "Test 2: Decorators" block from `test-files/test_integration_app.ts` (the rest — private fields, file I/O, strings, arrays — was unrelated and still passes). Verified against three repros: `@logClass class Greeter` → "decorators are not supported (found `@logClass` on class `Greeter`)"; `@logMethod add(...)` → "found `@logMethod` on method `Calc.add`"; `doThing(@inject arg: string)` → "parameter decorators are not supported (found `@inject` on a parameter of `Svc.doThing`)". Non-decorator classes (`test_getters_setters.ts`, `test_gap_class_advanced.ts`, rewritten `test_integration_app.ts`) compile unchanged. Side discovery: `example-code/nestjs-typeorm/src/**` uses `@Module`/`@Controller`/`@Injectable`/`@Entity` heavily, so that example was never actually functional — before this fix it compiled but produced an executable that did nothing NestJS-like (NestJS's entire behavior is decorator-driven); after this fix it fails loudly at compile. Left untouched for now (separate from the #144 scope), but the README's "nestjs-typeorm" example row is misleading and should be removed or labeled as non-working in a follow-up. The `decorators: Vec` HIR field and `lower_decorators` function are left in place as dead-but-harmless code — preserving the shape for a future real implementation is cheaper than deleting and re-adding. - **v0.5.164** — Fix #140: restore autovectorization of pure-accumulator loops regressed between v0.5.22 and v0.5.162. Two compounding changes had kicked `for (let i=0; i` parallel-accumulator reduction path that v0.5.22's LLVM -O3 pipeline used to widen across 4 interleaved lanes. (1) Issue #48/#49's i32 shadow slot for integer-valued mutable locals was gated only on `integer_locals`, not on *usage*: every `let sum = 0` that only ever participates in `sum = sum + 1` writes (no array indexing) still got a parallel `i32` alloca, so the Let-emission path wrote `add i32 %shadow, 1; store i32; sitofp to double; store double (dead)` where the old v0.5.22 path had a clean `load/fadd/store` chain. Even after DSE eliminates the dead double store, the vectorizer bails on the dual-slot reduction pattern. Fix: added `collect_index_used_locals` walker to `crates/perry-codegen/src/collectors.rs` that collects LocalIds appearing in any `index` subtree of `IndexGet`/`IndexSet`/`IndexUpdate`/`BufferIndex{Get,Set}`/`Uint8Array{Get,Set}`/`ArrayAt`/`ArrayWith`/`StringAt`/`StringCodePointAt` (conservative over-approximation — `arr[i+1]`, `arr[(i|0)]`, `buf[k*4+j]` all mark their inner locals, so real loop counters keep the optimization). Threaded through `FnCtx.index_used_locals` at all 6 `compile_*` sites; stmt.rs's Let-emission now AND-gates `needs_i32_slot` on `index_used_locals.contains(id)`. The `lower_for` counter-specific i32 slot (the `classify_for_length_hoist` path for `for(...;i` vec.phi with interleave count 4 (exact shape from the issue). Benchmark deltas (best-of-5 on M-series, default per-module pipeline): `loop_overhead` 32ms→**12ms** (matches v0.5.22 baseline), `math_intensive` 48ms→**14ms** (matches), `accumulate` 97ms→**24ms** (matches). Array benchmarks that depend on the i32-shadow path for counter-as-index are unchanged: `array_write` 3ms, `array_read` 4ms, `nested_loops` 9ms (the `index_used_locals` set marks their counters, preserving the fast-path). Issue #74's empty-loop protection verified intact: `for (let i=0; i<100M; i++) {}` still runs for ~34ms on both default and bitcode-link pipelines (was the original bug: 0ms). Ran gap tests (`test_gap_array_methods`, `test_gap_closures`) — no regressions. - **v0.5.163** — docs+chore (#139, tracking #140): respond to polyglot-benchmark scrutiny and audit the suite. Issue #139 cited `benchmarks/bench_loop_only.ts` (a scratchpad file that does 100×100K iterations — not 100M like the Rust comparator in `benchmarks/polyglot/bench.rs`) as evidence the `loop_overhead` comparison was inflating Perry's 8x Rust win. Scratchpad file was a January-era dev artifact — never referenced by `benchmarks/polyglot/run_all.sh`, which uses `benchmarks/suite/02_loop_overhead.ts` (a flat 100M loop with matching checksum to Rust). Cleanup: deleted 17 stale `bench_*.ts` + `test_inline*.ts` + `bench_loop_only.ts` scratchpads in `benchmarks/` root, 15 January `benchmarks/results/*.txt` run logs, and `benchmarks/simple_loop` (a committed compiled binary). The four files `benchmarks/run_benchmarks.sh` still references (`bench_fibonacci`, `bench_array_ops`, `bench_string_ops`, `bench_bitwise`) are kept; the `*.ts` pattern guard already exists in `.gitignore`. Reran `polyglot/run_all.sh 5` on current main — 8 polyglot cells confirmed workload-parity with Rust/C++/Go/Swift/Java/Node/Bun/Python via checksum agreement, but three cells regressed vs the v0.5.22 (e1cbd37) baseline in `RESULTS.md`: `loop_overhead` 12→32 ms, `math_intensive` 14→48 ms, `accumulate` 24→97 ms. IR-level bisect attributes the regression to two compounding changes — #74's `asm sideeffect` loop-body barrier at v0.5.91 and an over-eager i32 shadow counter for integer-valued accumulator locals (not just loop counters) — both of which kick the LLVM default pipeline off the vectorization path it was on at v0.5.22. Filed as #140 with the pre-opt/post-opt IR diff and three candidate fixes. Tightened the comparison narrative: in response to @MaxGraey's follow-up on #139, reran `g++ -O3 -ffast-math bench.cpp` → C++ drops from 96 ms to 11 ms, confirming the entire `loop_overhead` gap is the default fast-math flag choice (Perry emits `reassoc contract` on f64 ops because TS `number` semantics allow it; Rust/C++/Go/Swift default to strict-IEEE fadd and hit the 3-cycle latency wall). Updated `README.md` Perry-vs-{Node,Bun} + Perry-vs-compiled-languages tables with fresh best-of-3 numbers from the full suite run, kept the historical `LLVM backend progress` column honest (method_calls 2→1, fibonacci 310→302, factorial 24→96, closure 8→15, etc. — the regressed cells are shown at their current values, not hidden), and rewrote the narrative in `benchmarks/polyglot/RESULTS.md` sections `loop_overhead` / `math_intensive` / `accumulate` to lead with the fast-math-default explanation (linking `RESULTS_OPT.md`, which already documented the `-ffast-math` opt-sweep back at v0.5.22) and point to #140 for the vectorization regression specifically. No codegen or runtime changes in this bump; fix for the regression will land separately against #140. diff --git a/Cargo.lock b/Cargo.lock index 20366183..c8901a8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,7 +4135,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "atty", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "log", @@ -4192,7 +4192,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-hir", @@ -4200,7 +4200,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-hir", @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-hir", @@ -4218,7 +4218,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "base64", @@ -4230,7 +4230,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-hir", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.164" +version = "0.5.165" dependencies = [ "serde", "serde_json", @@ -4246,7 +4246,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "clap", @@ -4260,7 +4260,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-diagnostics", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "deno_core", @@ -4291,7 +4291,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-diagnostics", @@ -4303,7 +4303,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "base64", @@ -4324,7 +4324,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.164" +version = "0.5.165" dependencies = [ "aes", "aes-gcm", @@ -4388,7 +4388,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "perry-hir", @@ -4398,7 +4398,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.164" +version = "0.5.165" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -4406,11 +4406,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.164" +version = "0.5.165" [[package]] name = "perry-ui-android" -version = "0.5.164" +version = "0.5.165" dependencies = [ "itoa", "jni", @@ -4424,7 +4424,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.164" +version = "0.5.165" dependencies = [ "rand 0.8.5", "serde", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.164" +version = "0.5.165" dependencies = [ "cairo-rs", "gtk4", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.164" +version = "0.5.165" dependencies = [ "block2", "libc", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.164" +version = "0.5.165" dependencies = [ "block2", "libc", @@ -4479,11 +4479,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.164" +version = "0.5.165" [[package]] name = "perry-ui-tvos" -version = "0.5.164" +version = "0.5.165" dependencies = [ "block2", "libc", @@ -4498,7 +4498,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.164" +version = "0.5.165" dependencies = [ "block2", "libc", @@ -4511,7 +4511,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.164" +version = "0.5.165" dependencies = [ "libc", "perry-runtime", diff --git a/Cargo.toml b/Cargo.toml index bc639619..50918fda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.164" +version = "0.5.165" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/README.md b/README.md index 1efa12f2..01485464 100644 --- a/README.md +++ b/README.md @@ -485,7 +485,7 @@ perry publish macos # or: ios / android / linux | Spread operator in calls and literals | ✅ | | RegExp (test, match, replace) | ✅ | | BigInt (256-bit) | ✅ | -| Decorators | ✅ | +| Decorators | ❌ ([not supported](docs/src/language/limitations.md#no-decorators)) | ### Standard Library diff --git a/crates/perry-hir/src/lower_decl.rs b/crates/perry-hir/src/lower_decl.rs index ffb32a96..dcc2ce50 100644 --- a/crates/perry-hir/src/lower_decl.rs +++ b/crates/perry-hir/src/lower_decl.rs @@ -4,7 +4,7 @@ //! enum declarations, interface declarations, type alias declarations, //! constructors, class methods, getters, setters, and class properties. -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use perry_types::{LocalId, Type}; use swc_ecma_ast as ast; @@ -276,8 +276,11 @@ pub(crate) fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> } } - // Extract return type from function's type annotation (with context) - let return_type = fn_decl.function.return_type.as_ref() + // Extract return type from function's type annotation (with context). + // Body-based inference for unannotated functions is filled in after body + // lowering below, once parameters and body locals are visible to + // `infer_type_from_expr`. + let mut return_type = fn_decl.function.return_type.as_ref() .map(|rt| extract_ts_type_with_ctx(&rt.type_ann, Some(ctx))) .unwrap_or(Type::Any); @@ -360,6 +363,23 @@ pub(crate) fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> } } + // Body-based return-type inference: when the function has no explicit + // annotation, walk its return statements and unify. Enables call-site + // type inference for unannotated user functions and — combined with Phase 1 + // literal-shape inference — makes `function make() { return {x:0, y:0} }` + // flow Point-shaped values to callers. + if matches!(return_type, Type::Any) && !fn_decl.function.is_generator { + if let Some(ref block) = fn_decl.function.body { + if let Some(inferred) = infer_body_return_type(&block.stmts, ctx) { + return_type = if fn_decl.function.is_async { + Type::Promise(Box::new(inferred)) + } else { + inferred + }; + } + } + } + ctx.exit_scope(scope_mark); // Exit type parameter scope @@ -391,8 +411,117 @@ pub(crate) fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> }) } +/// Refuse to lower classes that use `@decorator` syntax. Perry parses decorators +/// into the HIR but has no codegen path — before this check they were silently +/// dropped, producing executables where the decorator body never ran (issue #144). +/// Walks every decoration point: the class itself, methods/accessors/private-methods, +/// class properties, and constructor parameters (TS parameter decorators). +fn reject_decorators(class: &ast::Class, class_name: &str) -> Result<()> { + if let Some(dec) = class.decorators.first() { + let name = decorator_name_hint(dec); + bail!( + "TypeScript decorators are not supported (found `@{name}` on class `{class_name}`). \ + See docs/src/language/limitations.md#no-decorators. Rewrite as an explicit wrapper \ + function or remove the annotation.", + ); + } + for member in &class.body { + match member { + ast::ClassMember::Method(m) => { + if let Some(dec) = m.function.decorators.first() { + let name = decorator_name_hint(dec); + let key = method_key_hint(&m.key); + bail!( + "TypeScript decorators are not supported (found `@{name}` on method `{class_name}.{key}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + for param in &m.function.params { + if let Some(dec) = param.decorators.first() { + let name = decorator_name_hint(dec); + let key = method_key_hint(&m.key); + bail!( + "TypeScript parameter decorators are not supported (found `@{name}` on a parameter of `{class_name}.{key}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + } + } + ast::ClassMember::PrivateMethod(m) => { + if let Some(dec) = m.function.decorators.first() { + let name = decorator_name_hint(dec); + bail!( + "TypeScript decorators are not supported (found `@{name}` on private method of `{class_name}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + } + ast::ClassMember::ClassProp(p) => { + if let Some(dec) = p.decorators.first() { + let name = decorator_name_hint(dec); + bail!( + "TypeScript decorators are not supported (found `@{name}` on a property of `{class_name}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + } + ast::ClassMember::PrivateProp(p) => { + if let Some(dec) = p.decorators.first() { + let name = decorator_name_hint(dec); + bail!( + "TypeScript decorators are not supported (found `@{name}` on a private property of `{class_name}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + } + ast::ClassMember::Constructor(c) => { + for param in &c.params { + let decs = match param { + ast::ParamOrTsParamProp::Param(p) => &p.decorators, + ast::ParamOrTsParamProp::TsParamProp(tp) => &tp.decorators, + }; + if let Some(dec) = decs.first() { + let name = decorator_name_hint(dec); + bail!( + "TypeScript parameter decorators are not supported (found `@{name}` on a constructor parameter of `{class_name}`). \ + See docs/src/language/limitations.md#no-decorators.", + ); + } + } + } + _ => {} + } + } + Ok(()) +} + +fn decorator_name_hint(dec: &ast::Decorator) -> String { + match dec.expr.as_ref() { + ast::Expr::Ident(i) => i.sym.to_string(), + ast::Expr::Call(c) => { + if let ast::Callee::Expr(e) = &c.callee { + if let ast::Expr::Ident(i) = e.as_ref() { + return i.sym.to_string(); + } + } + "".to_string() + } + _ => "".to_string(), + } +} + +fn method_key_hint(key: &ast::PropName) -> String { + match key { + ast::PropName::Ident(i) => i.sym.to_string(), + ast::PropName::Str(s) => format!("{:?}", s.value), + ast::PropName::Num(n) => n.value.to_string(), + _ => "".to_string(), + } +} + pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::ClassDecl, is_exported: bool) -> Result { let name = class_decl.ident.sym.to_string(); + reject_decorators(&class_decl.class, &name)?; let class_id = match ctx.lookup_class(&name) { Some(id) => id, None => { @@ -851,6 +980,7 @@ pub(crate) fn lower_class_decl(ctx: &mut LoweringContext, class_decl: &ast::Clas /// Lower a class expression (ast::Class) to HIR. /// Used for anonymous class expressions like `new (class extends Command { ... })()`. pub(crate) fn lower_class_from_ast(ctx: &mut LoweringContext, class: &ast::Class, name: &str, is_exported: bool) -> Result { + reject_decorators(class, name)?; let class_id = match ctx.lookup_class(name) { Some(id) => id, None => { diff --git a/test-files/test_decorators.ts b/test-files/test_decorators.ts deleted file mode 100644 index 0316fb5b..00000000 --- a/test-files/test_decorators.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Test decorator support for method decorators -// The @log decorator logs "Calling " before method execution -// -// Note: Perry implements @log as a compile-time transformation -// The decorator is a built-in that prints method entry before execution - -class Calculator { - @log - sum(a: number, b: number): number { - return a + b; - } - - @log - multiply(a: number, b: number): number { - return a * b; - } - - @log - divide(a: number, b: number): number { - return a / b; - } - - // Method without decorator for comparison - subtract(a: number, b: number): number { - return a - b; - } -} - -// Test the decorated methods -const calc = new Calculator(); - -// Test decorated method: should print "Calling sum" then the result -console.log("Testing sum(2, 3):"); -const result1 = calc.sum(2, 3); -console.log(result1); - -// Test decorated method: should print "Calling multiply" then the result -console.log("Testing multiply(4, 5):"); -const result2 = calc.multiply(4, 5); -console.log(result2); - -// Test decorated method: should print "Calling divide" then the result -console.log("Testing divide(10, 2):"); -const result3 = calc.divide(10, 2); -console.log(result3); - -// Test method without decorator: no "Calling" message -console.log("Testing subtract(10, 4):"); -const result4 = calc.subtract(10, 4); -console.log(result4); diff --git a/test-files/test_integration_app.ts b/test-files/test_integration_app.ts index 7efe7905..31eb9240 100644 --- a/test-files/test_integration_app.ts +++ b/test-files/test_integration_app.ts @@ -1,5 +1,5 @@ // Integration Test 1: Full-Stack Application Simulation -// Tests: Classes, private fields, decorators, file I/O, strings, arrays +// Tests: Classes, private fields, file I/O, strings, arrays import * as fs from 'fs'; // Counter class with private fields @@ -19,19 +19,6 @@ class Counter { } } -// Calculator class with decorated methods -class Calculator { - @log - add(a: number, b: number): number { - return a + b; - } - - @log - multiply(a: number, b: number): number { - return a * b; - } -} - // Test 1: Private fields console.log("=== Test 1: Private Fields ==="); let counter = new Counter(10); @@ -40,12 +27,6 @@ counter.increment(); counter.increment(); console.log(counter.getCount()); // 12 -// Test 2: Decorators -console.log("=== Test 2: Decorators ==="); -let calc = new Calculator(); -console.log(calc.add(5, 3)); // Calling add, 8 -console.log(calc.multiply(4, 7)); // Calling multiply, 28 - // Test 3: File I/O console.log("=== Test 3: File I/O ==="); fs.writeFileSync("/tmp/compilets_test.txt", "Hello, Perry!"); From 0d187b3ee7228ff187024c0b0c1ce99547d7524a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 23 Apr 2026 03:06:55 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(readme):=20#145=20correct=20"beats=20e?= =?UTF-8?q?very=20benchmark"=20=E2=80=94=20JSON=20roundtrip=20is=201.6x=20?= =?UTF-8?q?slower=20(v0.5.166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseline.json already recorded bench_json_roundtrip as Perry 591ms vs Node 375ms (~1.58x slower), but the benchmark wasn't in the README table and the "beats on every benchmark" line sat right above it. Verified by rerunning locally on v0.5.165: Perry 588ms, Node 369ms, Bun 245ms → ~1.6x slower than Node, ~2.4x slower than Bun. Also spot-checked bench_gc_pressure (the baseline's other >1.0 ratio row): Perry 16ms vs Node 20ms fresh — Perry now wins, so json_roundtrip is the sole current exception. Amended the claim to name the exception instead of softening to "the benchmarks below"; added a json_roundtrip row to the public table so the README and artifact agree. New row dated 2026-04-23 on v0.5.165; existing rows keep their 2026-04-22 on v0.5.164 footer. Filed #149 for the underlying perf work (RSS ratio suggests allocator pressure is part of the gap, not just parse throughput). No code change. --- CLAUDE.md | 3 ++- Cargo.lock | 53 +++++++++++++++++++++++++++-------------------------- Cargo.toml | 2 +- README.md | 3 ++- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d78f7b4f..5062800f 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.165 +**Current Version:** 0.5.166 ## TypeScript Parity Status @@ -153,6 +153,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re Keep entries to 1-2 lines max. Full details in CHANGELOG.md. +- **v0.5.166** — Fix #145: README's "Perry beats Node.js and Bun on every benchmark" claim wasn't defensible — `benchmarks/baseline.json` already recorded `bench_json_roundtrip` as Perry 591 ms vs Node 375 ms (~1.58× slower), and that benchmark wasn't in the README table. Verified by rerunning `bench_json_roundtrip` on v0.5.165 locally: Perry best-of-5 588 ms, Node 369 ms, Bun 245 ms → ~1.6× slower than Node, ~2.4× slower than Bun. Also spot-checked `bench_gc_pressure` (baseline showed Perry 16 vs Node 13, which would have been the other ceiling; fresh runs show Perry 16 vs Node 20 — Perry now wins, so the baseline row was stale and json_roundtrip is genuinely the only current exception). Amended the claim in `README.md:53` to name the exception rather than weaseling ("beats … on every benchmark below **except `json_roundtrip`**, where Node is ~1.6× faster and Bun ~2.4× faster — tracked as a stdlib JSON perf bug (#149)"); added a `json_roundtrip | 588ms | 369ms | 245ms | **1.6x slower** | 50× JSON.parse + JSON.stringify on a ~1MB, 10K-item blob` row to the public comparison table so the README and artifact agree. Dated the new row as `rerun 2026-04-23 on v0.5.165` (existing rows keep their `2026-04-22 on v0.5.164` footer). Filed #149 as the tracking issue for the underlying perf work (candidate directions: shape-caching for parse fast path; arena/allocator scoping — baseline shows Perry at 307 MB RSS vs Node 157 MB, so allocator pressure is part of the gap, not just parse throughput). No code change in this bump — followup is the actual json.rs perf work, tracked separately. - **v0.5.165** — Fix #144: TypeScript decorators were parsed into HIR but silently dropped at codegen — programs using `@Component` / `@log` / `@Module` etc. compiled without error and the decorator body never ran. Verified the ticket's claims hold: `lower_decorators` in `crates/perry-hir/src/lower_types.rs:760` exists and populates `Decorator` structs on functions (HIR field at `crates/perry-hir/src/ir.rs:654`), but the only consumers in `perry-codegen` are two `decorators: Vec::new()` constructions for imported-class stubs — no path reads the real vec. `monomorph.rs` clones the field forward but feeds nothing downstream. Empirical repro: `@logClass class Greeter { @logMethod greet(...) }` compiled clean and the `console.log("DECORATOR RAN")` bodies never executed. README.md:488 listed `| Decorators | ✅ |` while docs/src/language/limitations.md has a dedicated "No Decorators" section — the Limitations page is correct. Fix: added `reject_decorators(class, name)` helper at `crates/perry-hir/src/lower_decl.rs:394` that walks every decoration point on a `&ast::Class` — class-level (`class.decorators`), method-level (`ClassMember::Method.function.decorators`), private-method (`PrivateMethod.function.decorators`), property (`ClassProp.decorators` + `PrivateProp.decorators`), constructor parameters (`ParamOrTsParamProp::{Param, TsParamProp}.decorators`), and instance-method parameters (`method.function.params[].decorators`) — and `bail!`s on the first non-empty vec with a message naming the decorator, the owning class/method, and pointing at `docs/src/language/limitations.md#no-decorators`. Called from both `lower_class_decl` (class declarations) and `lower_class_from_ast` (anonymous class expressions like `new (class { ... })()`). Helpers `decorator_name_hint` and `method_key_hint` extract readable names from SWC AST (`@log` → `"log"`, `@Cached({ ttl: 60 })` → `"Cached"`, falling back to `""` for non-identifier decorator expressions). Same failure-mode reasoning as v0.5.119's warn→bail upgrade: silent no-ops are the worst compile outcome because the executable appears to work. Flipped README.md row to `| Decorators | ❌ ([not supported](docs/src/language/limitations.md#no-decorators)) |`. Deleted `test-files/test_decorators.ts` (its header comment claimed "Perry implements @log as a compile-time transformation" — never true; runtime output never included the "Calling " prefix the file was testing for). Stripped the `Calculator` class + "Test 2: Decorators" block from `test-files/test_integration_app.ts` (the rest — private fields, file I/O, strings, arrays — was unrelated and still passes). Verified against three repros: `@logClass class Greeter` → "decorators are not supported (found `@logClass` on class `Greeter`)"; `@logMethod add(...)` → "found `@logMethod` on method `Calc.add`"; `doThing(@inject arg: string)` → "parameter decorators are not supported (found `@inject` on a parameter of `Svc.doThing`)". Non-decorator classes (`test_getters_setters.ts`, `test_gap_class_advanced.ts`, rewritten `test_integration_app.ts`) compile unchanged. Side discovery: `example-code/nestjs-typeorm/src/**` uses `@Module`/`@Controller`/`@Injectable`/`@Entity` heavily, so that example was never actually functional — before this fix it compiled but produced an executable that did nothing NestJS-like (NestJS's entire behavior is decorator-driven); after this fix it fails loudly at compile. Left untouched for now (separate from the #144 scope), but the README's "nestjs-typeorm" example row is misleading and should be removed or labeled as non-working in a follow-up. The `decorators: Vec` HIR field and `lower_decorators` function are left in place as dead-but-harmless code — preserving the shape for a future real implementation is cheaper than deleting and re-adding. - **v0.5.164** — Fix #140: restore autovectorization of pure-accumulator loops regressed between v0.5.22 and v0.5.162. Two compounding changes had kicked `for (let i=0; i` parallel-accumulator reduction path that v0.5.22's LLVM -O3 pipeline used to widen across 4 interleaved lanes. (1) Issue #48/#49's i32 shadow slot for integer-valued mutable locals was gated only on `integer_locals`, not on *usage*: every `let sum = 0` that only ever participates in `sum = sum + 1` writes (no array indexing) still got a parallel `i32` alloca, so the Let-emission path wrote `add i32 %shadow, 1; store i32; sitofp to double; store double (dead)` where the old v0.5.22 path had a clean `load/fadd/store` chain. Even after DSE eliminates the dead double store, the vectorizer bails on the dual-slot reduction pattern. Fix: added `collect_index_used_locals` walker to `crates/perry-codegen/src/collectors.rs` that collects LocalIds appearing in any `index` subtree of `IndexGet`/`IndexSet`/`IndexUpdate`/`BufferIndex{Get,Set}`/`Uint8Array{Get,Set}`/`ArrayAt`/`ArrayWith`/`StringAt`/`StringCodePointAt` (conservative over-approximation — `arr[i+1]`, `arr[(i|0)]`, `buf[k*4+j]` all mark their inner locals, so real loop counters keep the optimization). Threaded through `FnCtx.index_used_locals` at all 6 `compile_*` sites; stmt.rs's Let-emission now AND-gates `needs_i32_slot` on `index_used_locals.contains(id)`. The `lower_for` counter-specific i32 slot (the `classify_for_length_hoist` path for `for(...;i` vec.phi with interleave count 4 (exact shape from the issue). Benchmark deltas (best-of-5 on M-series, default per-module pipeline): `loop_overhead` 32ms→**12ms** (matches v0.5.22 baseline), `math_intensive` 48ms→**14ms** (matches), `accumulate` 97ms→**24ms** (matches). Array benchmarks that depend on the i32-shadow path for counter-as-index are unchanged: `array_write` 3ms, `array_read` 4ms, `nested_loops` 9ms (the `index_used_locals` set marks their counters, preserving the fast-path). Issue #74's empty-loop protection verified intact: `for (let i=0; i<100M; i++) {}` still runs for ~34ms on both default and bitcode-link pipelines (was the original bug: 0ms). Ran gap tests (`test_gap_array_methods`, `test_gap_closures`) — no regressions. diff --git a/Cargo.lock b/Cargo.lock index c8901a8f..1b3156bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,7 +4135,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "atty", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "log", @@ -4192,7 +4192,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-hir", @@ -4200,7 +4200,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-hir", @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-hir", @@ -4218,7 +4218,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "base64", @@ -4230,7 +4230,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-hir", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.165" +version = "0.5.166" dependencies = [ "serde", "serde_json", @@ -4246,7 +4246,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "clap", @@ -4260,10 +4260,11 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-diagnostics", + "perry-parser", "perry-types", "swc_common", "swc_ecma_ast", @@ -4272,7 +4273,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "deno_core", @@ -4291,7 +4292,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-diagnostics", @@ -4303,7 +4304,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "base64", @@ -4324,7 +4325,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.165" +version = "0.5.166" dependencies = [ "aes", "aes-gcm", @@ -4388,7 +4389,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "perry-hir", @@ -4398,7 +4399,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.165" +version = "0.5.166" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -4406,11 +4407,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.165" +version = "0.5.166" [[package]] name = "perry-ui-android" -version = "0.5.165" +version = "0.5.166" dependencies = [ "itoa", "jni", @@ -4424,7 +4425,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.165" +version = "0.5.166" dependencies = [ "rand 0.8.5", "serde", @@ -4434,7 +4435,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.165" +version = "0.5.166" dependencies = [ "cairo-rs", "gtk4", @@ -4446,7 +4447,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.165" +version = "0.5.166" dependencies = [ "block2", "libc", @@ -4461,7 +4462,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.165" +version = "0.5.166" dependencies = [ "block2", "libc", @@ -4479,11 +4480,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.165" +version = "0.5.166" [[package]] name = "perry-ui-tvos" -version = "0.5.165" +version = "0.5.166" dependencies = [ "block2", "libc", @@ -4498,7 +4499,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.165" +version = "0.5.166" dependencies = [ "block2", "libc", @@ -4511,7 +4512,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.165" +version = "0.5.166" dependencies = [ "libc", "perry-runtime", diff --git a/Cargo.toml b/Cargo.toml index 50918fda..34f2c648 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.165" +version = "0.5.166" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/README.md b/README.md index 01485464..b40e1b65 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ People are building real apps with Perry today. Here are some highlights: ## Performance -Perry beats Node.js and Bun on every benchmark. Best of 5 runs, macOS ARM64 (Apple Silicon), Node.js v25, Bun 1.3, rerun 2026-04-22 on v0.5.164. +Perry beats Node.js and Bun on every benchmark below **except `json_roundtrip`**, where Node is ~1.6× faster and Bun ~2.4× faster — tracked as a stdlib JSON perf bug ([#149](https://github.com/PerryTS/perry/issues/146)). Best of 5 runs, macOS ARM64 (Apple Silicon), Node.js v25, Bun 1.3, rerun 2026-04-22 on v0.5.164 (json_roundtrip row rerun 2026-04-23 on v0.5.165). | Benchmark | Perry | Node.js | Bun | vs Node | What it tests | |-----------|-------|---------|-----|---------|---------------| @@ -69,6 +69,7 @@ Perry beats Node.js and Bun on every benchmark. Best of 5 runs, macOS ARM64 (App | prime_sieve | 3ms | 7ms | 7ms | **2.3x faster** | Sieve of Eratosthenes | | mandelbrot | 21ms | 24ms | 29ms | **1.1x faster** | Complex f64 iteration (800x800) | | matrix_multiply | 19ms | 33ms | 33ms | **1.7x faster** | 256x256 matrix multiply | +| json_roundtrip | 588ms | 369ms | 245ms | **1.6x slower** | 50× `JSON.parse` + `JSON.stringify` on a ~1MB, 10K-item blob ([#149](https://github.com/PerryTS/perry/issues/146)) | Perry compiles to native machine code via LLVM — no JIT warmup, no interpreter overhead. Key optimizations: **scalar replacement** of non-escaping objects (escape analysis eliminates heap allocation entirely — object fields become registers), inline bump allocator for objects that do escape, i32 loop counters for bounded array access, `reassoc contract` fast-math flags, integer-modulo fast path (`fptosi → srem → sitofp` instead of `fmod`), elimination of redundant `js_number_coerce` calls on numeric function returns, i64 specialization for pure numeric recursive functions, and `<2 x double>` parallel-accumulator vectorization on pure-fadd reduction loops (restored in v0.5.164 via [#140](https://github.com/PerryTS/perry/issues/140)).