diff --git a/CLAUDE.md b/CLAUDE.md index fdef13906b..9edb9eb983 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.1453 +**Current Version:** 0.5.1454 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c3b19cf5de..b7c461e04b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1453" +version = "0.5.1454" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1453" +version = "0.5.1454" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1453" +version = "0.5.1454" [[package]] name = "perry-ui-tvos" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1453" +version = "0.5.1454" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index ad207d533d..35de506fe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1453" +version = "0.5.1454" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7780-element-binding-clone.md b/changelog.d/7780-element-binding-clone.md new file mode 100644 index 0000000000..db9c5228fe --- /dev/null +++ b/changelog.d/7780-element-binding-clone.md @@ -0,0 +1,19 @@ +The element-shape versioned loop clone (#7480/#7669/#7701) now admits the body +form real read loops are written in: `for (let i = 0; i < a.length; i++) +{ const r = a[i]; s += r.x + r.y; }` (#7771). The binding is virtual inside +the fast clone — its `Let` emits nothing, every `r.field` read lowers through +the loop fact to a bare element load under the preheader guard, and the +binding's lexical-death shadow-slot clear is suppressed there (in the +call-fallback shadow mode that clear is a runtime call, which would fail the +clone's call-free admission scan and silently delete it, #7690). The read +loop's fast clone now contains no calls at all: the bounded-index guard +diamond, its `js_array_get_f64` slow arm, both `js_number_coerce` diamonds and +the back-edge poll are gone from the hot path (pinned mini, interleaved +best-of-5 over 50M fetches: 0.07–0.08s → 0.05s user). `const`-only by design +(`var` is observable after the loop); the slow clone still binds generically +and every guard-declined shape (holes, `undefined` elements, Array subclasses, +proxies, shrunk length, mid-loop mutation) is byte-verified against node in +`test-files/test_gap_7771_*.ts`. Found and filed while validating: #7775 +(module-wide read-loop miscompile triggered by an uncalled `new Proxy(arr,{})`) +and #7776 (`NaN` where node string-concatenates after an `as any` +heterogeneous element store) — both pre-existing on pristine main. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index af8b5f38f5..11db9021c0 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1613,6 +1613,14 @@ pub(crate) struct ElementShapeLoopFact { /// Largest packed slot index the loop touches — the per-element /// `field_count` check covers every tracked access with one compare. pub max_field_index: u32, + /// #7771: the body's `const r = arr[counter]` binding, when the matcher + /// admitted the element-binding form. Inside the fast clone the `Let` + /// itself emits nothing (`stmt/let_stmt.rs`) and every `r.field` read + /// lowers through [`element_shape_loop_fact_for_property_get`]'s + /// `LocalGet` arm; the slow clone, lowered after this fact is popped, + /// binds `r` generically. `None` for the single-statement accumulator + /// form. + pub element_binding: Option, } /// Find the innermost active element-shape loop fact covering a @@ -1652,23 +1660,39 @@ pub(crate) fn element_shape_loop_fact_for_property_get<'f>( if ctx.element_shape_loop_facts.is_empty() { return None; } - let Expr::IndexGet { object, index } = object else { - return None; - }; - let (Expr::LocalGet(array_local_id), Expr::LocalGet(index_local_id)) = - (object.as_ref(), index.as_ref()) - else { - return None; - }; - if !ctx.i32_counter_slots.contains_key(index_local_id) { - return None; - } - ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { - if fact.array_local_id != *array_local_id || fact.index_local_id != *index_local_id { - return None; + match object { + Expr::IndexGet { object, index } => { + let (Expr::LocalGet(array_local_id), Expr::LocalGet(index_local_id)) = + (object.as_ref(), index.as_ref()) + else { + return None; + }; + if !ctx.i32_counter_slots.contains_key(index_local_id) { + return None; + } + ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { + if fact.array_local_id != *array_local_id || fact.index_local_id != *index_local_id + { + return None; + } + fact.fields.get(property).map(|idx| (fact, *idx)) + }) } - fact.fields.get(property).map(|idx| (fact, *idx)) - }) + // #7771: `r.field` through the clone's element binding. The matcher + // pinned `r = arr[counter]` as the body's first statement, so this is + // the same tracked element access spelled through the binding. The + // counter-slot obligation is checked against the fact's own counter, + // because the binding form carries no index expression at the read. + Expr::LocalGet(recv_id) => ctx.element_shape_loop_facts.iter().rev().find_map(|fact| { + if fact.element_binding != Some(*recv_id) + || !ctx.i32_counter_slots.contains_key(&fact.index_local_id) + { + return None; + } + fact.fields.get(property).map(|idx| (fact, *idx)) + }), + _ => None, + } } /// Find the innermost active class-field loop fact covering diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 158a458b30..86011cec34 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -351,58 +351,59 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( crate::expr::element_shape_loop_fact_for_property_get(ctx, object, property) .map(|(fact, idx)| (fact.clone(), idx)) { - if let Expr::IndexGet { object: array, .. } = object.as_ref() { - if let Expr::LocalGet(arr_id) = array.as_ref() { - // The counter's canonical i32 slot is what the matcher - // required; without it there is nothing to index with. - if let Some(slot) = ctx.i32_counter_slots.get(&fact.index_local_id).cloned() { - let idx_i32 = ctx.block().load(I32, &slot); - let value = crate::expr::element_shape_guard::emit_element_shape_field_load( - ctx, - &fact, - &idx_i32, - field_index, - ); - let lowered = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ElementShapeFieldGet", - Some(*arr_id), - "element_shape_loop.raw_f64_load", - &lowered, - Some(BoundsState::Guarded { - guard_id: "element_shape_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - Some(*arr_id), - "consumed", - "element_shape_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("field={property}"), - format!("class={}", fact.class_name), - "loop_versioning=element_shape".to_string(), - "index_range=nonnegative_i32".to_string(), - "length_range=guarded_i32".to_string(), - "element_shape=homogeneous_class".to_string(), - ], - ); - return Ok(Some(value)); - } - } + // Both receiver spellings — `arr[j].field` and #7771's `r.field` + // through the clone's element binding — resolve to the fact's own + // array; the report below must not re-derive it from the expression + // shape, which the binding form does not carry. + let arr_id = fact.array_local_id; + // The counter's canonical i32 slot is what the matcher required; + // without it there is nothing to index with. + if let Some(slot) = ctx.i32_counter_slots.get(&fact.index_local_id).cloned() { + let idx_i32 = ctx.block().load(I32, &slot); + let value = crate::expr::element_shape_guard::emit_element_shape_field_load( + ctx, + &fact, + &idx_i32, + field_index, + ); + let lowered = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ElementShapeFieldGet", + Some(arr_id), + "element_shape_loop.raw_f64_load", + &lowered, + Some(BoundsState::Guarded { + guard_id: "element_shape_loop_preheader_check".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + Some(arr_id), + "consumed", + "element_shape_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("field={property}"), + format!("class={}", fact.class_name), + "loop_versioning=element_shape".to_string(), + "index_range=nonnegative_i32".to_string(), + "length_range=guarded_i32".to_string(), + "element_shape=homogeneous_class".to_string(), + ], + ); + return Ok(Some(value)); } } diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index a339cf6365..0aa859bb4f 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -133,6 +133,24 @@ pub(crate) fn emit_shadow_slot_clear(ctx: &mut FnCtx<'_>, slot_idx: u32) { if ctx.persistent_shadow_slots.contains(&slot_idx) { return; } + // #7771: inside an element-shape fast clone the tracked `const r = arr[j]` + // binding is VIRTUAL — its `Let` emits nothing (`stmt/let_stmt.rs`) and + // its slot is never (re)bound in the clone, so this lexical-death clear + // would be the clone's ONLY runtime call. Depending on the shadow-frame + // mode that call is real (`js_shadow_slot_set`), and a call inside a + // call-free-by-construction clone does not slow it, it DELETES it + // (#7690). Skipping is sound in every mode: the slot still holds whatever + // it held before the loop, a stale-but-rooted value is over-rooting that + // a moving collection rewrites like any root, and every later user of a + // shared slot index binds before use. The slow clone, lowered after the + // fact is popped, keeps its clear. + if ctx.element_shape_loop_facts.iter().any(|fact| { + fact.element_binding + .and_then(|id| ctx.shadow_slot_map.get(&id)) + == Some(&slot_idx) + }) { + return; + } // Never-bound slot: it provably still holds its initial 0 (slots are only // written through bind/set, and every value-set site binds first), so the // clear would be a redundant `js_shadow_slot_set(idx, 0)` TLS hit. diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 7d0b4c7735..a5e8f9e299 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -35,9 +35,12 @@ //! //! 1. **By shape (the matcher).** The body must be a single //! `acc = ` statement over tracked `arr[i].field` reads, -//! numeric locals, literals and pure arithmetic / `Math`. No store of any -//! kind, no call, no closure, no `await`, no update other than the -//! counter's. +//! 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. //! 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 @@ -133,6 +136,9 @@ struct ElementShapeVersionedLoop { keys_global_name: String, /// property name -> packed slot index. fields: std::collections::BTreeMap, + /// #7771: the body's `const r = arr[counter]` binding in the two-statement + /// form; `None` for the original single-statement accumulator body. + element_binding: Option, } /// Effect-free expression walk for the element-shape loop. @@ -147,6 +153,7 @@ fn element_shape_loop_pure_expr_collect( ctx: &FnCtx<'_>, expr: &perry_hir::Expr, counter_id: u32, + element_binding: Option, array: &mut Option, props: &mut std::collections::BTreeSet, ) -> bool { @@ -154,34 +161,48 @@ fn element_shape_loop_pure_expr_collect( match expr { Expr::PropertyGet { object, property, .. - } => { - let Expr::IndexGet { object, index } = object.as_ref() else { - return false; - }; - let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = - (object.as_ref(), index.as_ref()) - else { - return false; - }; - // The index must be the loop counter itself. An offset index - // (`arr[j + 1]`) would need the preheader's `length >= bound` - // check widened; deliberately out of the first slice. - if *idx_id != counter_id || *arr_id == counter_id { - return false; + } => match object.as_ref() { + Expr::IndexGet { object, index } => { + let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = + (object.as_ref(), index.as_ref()) + else { + return false; + }; + // The index must be the loop counter itself. An offset index + // (`arr[j + 1]`) would need the preheader's `length >= bound` + // check widened; deliberately out of the first slice. + if *idx_id != counter_id || *arr_id == counter_id { + return false; + } + match array { + Some(a) if *a == *arr_id => {} + Some(_) => return false, // one array per loop + None => *array = Some(*arr_id), + } + props.insert(property.clone()); + true } - match array { - Some(a) if *a == *arr_id => {} - Some(_) => return false, // one array per loop - None => *array = Some(*arr_id), + // #7771: `r.field` through the body's `const r = arr[counter]` + // binding is the same tracked read spelled through the Let the + // body match admitted; the binding already pins (array, counter), + // so only the property is left to record. + Expr::LocalGet(recv_id) if element_binding == Some(*recv_id) => { + props.insert(property.clone()); + true } - props.insert(property.clone()); - true - } - // A bare read of the array or the counter as a VALUE could flow it - // into arbitrary lowering; only scalar reads the analysis proves - // numeric are admitted. + _ => false, + }, + // A bare read of the array, the counter, or the element binding as a + // VALUE could flow it into arbitrary lowering; only scalar reads the + // analysis proves numeric are admitted. The element binding is + // excluded EXPLICITLY rather than via the numeric test: a bare `r` + // would hand out a reference the clone's skipped `Let` never bound + // (#7771), and betting that exclusion on a type predicate is the + // #6377 shape this walk's docs warn about. Expr::LocalGet(id) => { - array.is_none_or(|a| a != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) + element_binding != Some(*id) + && array.is_none_or(|a| a != *id) + && crate::type_analysis::is_numeric_expr(ctx, expr) } Expr::Number(_) | Expr::Integer(_) => true, // NOTE (#7480 step 3): deliberately NOT gated on @@ -201,19 +222,50 @@ fn element_shape_loop_pure_expr_collect( // object-literal element). Keeping it would have declined #7480's own // kernel before the class resolver was ever consulted. Expr::Binary { left, right, .. } => { - element_shape_loop_pure_expr_collect(ctx, left, counter_id, array, props) - && element_shape_loop_pure_expr_collect(ctx, right, counter_id, array, props) - } - Expr::NumberCoerce(operand) => { - element_shape_loop_pure_expr_collect(ctx, operand, counter_id, array, props) + element_shape_loop_pure_expr_collect( + ctx, + left, + counter_id, + element_binding, + array, + props, + ) && element_shape_loop_pure_expr_collect( + ctx, + right, + counter_id, + element_binding, + array, + props, + ) } + Expr::NumberCoerce(operand) => element_shape_loop_pure_expr_collect( + ctx, + operand, + counter_id, + element_binding, + array, + props, + ), Expr::MathImul(left, right) | Expr::MathPow(left, right) => { - element_shape_loop_pure_expr_collect(ctx, left, counter_id, array, props) - && element_shape_loop_pure_expr_collect(ctx, right, counter_id, array, props) + element_shape_loop_pure_expr_collect( + ctx, + left, + counter_id, + element_binding, + array, + props, + ) && element_shape_loop_pure_expr_collect( + ctx, + right, + counter_id, + element_binding, + array, + props, + ) } - Expr::MathMin(values) | Expr::MathMax(values) => values - .iter() - .all(|e| element_shape_loop_pure_expr_collect(ctx, e, counter_id, array, props)), + Expr::MathMin(values) | Expr::MathMax(values) => values.iter().all(|e| { + element_shape_loop_pure_expr_collect(ctx, e, counter_id, element_binding, array, props) + }), Expr::MathAbs(value) | Expr::MathSqrt(value) | Expr::MathFloor(value) @@ -221,9 +273,14 @@ fn element_shape_loop_pure_expr_collect( | Expr::MathRound(value) | Expr::MathTrunc(value) | Expr::MathSign(value) - | Expr::MathF16round(value) => { - element_shape_loop_pure_expr_collect(ctx, value, counter_id, array, props) - } + | Expr::MathF16round(value) => element_shape_loop_pure_expr_collect( + ctx, + value, + counter_id, + element_binding, + array, + props, + ), _ => false, } } @@ -521,10 +578,52 @@ fn match_element_shape_versioned_loop( return None; } - // Store-free single-statement body: a scalar accumulator over tracked - // element-field reads. NOTHING else is admitted (see the module docs). - let [Stmt::Expr(Expr::LocalSet(acc_id, value))] = body else { - return None; + // Store-free body, in one of two admitted shapes (see the module docs): + // + // 1. `acc = ` — the original single + // statement; + // 2. `const r = arr[j]; acc = ` — #7771's + // element-binding form, the shape real read loops are written in. + // The binding is VIRTUAL inside the fast clone: its `Let` emits + // nothing (`stmt/let_stmt.rs`) and every `r.field` lowers through + // the fact, so the revocation argument (no store, no call in the + // clone) is unchanged. `const`-only, deliberately: a `var` binding + // is function-scoped and observable after the loop, where the + // skipped `Let` would leave the slot holding its pre-loop value. + // + // NOTHING else is admitted. + let (element_binding, acc_id, value) = match body { + [Stmt::Expr(Expr::LocalSet(acc_id, value))] => (None, acc_id, value), + [Stmt::Let { + id, + mutable: false, + init: Some(Expr::IndexGet { object, index }), + .. + }, Stmt::Expr(Expr::LocalSet(acc_id, value))] => { + let (Expr::LocalGet(arr_id), Expr::LocalGet(idx_id)) = + (object.as_ref(), index.as_ref()) + else { + return None; + }; + // Same receiver/index discipline as the walk's IndexGet arm: the + // fetch must be `arr[counter]` exactly. + if *idx_id != counter_id || *arr_id == counter_id || *id == counter_id { + return None; + } + // The binding must be a plain, loop-owned const local. A boxed or + // captured binding lives in a cell the skipped `Let` would leave + // stale for an observer outside the clone; a module-global id is + // not a body-scoped binding at all. + if *id == *arr_id + || ctx.boxed_vars.contains(id) + || ctx.module_globals.contains_key(id) + || ctx.closure_captures.contains_key(id) + { + return None; + } + (Some((*id, *arr_id)), acc_id, value) + } + _ => return None, }; if *acc_id == counter_id || !ctx.locals.contains_key(acc_id) @@ -535,9 +634,22 @@ fn match_element_shape_versioned_loop( { return None; } - let mut array: Option = None; + // The binding form pins the array before the walk runs, so a body mixing + // `r.field` with `other[j].field` is declined by the walk's one-array rule. + let mut array: Option = element_binding.map(|(_, arr_id)| arr_id); + let element_binding = element_binding.map(|(id, _)| id); + if element_binding == Some(*acc_id) { + return None; + } let mut props: std::collections::BTreeSet = std::collections::BTreeSet::new(); - if !element_shape_loop_pure_expr_collect(ctx, value, counter_id, &mut array, &mut props) { + if !element_shape_loop_pure_expr_collect( + ctx, + value, + counter_id, + element_binding, + &mut array, + &mut props, + ) { return None; } let array_id = array?; @@ -546,7 +658,7 @@ fn match_element_shape_versioned_loop( } match bound { ElementShapeLoopBound::Local(bound_id) => { - if bound_id == array_id || bound_id == *acc_id { + if bound_id == array_id || bound_id == *acc_id || Some(bound_id) == element_binding { return None; } } @@ -639,6 +751,7 @@ fn match_element_shape_versioned_loop( expected_class_id, keys_global_name, fields, + element_binding, }) } @@ -755,6 +868,7 @@ pub(super) fn lower_element_shape_versioned_for( side_exit_label: slow_pre_label.clone(), fields: matched.fields.clone(), max_field_index, + element_binding: matched.element_binding, }); let lowered = lower_for_after_init_with_i32_bound( ctx, 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 9c65b8073c..b210c6f651 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -1170,3 +1170,210 @@ fn the_repair_does_not_put_a_call_inside_the_fast_clone() { "the clone must still be reached after the #7480 repair" ); } + +// ── #7771: the element-binding body form ──────────────────────────────────── +// +// `for (let j = 0; j < N; j++) { const r = keep[j]; sum = sum + r.v; }` — the +// shape real read loops are written in. The binding is VIRTUAL inside the +// fast clone: its `Let` emits nothing and every `r.field` lowers through the +// fact, so the element fetch is a bare load under the preheader guard instead +// of the generic index-get's runtime-call diamond. + +const BINDING_ID: u32 = 9; + +/// `const r = keep[j];` (or `let r = ...` when `mutable`). +fn binding_stmt(mutable: bool) -> Stmt { + Stmt::Let { + id: BINDING_ID, + name: "r".to_string(), + ty: Type::Named("Node".to_string()), + mutable, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARRAY_ID)), + index: Box::new(Expr::LocalGet(COUNTER_ID)), + }), + } +} + +/// `sum = sum + ` +fn binding_accumulate(value: Expr) -> Stmt { + Stmt::Expr(Expr::LocalSet( + SUM_ID, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(SUM_ID)), + right: Box::new(value), + }), + )) +} + +/// `r.` +fn binding_field(prop: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(BINDING_ID)), + property: prop.to_string(), + byte_offset: 0, + } +} + +#[test] +fn element_shape_versioned_loop_fires_for_the_7771_element_binding_shape() { + let ir = emit(&element_shape_module( + vec![binding_stmt(false), binding_accumulate(binding_field("v"))], + None, + )); + for label in CLONE_LABELS { + assert!( + ir.contains(label), + "expected the element-binding form to be admitted, but `{label}` is \ + absent — the matcher in stmt/element_shape_loop.rs declined the \ + two-statement body" + ); + } + // Entered, not merely emitted. This is ALSO the assertion that proves the + // binding's `Let` really was skipped: had it lowered generically, its + // index-get diamond would put `js_array_get_f64` inside the clone, the + // call-free scan would fail, and the guard would branch unconditionally + // to the slow clone (#7690's deletion mode). + assert_fast_clone_is_entered(&ir); + let fast = fast_clone_slice(&ir); + assert!( + !fast.contains(" call "), + "the fast clone must be call-free; found a call in:\n{fast}" + ); + assert!( + !fast.contains("js_array_get_f64"), + "the element fetch must be a bare load in the fast clone" + ); + // The slow clone is the unchanged generic body: it must still bind `r` + // through the generic index-get, whose runtime call handles every shape + // the guard declines (holes, forwarding stubs, descriptors, subclasses). + assert!( + ir.contains("js_array_get_f64"), + "the slow clone must keep the generic element fetch" + ); +} + +#[test] +fn element_binding_reads_every_tracked_field_through_the_fact() { + let ir = emit(&element_shape_module( + vec![ + binding_stmt(false), + binding_accumulate(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(binding_field("v")), + right: Box::new(binding_field("w")), + }), + ], + None, + )); + assert_fast_clone_is_entered(&ir); + let fast = fast_clone_slice(&ir); + assert!( + !fast.contains(" call "), + "the two-field fast clone must be call-free; found a call in:\n{fast}" + ); + // One `element_shape.load` chain per tracked field read. + let load_defs = ir + .lines() + .filter(|l| l.starts_with("element_shape.load") && l.trim_end().ends_with(':')) + .count(); + assert!( + load_defs >= 2, + "both `r.v` and `r.w` must lower through the fact's bare load; found \ + {load_defs} element_shape.load block(s)" + ); +} + +/// SABOTAGE (scoping): a `let`/`var` binding is not admitted. `var` is +/// function-scoped and observable after the loop, where the skipped `Let` +/// would leave the slot holding its pre-loop value; the matcher keys on +/// `mutable: false`, so this must decline rather than emit a clone. +#[test] +fn element_binding_declines_a_mutable_binding() { + let ir = emit(&element_shape_module( + vec![binding_stmt(true), binding_accumulate(binding_field("v"))], + None, + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a mutable element binding must decline the clone" + ); +} + +/// SABOTAGE (escape): the binding used as a bare VALUE hands out a reference +/// the clone's skipped `Let` never bound. The walk must exclude it +/// EXPLICITLY — not via the numeric-type test, which a lying annotation +/// could satisfy. +#[test] +fn element_binding_declines_a_bare_value_use() { + let ir = emit(&element_shape_module( + vec![ + binding_stmt(false), + binding_accumulate(Expr::LocalGet(BINDING_ID)), + ], + None, + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a bare use of the element binding must decline the clone" + ); +} + +/// SABOTAGE (field admission): a property the element class does not declare +/// as a raw-f64 slot has no packed index to load; the per-prop admission +/// must decline the whole loop for the binding form exactly as it does for +/// the `arr[j].field` form. +#[test] +fn element_binding_declines_an_untracked_field() { + let ir = emit(&element_shape_module( + vec![ + binding_stmt(false), + binding_accumulate(binding_field("missing")), + ], + None, + )); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a field the class does not declare must decline the clone" + ); +} + +/// SABOTAGE (one array per loop): the binding pins the array it was fetched +/// from; a body that ALSO reads a different array's element must decline via +/// the walk's one-array rule. +#[test] +fn element_binding_declines_a_second_array() { + const OTHER_ID: u32 = 11; + let mut m = 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(OTHER_ID)), + index: Box::new(Expr::LocalGet(COUNTER_ID)), + }), + }, + binding_accumulate(elem_field(ARRAY_ID, Expr::LocalGet(COUNTER_ID))), + ], + None, + ); + m.init.insert( + 1, + Stmt::Let { + id: OTHER_ID, + name: "other".to_string(), + ty: Type::Array(Box::new(Type::Named("Node".to_string()))), + mutable: false, + init: Some(Expr::Array(Vec::new())), + }, + ); + let ir = emit(&m); + assert!( + !ir.contains("element_shape.loop.fast.preheader"), + "a body reading two different arrays must decline the clone" + ); +} diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 2064821f4a..1c8739b298 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -61,6 +61,27 @@ pub(crate) fn lower_let( ty: &perry_hir::types::Type, mutable: bool, ) -> Result<()> { + // #7771: inside an element-shape fast clone, the tracked + // `const r = arr[j]` binding is VIRTUAL. The matcher admitted the body + // only because every use of `r` is a tracked `r.field` read, and each of + // those lowers through `element_shape_loop_fact_for_property_get` to a + // bare element load — so the binding itself emits nothing. Lowering the + // generic `IndexGet` here would put a runtime-call diamond inside the + // clone, fail its call-free admission scan, and DELETE the clone rather + // than slow it (#7690's lesson). Skipping is sound: nothing reads `r` + // bare inside the clone (matcher), the clone is call-free so no GC + // observes the slot mid-loop, `const` scoping means nothing after the + // loop can read it, and a residual-check side exit re-runs the current + // iteration in the slow clone, whose OWN `Let` binds the slot before any + // use. The fact is popped before the slow clone lowers, so this arm + // cannot fire there. + if ctx + .element_shape_loop_facts + .iter() + .any(|fact| fact.element_binding == Some(id)) + { + return Ok(()); + } // `let C = SomeClass` aliases the local `C` to the class // `SomeClass` for `new C()` site rerouting. The HIR lowers // class identifiers referenced as values to `Expr::ClassRef`, diff --git a/test-files/test_gap_7771_element_binding_read_loop.ts b/test-files/test_gap_7771_element_binding_read_loop.ts new file mode 100644 index 0000000000..2d7c8d4b1f --- /dev/null +++ b/test-files/test_gap_7771_element_binding_read_loop.ts @@ -0,0 +1,107 @@ +// #7771: element-binding read loops (const r = a[i]; s += r.x + r.y) must +// behave exactly like node whether the element-shape clone's guard admits +// (h7: clean grown array), declines at runtime (h1 hole / h2 undefined / +// h4 Array subclass / h6 shrunk length), or the matcher declines statically +// (h8 mid-loop length write, h9 let-binding, h10 escaping binding). +// The different-shape-element case lives in #7776; the proxy case is +// test_gap_7771_proxy_array_read_loop.ts (separate file: #7775). +// Every read loop below is written in the +// EXACT body shape the widened matcher admits (`const r = a[i]; s += ...`), +// so the clone is emitted and the runtime guard / side exit — not the +// matcher — must reach correct behaviour. Byte-compared against node. +class P { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } } +class Q { x: string; y: string; constructor(x: string, y: string) { this.x = x; this.y = y; } } +class MyArr extends Array

{} + +function build(n: number): P[] { + const a: P[] = []; + for (let i = 0; i < n; i++) a.push(new P(i, i + 1)); + return a; +} + +// h1: hole punched by delete — guard must decline, slow clone throws like node +function h1(): number { + const a = build(10); + delete (a as any)[3]; + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} + +// h2: element overwritten with undefined +function h2(): number { + const a = build(10); + (a as any)[5] = undefined; + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} + +// h4: Array subclass behind an Array-typed binding (#7573/#7603 brand shape) +function h4(): number { + const a: P[] = new MyArr(); + for (let i = 0; i < 10; i++) a.push(new P(i, i + 1)); + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} + +// h6: length reduced between build and read +function h6(): number { + const a = build(10); + a.length = 5; + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} + +// h7: growth far past the initial inline capacity, then read (the base shape) +function h7(): number { + const a = build(100000); + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} + +// h8: length reduced MID-LOOP (3-statement body — matcher must decline; the +// generic loop re-reads the live length every iteration like node does) +function h8(): number { + const a = build(10); + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; if (i === 0) a.length = 3; } + return s; +} + +// h9: `let` binding instead of `const` (matcher declines; behaviour unchanged) +function h9(): number { + const a = build(10); + let s = 0; + for (let i = 0; i < a.length; i++) { let r = a[i]; s += r.x + r.y; } + return s; +} + +// h10: binding escapes as a bare value (walk declines; behaviour unchanged) +let leak: P | null = null; +function h10(): number { + const a = build(10); + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y + ((leak = r), 0); } + return s; +} + +function tryRun(tag: string, fn: () => number): void { + try { + console.log(tag, fn()); + } catch (e) { + console.log(tag, "threw:", (e as Error).message); + } +} +tryRun("h1", h1); +tryRun("h2", h2); +tryRun("h4", h4); +tryRun("h6", h6); +tryRun("h7", h7); +tryRun("h8", h8); +tryRun("h9", h9); +tryRun("h10", h10); +console.log("leak", leak ? (leak as P).x : -1); diff --git a/test-files/test_gap_7771_proxy_array_read_loop.ts b/test-files/test_gap_7771_proxy_array_read_loop.ts new file mode 100644 index 0000000000..7df9acde53 --- /dev/null +++ b/test-files/test_gap_7771_proxy_array_read_loop.ts @@ -0,0 +1,18 @@ +// #7771: a proxy-wrapped array behind a P[]-typed binding must take the +// generic path (the clone's brand guard declines) and match node. +// Kept alone in this file: #7775 tracks a module-wide miscompile triggered +// by the mere presence of `new Proxy(arr, {})` alongside other read loops. +class P { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } } +function build(n: number): P[] { + const a: P[] = []; + for (let i = 0; i < n; i++) a.push(new P(i, i + 1)); + return a; +} +function h5(): number { + const raw = build(10); + const a: P[] = new Proxy(raw, {}) as any; + let s = 0; + for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; } + return s; +} +console.log("h5", h5());