From 1d4774e55e9661ca60eb5bb90c20cf6c4218cdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 22:28:22 +0200 Subject: [PATCH 1/4] perf(hir): type a for-head let/const binding from its initializer (#7544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for (let i = 0; …)` hardcoded `Type::Any` for the head binding while the statement-level `let i = 0;` runs `infer_decl_type` and gets `Number`. The gap is GC-visible: a closed-shape object literal lowers to `new __AnonShape_(…)` whose `ClassField::ty` comes from `infer_type_from_expr` over each property's value, so inside a `for` loop `{ v: i, w: i + 1 }` minted two `Any` fields — and `Any` is pointer-bearing, so the most common allocation shape in the language was declared to the collector as two POINTER slots. Both for-head declarator sites now delegate to the same `infer_decl_type` the statement-level declarator uses. `var` heads keep `Type::Any` (they are function-scoped and var-hoisted, so the declarator is not the only writer). Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- crates/perry-hir/src/destructuring/mod.rs | 2 +- .../perry-hir/src/destructuring/var_decl.rs | 2 +- crates/perry-hir/src/lower/stmt.rs | 52 +++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/perry-hir/src/destructuring/mod.rs b/crates/perry-hir/src/destructuring/mod.rs index 6ea392825e..75838bab52 100644 --- a/crates/perry-hir/src/destructuring/mod.rs +++ b/crates/perry-hir/src/destructuring/mod.rs @@ -27,7 +27,7 @@ mod assignment_expr; mod assignment_stmt; mod helpers; mod pattern_binding; -mod var_decl; +pub(crate) mod var_decl; mod var_decl_sources; pub(crate) use assignment_expr::lower_destructuring_assignment; diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index 43943d1f33..f29931ebcf 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -8,7 +8,7 @@ mod alias_tracking; mod binding_guards; mod native_fetch; mod native_new; -mod type_infer; +pub(crate) mod type_infer; use alias_tracking::track_decl_aliases; use binding_guards::apply_binding_guards; diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index de2f8fe3bf..4d5eb994e9 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -11,6 +11,48 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +/// #7544: the type of a `for (let|const = ; …)` head binding. +/// +/// Delegates to the SAME `infer_decl_type` a statement-level `let x = ` +/// uses, instead of the hardcoded `Type::Any` this site carried. The two forms +/// declare the same thing and differ only in where the declarator sits, so +/// there was never a reason for `for (let i = 0; …)` to type `i` as `Any` +/// while `let i = 0;` types it `Number`. +/// +/// **Why this is a GC-visible fix, not a cosmetic one.** A closed-shape object +/// literal lowers to `new __AnonShape_(…)` whose synthesized +/// `ClassField::ty` comes from `infer_type_from_expr` over each property's +/// value. Inside a `for` loop, that value is almost always the counter or +/// arithmetic over it, so `Any` at the head propagated to `Any` fields — and +/// `Any` is pointer-bearing, so `{ v: i, w: i + 1 }` was declared to the +/// collector as **two POINTER slots** (`class_layout_declarable_at_allocation` +/// refuses, `js_gc_init_typed_shape_layout` installs a pointer mask). Typing +/// the head makes the same literal mint `Number` fields, which is what +/// #7532's allocation-site declaration needs to fire. See #7544. +/// +/// Restricted to `let`/`const`: a `var` head binding is function-scoped and +/// var-hoisted, so its declarator is not the only writer of the name and the +/// statement-level parity argument does not carry over. `var` heads keep +/// `Type::Any`. +/// +/// **This is not a runtime type assertion.** Perry validates no declared type +/// (CLAUDE.md, "No runtime type *validation*"), so a `Number`-typed field can +/// still receive a pointer through a later dynamic write. That is discharged +/// where every other typed-shape contradiction is: the raw-f64 store guard +/// rejects the non-double bits, falls back to the boxed setter, and +/// `layout_note_slot` downgrades the descriptor to `GC_LAYOUT_UNKNOWN` so the +/// collector scans the slot conservatively from then on. +fn for_init_binding_type( + ctx: &mut LoweringContext, + decl: &ast::VarDeclarator, + name: &str, +) -> Type { + let ast::Pat::Ident(ident) = &decl.name else { + return Type::Any; + }; + crate::destructuring::var_decl::type_infer::infer_decl_type(ctx, decl, ident, name) +} + fn emit_class_expression_value_binding( ctx: &mut LoweringContext, module: &mut Module, @@ -1513,13 +1555,14 @@ pub(crate) fn lower_stmt( continue; } let name = get_binding_name(&decl.name)?; + let ty = for_init_binding_type(ctx, decl, &name); let init_expr = decl.init.as_ref().map(|e| lower_expr(ctx, e)).transpose()?; - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local(name.clone(), ty.clone()); module.init.push(Stmt::Let { id, name, - ty: Type::Any, + ty, mutable: true, init: init_expr, }); @@ -1551,16 +1594,17 @@ pub(crate) fn lower_stmt( None } else { let name = get_binding_name(&decl.name)?; + let ty = for_init_binding_type(ctx, decl, &name); let init_expr = decl .init .as_ref() .map(|e| lower_expr(ctx, e)) .transpose()?; - let id = ctx.define_local(name.clone(), Type::Any); + let id = ctx.define_local(name.clone(), ty.clone()); Some(Box::new(Stmt::Let { id, name, - ty: Type::Any, + ty, mutable: true, init: init_expr, })) From 38523c2f0e6fef3a3a05acf340707bdb4fa1a582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 22:43:49 +0200 Subject: [PATCH 2/4] test(hir,gc): pin the anon-shape field-type boundary and its self-healing (#7544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/perry-hir/tests/anon_shape_field_types.rs` pins both directions of what a closed-shape literal mints. The four positive cases fail when the change is stubbed out (verified by hand: replace `for_init_binding_type`'s body with `Type::Any` and 4 of 7 go red); the three boundary cases — a bare parameter, an explicit `any`, a `var` head, and an inner shadow — pass either way, which is the point: a value we cannot type must still mint `Any`. `test-files/test_gap_7544_anon_shape_numeric_fields.ts` is the runtime witness, byte-diffed against the node oracle. It builds literals through the new path, forces collections, and asserts every element survives; then it writes freshly allocated heap strings over already-constructed *numeric* fields, collects again, and reads them back. The subject is verified live in the emitted IR: three `js_gc_declare_typed_shape_layout` calls and a raw-f64 mask on the pure-numeric shapes, with a pointer mask only on the mixed one. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- crates/perry-hir/src/lower/stmt.rs | 6 +- .../perry-hir/tests/anon_shape_field_types.rs | 224 ++++++++++++++++++ ...test_gap_7544_anon_shape_numeric_fields.ts | 113 +++++++++ 3 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 crates/perry-hir/tests/anon_shape_field_types.rs create mode 100644 test-files/test_gap_7544_anon_shape_numeric_fields.ts diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 4d5eb994e9..27f706d2d6 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -42,11 +42,7 @@ use crate::ir::*; /// rejects the non-double bits, falls back to the boxed setter, and /// `layout_note_slot` downgrades the descriptor to `GC_LAYOUT_UNKNOWN` so the /// collector scans the slot conservatively from then on. -fn for_init_binding_type( - ctx: &mut LoweringContext, - decl: &ast::VarDeclarator, - name: &str, -) -> Type { +fn for_init_binding_type(ctx: &mut LoweringContext, decl: &ast::VarDeclarator, name: &str) -> Type { let ast::Pat::Ident(ident) = &decl.name else { return Type::Any; }; diff --git a/crates/perry-hir/tests/anon_shape_field_types.rs b/crates/perry-hir/tests/anon_shape_field_types.rs new file mode 100644 index 0000000000..e25a3c30bf --- /dev/null +++ b/crates/perry-hir/tests/anon_shape_field_types.rs @@ -0,0 +1,224 @@ +//! #7544 — the field types a closed-shape object literal mints. +//! +//! HIR rewrites a closed-shape literal into `new __AnonShape_(…)`, and +//! the synthesized `ClassField::ty` of that class is a **collector-facing +//! layout input**: codegen's `typed_shape::class_layout_declarable_at_allocation` +//! refuses any class with a pointer-bearing field, and `Any` is pointer-bearing. +//! So a literal that mints `Any` fields is declared to the GC as N POINTER +//! slots and takes the mask-maintaining store path; one that mints `Number` +//! fields gets a raw-f64 mask, a `POINTER_FREE` layout, and the +//! allocation-site declaration #7532 added. +//! +//! The defect: `for (let i = 0; …)` hardcoded `Type::Any` for its head +//! binding while the statement-level `let i = 0;` inferred `Number`, so every +//! literal built from a loop counter — the single most common allocation shape +//! in the language — minted `Any` fields. `{ v: i, w: i + 1 }` was two pointer +//! slots holding two doubles. +//! +//! These tests pin **both directions**. The negative cases matter as much as +//! the positive one: Perry validates no declared type at runtime, so a field +//! typed `Number` that receives a pointer relies on the raw-f64 store guard +//! rejecting it and `layout_note_slot` downgrading the descriptor. That +//! self-healing is real (see `gc/tests/layout_trace/declared_at_allocation.rs` +//! and `test-files/test_gap_7544_anon_shape_numeric_fields.ts`), but it is a +//! recovery path, not a licence — a value we cannot type must still mint +//! `Any` so the collector's view is right the first time. + +use perry_diagnostics::SourceCache; +use perry_hir::types::Type; +use perry_hir::{lower_module, Module}; +use perry_parser::parse_typescript_with_cache; + +/// Lowering is deeply recursive; the default 2 MB test thread SIGABRTs. +fn lower_src(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "test.ts").expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +/// The minted field types of the one anon-shape class carrying `field_name`, +/// as `(name, ty)` pairs in declaration order. +fn anon_shape_fields(module: &Module, field_name: &str) -> Vec<(String, Type)> { + let matches: Vec<&perry_hir::Class> = module + .classes + .iter() + .filter(|c| { + c.name.starts_with("__AnonShape_") && c.fields.iter().any(|f| f.name == field_name) + }) + .collect(); + assert_eq!( + matches.len(), + 1, + "expected exactly one anon-shape class carrying field `{field_name}`, found {}", + matches.len() + ); + matches[0] + .fields + .iter() + .map(|f| (f.name.clone(), f.ty.clone())) + .collect() +} + +fn tys(module: &Module, field_name: &str) -> Vec { + anon_shape_fields(module, field_name) + .into_iter() + .map(|(_, ty)| ty) + .collect() +} + +/// The headline case: the `churn` shape. Both fields must be `Number`, or the +/// literal is two pointer slots holding two doubles. +#[test] +fn for_loop_counter_literal_mints_number_fields() { + let m = lower_src( + r#" + const keep: any[] = []; + for (let i = 0; i < 10; i++) { + const o = { v: i, w: i + 1 }; + keep.push(o); + } + console.log(keep.length); + "#, + ); + assert_eq!( + tys(&m, "v"), + vec![Type::Number, Type::Number], + "`{{ v: i, w: i + 1 }}` in a `for (let i = 0; …)` loop must mint two \ + Number fields — Any is pointer-bearing and declares two POINTER slots \ + to the collector (#7544)" + ); +} + +/// Arithmetic over the counter, not just the counter itself. +#[test] +fn for_loop_counter_arithmetic_mints_number_fields() { + let m = lower_src( + r#" + let acc = 0; + for (let i = 0; i < 10; i++) { + const o = { a: i * 2, b: i - 1, c: i }; + acc += o.a + o.b + o.c; + } + console.log(acc); + "#, + ); + assert_eq!(tys(&m, "a"), vec![Type::Number, Type::Number, Type::Number]); +} + +/// A for-head binding whose initializer is a string types the field `String`, +/// not `Number` — the head's type is inferred from its initializer, and the +/// inference is not hardwired to numbers. +#[test] +fn for_loop_string_head_mints_string_field() { + let m = lower_src( + r#" + for (let s = "x"; s.length < 4; s = s + "x") { + const o = { t: s }; + console.log(o.t); + } + "#, + ); + assert_eq!(tys(&m, "t"), vec![Type::String]); +} + +/// The boundary that keeps this sound. A value the lowering cannot type stays +/// `Any`, so the class stays pointer-bearing and the collector scans the slot. +#[test] +fn unprovable_values_still_mint_any_fields() { + // A bare (unannotated) parameter: its value comes from the caller. + let bare_param = lower_src( + r#" + function g(p) { return { p1: p }; } + console.log(g(1).p1); + "#, + ); + assert_eq!(tys(&bare_param, "p1"), vec![Type::Any]); + + // An explicitly `any` value. + let any_value = lower_src( + r#" + declare const z: any; + const o = { p2: z }; + console.log(o.p2); + "#, + ); + assert_eq!(tys(&any_value, "p2"), vec![Type::Any]); + + // A `for` head over a value of unknown type stays Any too: the head's + // binding is only as good as its initializer. + let opaque_head = lower_src( + r#" + declare const start: any; + for (let k = start; k < 10; k++) { + const o = { p3: k }; + console.log(o.p3); + } + "#, + ); + assert_eq!(tys(&opaque_head, "p3"), vec![Type::Any]); +} + +/// A `var` head keeps `Type::Any`. It is function-scoped and var-hoisted, so +/// its declarator is not the only writer of the name and the statement-level +/// parity argument does not carry over. +#[test] +fn var_for_head_still_mints_any() { + let m = lower_src( + r#" + for (var i = 0; i < 10; i++) { + const o = { q: i }; + console.log(o.q); + } + "#, + ); + assert_eq!(tys(&m, "q"), vec![Type::Any]); +} + +/// A mixed literal keeps each field's own type — the change propagates per +/// property, and a pointer-bearing property still makes the class +/// pointer-bearing (which is what `class_layout_declarable_at_allocation` +/// reads). +#[test] +fn mixed_literal_keeps_per_field_types() { + let m = lower_src( + r#" + declare const z: any; + for (let i = 0; i < 10; i++) { + const o = { n: i, s: "k", u: z }; + console.log(o.n, o.s, o.u); + } + "#, + ); + assert_eq!( + tys(&m, "n"), + vec![Type::Number, Type::String, Type::Any], + "each property carries its own inferred type; one Any field is enough \ + to keep the class pointer-bearing" + ); +} + +/// An inner binding that shadows the counter is a different local, so the +/// literal must not inherit the counter's type through the name. +#[test] +fn inner_shadow_does_not_inherit_the_counter_type() { + let m = lower_src( + r#" + declare const z: any; + for (let i = 0; i < 10; i++) { + const i2 = z; + const o = { r: i2 }; + console.log(o.r); + } + "#, + ); + assert_eq!(tys(&m, "r"), vec![Type::Any]); +} diff --git a/test-files/test_gap_7544_anon_shape_numeric_fields.ts b/test-files/test_gap_7544_anon_shape_numeric_fields.ts new file mode 100644 index 0000000000..f1896de8db --- /dev/null +++ b/test-files/test_gap_7544_anon_shape_numeric_fields.ts @@ -0,0 +1,113 @@ +// #7544 — closed-shape numeric object literals built in a `for` loop now mint +// `Number`-typed anon-shape fields, so they are declared to the collector as a +// raw-f64, POINTER-FREE layout instead of N pointer slots. +// +// Two things are witnessed here, and they pull in opposite directions: +// +// 1. SURVIVAL. A pointer-free declaration tells the collector these slots can +// never hold a reference. If that were declared for an object that DOES +// hold one, the child would be stranded — a use-after-free, not a +// slowdown. So the retained objects here carry a heap string child in a +// third field, and every one of them is read back after a forced +// collection. +// +// 2. SELF-HEALING. Perry validates no declared type at runtime (CLAUDE.md, +// "No runtime type *validation*"), so a field the compiler typed `number` +// can still receive a pointer through a later dynamic write. The raw-f64 +// store guard must reject those bits, fall back to the boxed setter, and +// downgrade the descriptor through `layout_note_slot` so the collector +// starts scanning the slot again. The second half of this test writes heap +// strings into the numeric fields of already-constructed objects, collects +// hard, and reads them back. +// +// `gc()` is Perry's global; node only exposes it under `--expose-gc`, so the +// call is guarded and the test is a byte-for-byte parity test either way. +declare const gc: undefined | (() => void); + +function collect(): void { + if (typeof gc === "function") gc(); +} + +// ── 1. survival ────────────────────────────────────────────────────────────── +// `{ v: i, w: i + 1 }` is the shape that used to mint two Any fields. `tag` is +// a freshly allocated heap string, so the object is genuinely mixed and a +// wrongly-declared pointer-free layout would lose it. +const kept: { v: number; w: number; tag: string }[] = []; +let checksum = 0; +for (let i = 0; i < 40000; i++) { + const o = { v: i, w: i + 1, tag: "t" + (i % 7) }; + if (i % 4000 === 0) kept.push(o); + checksum += o.v + o.w; + if (i % 10000 === 0) collect(); +} +collect(); +collect(); + +console.log("checksum", checksum); +console.log("kept", kept.length); +let survived = 0; +for (let k = 0; k < kept.length; k++) { + const o = kept[k]; + if (o.v === k * 4000 && o.w === k * 4000 + 1 && o.tag === "t" + ((k * 4000) % 7)) { + survived++; + } +} +console.log("survived", survived); + +// A purely numeric literal — no pointer field at all, so this is the shape +// that actually gets the raw-f64 pointer-free descriptor. +const pure: { a: number; b: number }[] = []; +for (let i = 0; i < 20000; i++) { + const o = { a: i, b: i * 2 }; + if (i % 2000 === 0) pure.push(o); +} +collect(); +let pureOk = 0; +for (let k = 0; k < pure.length; k++) { + if (pure[k].a === k * 2000 && pure[k].b === k * 2000 * 2) pureOk++; +} +console.log("pureOk", pureOk, "of", pure.length); + +// ── 2. self-healing ────────────────────────────────────────────────────────── +// Every one of these objects was constructed with a numeric-typed `n`. Writing +// a freshly allocated heap string over it contradicts the declared layout; the +// store guard must reject the raw-f64 fast path and the descriptor must be +// downgraded so the string is traced as a child from here on. +const healed: { n: number | string }[] = []; +for (let i = 0; i < 20000; i++) { + const o: { n: number | string } = { n: i }; + if (i % 2000 === 0) healed.push(o); +} +for (let k = 0; k < healed.length; k++) { + // The concatenation allocates, so this is a real heap string, not an interned + // literal that would survive by living outside the nursery. + healed[k].n = "healed-" + k + "-" + k * 3; +} +collect(); +collect(); + +let healedOk = 0; +for (let k = 0; k < healed.length; k++) { + if (healed[k].n === "healed-" + k + "-" + k * 3 && typeof healed[k].n === "string") { + healedOk++; + } +} +console.log("healedOk", healedOk, "of", healed.length); + +// Mixed contradiction: half the objects keep their numbers, half take strings. +// Both kinds must read back correctly out of the same shape. +const mixed: { m: number | string }[] = []; +for (let i = 0; i < 20000; i++) { + const o: { m: number | string } = { m: i }; + if (i % 2000 === 0) mixed.push(o); +} +for (let k = 0; k < mixed.length; k++) { + if (k % 2 === 1) mixed[k].m = "s" + k + "-" + k; +} +collect(); +let mixedOk = 0; +for (let k = 0; k < mixed.length; k++) { + const want = k % 2 === 1 ? "s" + k + "-" + k : k * 2000; + if (mixed[k].m === want) mixedOk++; +} +console.log("mixedOk", mixedOk, "of", mixed.length); From 7346528d1241e48e75e2fd52ef7813af3fabe81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 22:46:08 +0200 Subject: [PATCH 3/4] docs(changelog): fragment for #7550 (#7544) Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH --- .../7550-anon-shape-numeric-field-types.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 changelog.d/7550-anon-shape-numeric-field-types.md diff --git a/changelog.d/7550-anon-shape-numeric-field-types.md b/changelog.d/7550-anon-shape-numeric-field-types.md new file mode 100644 index 0000000000..45ead376ee --- /dev/null +++ b/changelog.d/7550-anon-shape-numeric-field-types.md @@ -0,0 +1,76 @@ +**A closed-shape object literal built in a `for` loop is no longer declared to +the collector as N pointer slots (#7544).** + +#7532 left a note saying `mint_anon_shape_class` "gives every synthesized field +type `Any`". It does not — it writes each property's inferred type through +verbatim, and `{ v: 1, w: 2 }` already minted two `Number` fields, already got a +raw-f64 mask with a null pointer mask, and already took #7532's allocation-site +declaration. + +What was `Any` is the **loop** form, and the cause is one hardcoded type. +`for (let i = 0; …)` wrote `Type::Any` for its head binding while the +statement-level `let i = 0;` runs `infer_decl_type` and gets `Number`. So +`infer_type_from_expr` saw `i: Any`, `i + 1` inherited it, and +`{ v: i, w: i + 1 }` minted two `Any` fields. `Any` is pointer-bearing, so the +most common allocation shape in the language told the GC it held two references +when it provably held two doubles — and `class_layout_declarable_at_allocation` +correctly refused, so it also missed #7532's declaration entirely. + +Both `for`-head declarator sites now call the same `infer_decl_type` the +statement-level declarator uses. `var` heads keep `Type::Any`: a `var` head +binding is function-scoped and var-hoisted, so its declarator is not the only +writer of the name and the statement-level parity argument does not carry over. + +**What is propagated is the initializer-inferred type of the head binding** — +byte-for-byte the computation `let i = 0;` has always performed. No new *kind* +of fact enters the layout; the annotation channel (`for (let i: number = 0; …)`) +is the one `let y: number = 0;` already carried into anon-shape fields. A bare +parameter, an explicit `any`, a `var` head, and an inner shadow all still mint +`Any`. + +This is **not** a runtime type assertion. Perry validates no declared type, so a +`Number`-typed field can still receive a pointer through a later dynamic write. +That is discharged where #7532 discharged it: the raw-f64 store guard rejects +the non-double bits, falls back to the boxed setter, and `layout_note_slot` +downgrades the descriptor to `GC_LAYOUT_UNKNOWN`. + +IR census on `{ v: i, w: i + 1 }` in a loop — identical source, only the +compiler differs: + +| call | before | after | +|---|--:|--:| +| `js_gc_init_typed_shape_layout` (post-constructor) | 1 | 0 | +| `js_gc_declare_typed_shape_layout` (at allocation) | 0 | 1 | +| `js_gc_note_slot_layout` | 3 | 1 | +| `js_write_barrier_slot` | 3 | 1 | +| `js_string_addref_if_heap_string` | 3 | 1 | +| `js_dynamic_string_or_number_add` | 1 | 0 | + +and the mask flips from `pointer_mask = [i64 3]` to `raw_f64_mask = [i64 3]` +with a null pointer mask — a pointer-free layout. + +**Two corrections to the expected evidence, recorded because both would have +been wrong claims.** The anon-shape constructor's stores never routed through +`js_put_value_set` — that was #7532's *declared-class* path; the synthesized +ctor's stores are direct-GEP, and what they shed here is the three-call +bookkeeping preamble. And the collector's **byte** counters do not move: +`copied_bytes 361760`, `promoted_bytes 91216`, 8 cycles, byte-identical across +both arms. On reflection that is expected — `mark_field_into_worklist` +re-validates every slot word and rejects a double, so declaring the slots +pointers cost a visit and a reject, never retention. The win is scan work and +store bookkeeping, not retained bytes. + +New tests. `crates/perry-hir/tests/anon_shape_field_types.rs` pins both +directions of the boundary and is sabotage-tested: stubbing +`for_init_binding_type` back to `Type::Any` turns 4 of its 7 tests red and +leaves the 3 boundary cases green. `test-files/test_gap_7544_anon_shape_numeric_fields.ts` +is the runtime witness, byte-diffed against the node oracle — mixed literals +with a heap-string child retained across forced collections, pure numeric +literals, and objects whose *numeric* fields are overwritten with freshly +allocated heap strings and read back after two more collections. Its subject is +verified live in the emitted IR (three `js_gc_declare_typed_shape_layout` calls, +raw-f64 masks on the pure shapes, a pointer mask only on the mixed one), and it +runs clean under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 +PERRY_GC_PROTECT_FROMSPACE_DEPTH=800` with `PERRY_GC_MOVING_LOOP_POLLS=1` at +compile and run time — 100 131 copying minors, 100 131 quarantined from-space +page-sets, exit 0 — and under `PERRY_GC_VERIFY_EVACUATION=1`. From 4e382a3630ec0cea49107cca4d429f7d62ba8598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 00:14:46 +0200 Subject: [PATCH 4/4] chore: bump version to 0.5.1312 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 769a806eec..f847486c2f 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.1311 +**Current Version:** 0.5.1312 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d2195b1f14..2fbf36c3cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1311" +version = "0.5.1312" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1311" +version = "0.5.1312" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1311" +version = "0.5.1312" [[package]] name = "perry-ui-tvos" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1311" +version = "0.5.1312" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 74cd009fc9..f2e0189cc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1311" +version = "0.5.1312" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"