diff --git a/CLAUDE.md b/CLAUDE.md index 6752f7d41f..a13dc69923 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.1455 +**Current Version:** 0.5.1456 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a4c1ec47c7..752746441c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1455" +version = "0.5.1456" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1455" +version = "0.5.1456" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1455" +version = "0.5.1456" [[package]] name = "perry-ui-tvos" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1455" +version = "0.5.1456" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d8fd276816..a4b12f6567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1455" +version = "0.5.1456" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7778-element-shape-param-binding.md b/changelog.d/7778-element-shape-param-binding.md new file mode 100644 index 0000000000..3481c0260b --- /dev/null +++ b/changelog.d/7778-element-shape-param-binding.md @@ -0,0 +1,35 @@ +**repsel: the element-shape loop clone serves the element-binding form through function boundaries (#7766).** + +A fully-typed field read through a **parameter** array was still a by-name +lookup in two spellings the versioned clone declined: the explicit binding +(`const r = ps[i]; s += r.x + r.y`) and `for…of`, whose desugar emits exactly +that shape. The direct `ps[i].x` spelling was already served (the preheader +guard establishes the element shape at run time — the declared type only names +the class id to check against — so the mechanism was always sound for +parameters; it just could not see the binding form). + +The binding-form matcher itself landed separately as #7780 (issue #7771); this +PR contributes the pieces that carry it across function boundaries: + +1. **for-of desugar** (both emission sites): the minted counter init is + `Integer(0)` instead of `Number(0.0)` — the literal kind + `collect_integer_let_ids` seeds on, and the shape a user-written + `let i = 0` produces. With `Number(0.0)` the desugared counter never + joined `integer_locals`, never got a canonical i32 slot, and every + i32-counter loop optimization silently declined the `for…of` spelling of + loops it served in indexed form. +2. **`--opt-report`**: the clone records a `Ptr` selection and + per-read consumption when — and only when — the deref block branches into + the fast clone, so a served parameter-array loop no longer reads as an + unserved rule-1 wall (the exact mis-reading #7766 was filed on). +3. **Tests**: the parameter-array positive + (`element_binding_form_through_a_parameter_gets_the_clone`, asserted + call-free and entered, not merely emitted) and the non-counter-index + negative (`an_element_binding_indexed_by_a_non_counter_declines`) — the + two cases #7780's suite did not cover. + +Probes (200k elements × 200 passes): binding form through a parameter +0.22s → 0.07s, `for…of` 0.78s → 0.07s — both at node parity. Soundness is +tested, not argued: `test_gap_repsel_element_shape_param_binding.ts` runs +mixed arrays, a subclass, same-shape literals, a hole, mid-loop mutation and +a fake array-like through the boundary, byte-identical to node 26.5.1. diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 86011cec34..b05c912ee1 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -403,6 +403,36 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( "element_shape=homogeneous_class".to_string(), ], ); + // `--opt-report` consumption (#7766): the selection recorded at + // clone emission was APPLIED here. Without this row a build under + // the report would print "selected, consumed 0" — the wasted-proof + // outcome — for a proof that is in fact doing the work. + if crate::opt_report::enabled() { + let (name, local_id) = match fact.element_binding { + Some(id) => ( + ctx.local_id_to_name + .get(&id) + .cloned() + .unwrap_or_else(|| format!("")), + Some(id), + ), + None => ( + ctx.local_id_to_name + .get(&arr_id) + .map(|n| format!("elements of `{n}`")) + .unwrap_or_else(|| format!("elements of ")), + None, + ), + }; + crate::opt_report::consume( + crate::opt_report::Position::Local, + &name, + local_id, + crate::opt_report::Analysis::PtrShape, + "Ptr", + "element_shape_loop.raw_f64_load", + ); + } return Ok(Some(value)); } } diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index a5e8f9e299..ca7e64833f 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -37,10 +37,12 @@ //! `acc = ` statement over tracked `arr[i].field` reads, //! numeric locals, literals and pure arithmetic / `Math` — or, since //! #7771, that statement preceded by exactly one `const r = arr[i]` -//! binding whose only uses are tracked `r.field` reads (the binding is -//! virtual in the fast clone: its `Let` emits nothing and the reads lower -//! through the fact). No store of any kind, no call, no closure, no -//! `await`, no update other than the counter's. +//! binding whose only uses are tracked `r.field` reads (#7766: the shape +//! the `for…of` desugar emits, and the form a parameter array reaches the +//! clone through — the binding is virtual in the fast clone: its `Let` +//! emits nothing and the reads lower through the fact). No store of any +//! kind, no call, no closure, no `await`, no update other than the +//! counter's. //! 2. **By construction (the lowering).** After the fast clone is emitted, //! every one of its blocks is scanned for a GC-unsafe call //! (`LlBlock::contains_gc_unsafe_call`). If ANY call survived — because @@ -901,6 +903,45 @@ pub(super) fn lower_element_shape_versioned_for( ctx.block().br(&slow_pre_label); } + // `--opt-report` (#7766): the clone is a runtime-guarded `Ptr` + // selection for the reads it serves — the report must say so, or the + // parameter-array case reads as an unserved rule-1 wall (the exact + // mis-reading #7766 was filed on). Recorded ONLY when the deref block + // branches INTO the fast clone: an emitted-but-deleted clone selects + // nothing ("a gate must assert its subject was live"). + if fast_clone_call_free && crate::opt_report::enabled() { + let (name, local_id) = match matched.element_binding { + Some(id) => ( + ctx.local_id_to_name + .get(&id) + .cloned() + .unwrap_or_else(|| format!("")), + Some(id), + ), + None => ( + ctx.local_id_to_name + .get(&matched.array_id) + .map(|n| format!("elements of `{n}`")) + .unwrap_or_else(|| format!("elements of ", matched.array_id)), + None, + ), + }; + crate::opt_report::select( + crate::opt_report::Position::Local, + &name, + local_id, + crate::opt_report::Analysis::PtrShape, + "Ptr", + 1, + Some(format!( + "element-shape loop clone (runtime-guarded): class {}, {} tracked field(s); \ + element reads in this loop lower to offset loads behind the preheader guard", + matched.class_name, + matched.fields.len() + )), + ); + } + ctx.current_block = slow_pre_idx; lower_for_after_init(ctx, init, condition, update, body, "for.element_shape_slow")?; if !ctx.block().is_terminated() { diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index b210c6f651..d39ecbf469 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -1377,3 +1377,158 @@ fn element_binding_declines_a_second_array() { "a body reading two different arrays must decline the clone" ); } + +/// The full positive contract in one helper: the clone was admitted, the +/// deref block cond_brs INTO it, and its fast region carries neither a call +/// nor any element-read/field-diamond runtime symbol. Stronger than the +/// [`CLONE_LABELS`] presence checks alone — an emitted-but-deleted clone +/// passes those (#7690's failure shape). +fn assert_clone_fires_call_free(ir: &str, what: &str) { + for label in CLONE_LABELS { + assert!( + ir.contains(label), + "{what}: expected the element-shape clone, but `{label}` is absent \ + — the matcher declined" + ); + } + assert_fast_clone_is_entered(ir); + let fast = fast_clone_slice(ir); + assert!( + !fast.contains(" call "), + "{what}: the fast clone must be call-free; found a call in:\n{fast}" + ); + assert!( + !fast.contains("js_array_get_f64") && !fast.contains("js_array_get_index_or_string"), + "{what}: the element-read tier must be gone from the fast clone" + ); + assert!( + !fast.contains("js_object_get_field_by_name_f64") + && !fast.contains("js_typed_feedback_class_field_get_guard") + && !fast.contains("js_number_coerce"), + "{what}: the by-name field diamond must be gone from the fast clone" + ); +} + +#[test] +fn an_element_binding_indexed_by_a_non_counter_declines() { + // `const r = keep[0]` is not the induction read; the preheader's range + // argument covers only `arr[counter]`. + let ir = emit(&element_shape_module( + vec![ + Stmt::Let { + id: BINDING_ID, + name: "r".to_string(), + ty: Type::Named("Node".to_string()), + mutable: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARRAY_ID)), + index: Box::new(Expr::Integer(0)), + }), + }, + binding_accumulate(binding_field("v")), + ], + None, + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a non-counter-indexed element binding must decline the clone" + ); +} + +#[test] +fn element_binding_form_through_a_parameter_gets_the_clone() { + // #7766's case (A), binding spelling: `function total(ps: Node[])`. The + // array's provenance is the CALLER's — no static rule can prove it — and + // the clone must be admitted anyway, because its preheader establishes + // the element shape at run time and the declared type only names the + // class id to check against. + const PS_ID: u32 = 21; + const F_SUM_ID: u32 = 22; + const F_COUNTER_ID: u32 = 23; + const F_BINDING_ID: u32 = 24; + let mut m = Module::new("element_shape_loop.ts"); + m.classes = vec![node_class(None)]; + m.functions = vec![perry_hir::Function { + id: 900, + name: "total".to_string(), + type_params: Vec::new(), + params: vec![perry_hir::Param { + id: PS_ID, + name: "ps".to_string(), + ty: Type::Array(Box::new(Type::Named("Node".to_string()))), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Number, + body: vec![ + Stmt::Let { + id: F_SUM_ID, + name: "sum".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: F_COUNTER_ID, + name: "j".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(F_COUNTER_ID)), + right: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(PS_ID)), + property: "length".to_string(), + byte_offset: 0, + }), + }), + update: Some(Expr::Update { + id: F_COUNTER_ID, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![ + Stmt::Let { + id: F_BINDING_ID, + name: "r".to_string(), + ty: Type::Named("Node".to_string()), + mutable: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(PS_ID)), + index: Box::new(Expr::LocalGet(F_COUNTER_ID)), + }), + }, + Stmt::Expr(Expr::LocalSet( + F_SUM_ID, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(F_SUM_ID)), + right: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(F_BINDING_ID)), + property: "v".to_string(), + byte_offset: 0, + }), + }), + )), + ], + }, + Stmt::Return(Some(Expr::LocalGet(F_SUM_ID))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + m.init_kind = ModuleInitKind::Eager; + let ir = emit(&m); + assert_clone_fires_call_free(&ir, "parameter binding form"); +} diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index e36b0c5ee7..70e9181efc 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -1859,13 +1859,22 @@ pub(super) fn lower_stmt_for_of_inner( }; // Create the for loop: // for (let __i = 0; __i < __arr.length; __i++) { ... } + // + // #7766: the init is `Integer(0)`, not `Number(0.0)` — the same shape a + // user-written `let i = 0` lowers to. The counter is integral by + // construction (zero init, `++` only), and the literal kind is what the + // integer-local collector seeds on (`collect_integer_let_ids`): with + // `Number(0.0)` the desugared counter never joined `integer_locals`, + // never got a canonical i32 slot, and every i32-counter loop + // optimization — the element-shape versioned clone included — silently + // declined the `for…of` spelling of a loop it served in indexed form. module.init.push(Stmt::For { init: Some(Box::new(Stmt::Let { id: idx_id, name: format!("__idx_{}", idx_id), ty: Type::Number, mutable: true, - init: Some(Expr::Number(0.0)), + init: Some(Expr::Integer(0)), })), condition: Some(condition), update: Some(Expr::Update { diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index f861b5ea42..cee53b72ba 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1840,13 +1840,20 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result str: registered=lambda t: _read_hash_list(t, "test-parity/gc_repsel_corpus.txt"), entry_to_path=lambda e: "test-files/%s.ts" % e, min_candidates=45, - exclusions={ - "test_gap_repsel_element_group_numeric": ( - "parity fixture for #7770's numeric-field proof, not a moving-GC " - "witness: its read loop is raw f64 loads with near-zero " - "allocation, so the matrix's scavenge arms are INERT on it " - "(liveness gate: counter=0, 0/1 cells live) and a registered " - "green cell would be green for the wrong reason. GC-under-" - "relocation coverage for this feature lives in the reproducer " - "run recorded on PR #7774 (1,762 evacuating minors, 400,014 " - "objects moved, exit 0) and in the ordinary parity harness." - ), - }, ), Mechanism( id="feature-matrix-probes", diff --git a/test-files/test_gap_repsel_element_shape_param_binding.ts b/test-files/test_gap_repsel_element_shape_param_binding.ts new file mode 100644 index 0000000000..53295a7797 --- /dev/null +++ b/test-files/test_gap_repsel_element_shape_param_binding.ts @@ -0,0 +1,169 @@ +// repsel #7766: the element-shape versioned loop clone through a FUNCTION +// BOUNDARY, in the element-binding spelling. +// +// A typed `P[]` parameter has no static provenance — the caller can pass +// anything — so the clone's preheader establishes the element shape at run +// time (`js_array_ensure_element_shape`) and the declared type only names the +// class id to check against. These cases are the ways a caller can make the +// declared type a LIE; every one must take the slow path and print exactly +// what node prints. The binding spelling (`const r = ps[i]` / `for…of`) is +// the shape the matcher learned in #7766 — the direct `ps[i].x` spelling has +// its own file (test_gap_repsel_element_shape_loop_clone.ts). + +class P { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +class PSub extends P { + z: number; + constructor(x: number, y: number, z: number) { + super(x, y); + this.z = z; + } +} + +// The binding spelling, through a parameter. +function totalBinding(ps: P[]): number { + let s = 0; + for (let i = 0; i < ps.length; i++) { + const r = ps[i]; + s += r.x + r.y; + } + return s; +} + +// The for…of spelling — desugars to the binding spelling. +function totalOf(ps: P[]): number { + let s = 0; + for (const p of ps) { + s += p.x + p.y; + } + return s; +} + +function make(n: number): P[] { + const a: P[] = []; + for (let i = 0; i < n; i++) a.push(new P(i, i + 1)); + return a; +} + +// --------------------------------------------------------------------------- +// 1. Honest caller — the hot path both spellings exist for. +// --------------------------------------------------------------------------- +const honest = make(64); +console.log("binding:", totalBinding(honest)); +console.log("for-of:", totalOf(honest)); +// Second visit runs the O(1) confirm path. +console.log("binding-again:", totalBinding(honest)); + +// --------------------------------------------------------------------------- +// 2. Mixed array — an element of a different class. The guard must decline +// and the reads go by-name, exactly as node reads them. +// --------------------------------------------------------------------------- +const mixed: P[] = make(8); +mixed.push({ x: 100, y: 200 } as unknown as P); +mixed.push(new P(8, 9)); +console.log("mixed binding:", totalBinding(mixed)); +console.log("mixed for-of:", totalOf(mixed)); + +// --------------------------------------------------------------------------- +// 3. Subclass elements — extra fields, different class id. Field VALUES are +// inherited-compatible, so the sums agree with node either way; the guard +// must still not treat PSub as P. +// --------------------------------------------------------------------------- +const subs: P[] = []; +for (let i = 0; i < 8; i++) subs.push(new PSub(i, i * 2, i * 3)); +console.log("subclass binding:", totalBinding(subs)); +console.log("subclass for-of:", totalOf(subs)); + +// One P mixed among PSubs: no homogeneous class either way. +subs.push(new P(50, 60)); +console.log("subclass+base binding:", totalBinding(subs)); + +// --------------------------------------------------------------------------- +// 4. Plain object literals of the same SHAPE — same keys, not the class. +// --------------------------------------------------------------------------- +const literals = [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + { x: 5, y: 6 }, +] as unknown as P[]; +console.log("literal binding:", totalBinding(literals)); +console.log("literal for-of:", totalOf(literals)); + +// --------------------------------------------------------------------------- +// 5. A hole — `undefined` element. Node throws reading `.x` of undefined; +// Perry must throw the same way, never mask the hole into a pointer. +// --------------------------------------------------------------------------- +const holey: P[] = make(4); +delete (holey as unknown as Record)[2]; +try { + console.log("holey binding:", totalBinding(holey)); +} catch (e) { + console.log("holey binding threw:", e instanceof TypeError); +} +try { + console.log("holey for-of:", totalOf(holey)); +} catch (e) { + console.log("holey for-of threw:", e instanceof TypeError); +} + +// --------------------------------------------------------------------------- +// 6. Mutation between calls — the array a previous call verified is grown +// with a foreign element afterwards; the next call must re-verify. +// --------------------------------------------------------------------------- +const mutated = make(16); +console.log("pre-mutate:", totalBinding(mutated)); +mutated.push({ x: 1000, y: 2000 } as unknown as P); +console.log("post-mutate:", totalBinding(mutated)); + +// --------------------------------------------------------------------------- +// 7. Mutation mid-loop — a body that writes the array does not match the +// clone at all; it must still be exactly node-correct. +// --------------------------------------------------------------------------- +function sumAndTruncate(ps: P[]): number { + let s = 0; + for (let i = 0; i < ps.length; i++) { + const r = ps[i]; + s += r.x; + if (i === 2) ps.length = 4; + } + return s; +} +console.log("mid-loop truncate:", sumAndTruncate(make(16))); + +// --------------------------------------------------------------------------- +// 8. Non-array caller — the brand test's job. +// --------------------------------------------------------------------------- +const fake = { + length: 3, + 0: new P(1, 1), + 1: new P(2, 2), + 2: new P(3, 3), +} as unknown as P[]; +console.log("fake-array binding:", totalBinding(fake)); + +// --------------------------------------------------------------------------- +// 9. The binding observed as a value elsewhere (declines the clone; must +// still be correct): r escapes into a comparison. +// --------------------------------------------------------------------------- +function maxX(ps: P[]): number { + let best = -1; + for (let i = 0; i < ps.length; i++) { + const r = ps[i]; + if (r.x > best) best = r.x; + } + return best; +} +console.log("escaping binding:", maxX(honest)); + +// --------------------------------------------------------------------------- +// 10. Empty array through the boundary — zero iterations, both spellings. +// --------------------------------------------------------------------------- +console.log("empty binding:", totalBinding([])); +console.log("empty for-of:", totalOf([])); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index acaac390aa..8406a96226 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -738,3 +738,16 @@ test_gap_gc_alloc_point_no_move test_gap_gc_class_field_receiver_rooting test_gap_gc_index_set_bounded_globalthis_ta_rooting test_gap_gc_namespace_and_computed_dispatch_rooting + +# #7766 (PR #7778): the element-binding clone through a function boundary. +# Lying callers force the slow path while the honest arm's clone reads raw +# f64 slots off relocatable objects — live on the evacuating arms (9 copying +# minors / 15,843 objects moved under zeal at audit time). +test_gap_repsel_element_shape_param_binding + +# #7770 (PR #7774, registered by #7778): the element-group numeric-field +# proof. Low-allocation read loop — its cell can run zero copying minors on +# a filtered arm (the arm-level liveness gate is the vacuity guard); its +# value here is byte-parity under every arm plus raw-f64 loads on proven +# fields whenever relocation does land. +test_gap_repsel_element_group_numeric