diff --git a/CLAUDE.md b/CLAUDE.md index d53967e24a..86970f7e65 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.1617 +**Current Version:** 0.5.1618 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 1899e6c64f..83e47ca547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5565,7 +5565,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-dispatch", "serde", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "cc", "libc", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "aho-corasick", "anyhow", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5671,7 +5671,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5679,7 +5679,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-dispatch", @@ -5688,7 +5688,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -5708,7 +5708,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-hir", @@ -5716,7 +5716,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "async-trait", "clap", @@ -5740,14 +5740,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "serde", "serde_json", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "clap", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "block2", "objc2", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "argon2", "perry-ffi", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bcrypt", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rusqlite", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "scraper", @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "cron", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rust_decimal", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "perry-runtime", @@ -5858,14 +5858,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fetch" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bytes", "lazy_static", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "bytes", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lazy_static", "perry-ffi", @@ -5920,7 +5920,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "perry-ffi", @@ -5928,7 +5928,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bson", "futures-util", @@ -5940,7 +5940,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "chrono", "perry-ffi", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "bytes", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lettre", "perry-ffi", @@ -5996,7 +5996,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "notify", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "printpdf", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "sqlx", @@ -6025,7 +6025,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "fast_image_resize", "image", @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "lazy_static", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-ffi", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-ffi", "perry-runtime", @@ -6074,7 +6074,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "futures-util", "lazy_static", @@ -6087,7 +6087,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "brotli", "flate2", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-api-manifest", @@ -6127,11 +6127,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-parser" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "perry-diagnostics", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perex", "regex", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "ahash", "base64 0.22.1", @@ -6210,14 +6210,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6301,21 +6301,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "dirs", "perry-ffi", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "jni", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "rand 0.10.2", "serde", @@ -6350,7 +6350,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6373,7 +6373,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6407,7 +6407,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-ui-test" @@ -6418,11 +6418,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1617" +version = "0.5.1618" [[package]] name = "perry-ui-tvos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6439,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "block2", @@ -6456,7 +6456,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "block2", "libc", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "libc", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "base64 0.22.1", "libc", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "anyhow", "base64 0.22.1", @@ -6517,7 +6517,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1617" +version = "0.5.1618" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d1d68ef65f..11bdfdbd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -321,7 +321,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1617" +version = "0.5.1618" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10777-ptrshape-rawf64-arm.md b/changelog.d/10777-ptrshape-rawf64-arm.md new file mode 100644 index 0000000000..2dd3621922 --- /dev/null +++ b/changelog.d/10777-ptrshape-rawf64-arm.md @@ -0,0 +1,5 @@ +**A property read whose shape already records the field as numeric now vouches as canonical raw f64.** + +`expr_produces_canonical_raw_f64` refused every `PropertyGet` on principle, so an arithmetic leaf reading such a field kept a per-iteration tag test — even though the fact was present and already consumed by codegen, which emits `load(DOUBLE, …)` with `rep: F64`. It was then re-derived *syntactically* from the Expr node at the add. That is also why `o.a * 1` reaches 9 where `o.a` does not: `Binary` is a shape the predicate recognises, and the multiply normalises nothing. + +`h += o.a` goes 35 → 33, `h += o.a + o.b` 50 → 43, `o.a = o.a + 1; h += o.a` 97 → 94 — 2–7 instructions per iteration where the receiver is a class instance with proven provenance. diff --git a/changelog.d/10793-repsel-wasted-ptr-shape.md b/changelog.d/10793-repsel-wasted-ptr-shape.md new file mode 100644 index 0000000000..1fa4813f4f --- /dev/null +++ b/changelog.d/10793-repsel-wasted-ptr-shape.md @@ -0,0 +1,24 @@ +**A `Ptr` proof that has no access site to spend now says so, instead of reading like an honest zero.** Fixes the `repsel-census` gate, red on `main` since v0.5.1613. + +The census refused a workload with a wasted promotion and no named mechanism: + +``` +batch: 1 selected ptr-shape promotion(s) were not consumed, and not one of them +names a mechanism. … Either a mechanism recorder was removed, or a new way to +drop a proof exists and needs one. +``` + +The wasted promotion is `row` in `buildRows` (`benchmarks/app-patterns/kernels/batch.ts`). Its only use is `rows.push(row)`, a contained element push, so it survives rule 2 with **no property access anywhere in the body** — the proof is sound and there is nothing to spend it on. That is the residue `check_unconsumed_is_explained`'s docstring already described as "dropped by nobody" and then tolerated, on condition that some *other* promotion in the same workload named a rule. Nobody is a mechanism; it just had no recorder. + +#10769 exposed it without causing it. Confirmed by A/B rather than inferred: subtracting only #10769's `RepselContextFlags::derive` `Entry` hunk from today's `main` reproduces the v0.5.1612 `batch` report entry-for-entry and the census goes green. What that hunk changed is `batch`'s **other** wasted promotion — `totals`, in the module-init body, previously dropped by `module_init_context` and now consumed. Removing the only named mechanism on the workload left the pre-existing residue as the only wasted promotion, and therefore the only one the check could see. + +So the fix names the mechanism rather than undoing anything. No floor was lowered and no allowance widened. + +- `PTR_SHAPE_NO_ACCESS_SITE` (`no_access_site`) joins `module_init_context` and `scalar_replaced` in `expr/slot_rep.rs`. The three are disjoint by construction: `module_init_context` fires from `ptr_shape_receiver_fact`, which only runs *at* an access site, and `scalar_replaced` fires for objects whose HIR field accesses are exactly what scalar replacement rewrites. +- `ptr_shape_report::note_no_access_site` records it, called from the same loop iteration as the `select()` it annuls. The verdict reads the use walk's **own** containment bookkeeping — `field_stores`, `method_calls`, plus a new `UseWalk::field_reads` for the one shape that stores nothing — rather than a second, separately-drifting definition of "access site". Report-only; `--opt-report` off allocates and records nothing. +- Inferring the rule at render time from `selected − consumed` was rejected: it would name a mechanism for *every* gap and permanently disarm the check. +- `report_early_bail` moved to `ptr_shape_report.rs` as `report::early_bail` (it calls nothing but `report::`) to keep `ptr_shape.rs` under the 2000-line cap. + +Effect on the corpus is one report entry: the full 29-workload run against the v0.5.1616 release compiler differs from the fixed compiler by `no_access_site 1` and nothing else — every promotion count, floor and advisory-improvement line is byte-identical. + +The gate still discriminates, checked in both directions. Disabling the new recorder returns `batch` to the original failure; disabling the pre-existing `scalar_replaced` recorder reddens `fixture_alloc_buckets`, `suite_07_object_create` and `suite_12_binary_trees`, which `no_access_site` does **not** paper over. `a_selected_promotion_with_no_access_site_names_its_mechanism` asserts both halves and fails under either sabotage — including a recorder that fires on a local that *does* have an access site, the direction that would turn the census permanently green. diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index c79765d553..13b64f34e3 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -134,7 +134,6 @@ use std::collections::{HashMap, HashSet}; use perry_hir::{Class, Expr, Stmt}; -use super::cjs_scaffolding::CjsPreamble; use super::ptr_shape_elements::ElementShapeFacts; use super::ptr_shape_report as report; use super::ptr_shape_report::ShapeDenial; @@ -229,33 +228,6 @@ fn note_ptr_shape_local( ); } -/// Record every candidate the collector will not even reach, on an -/// early-bail path (env gate off, or the rule-5 module-wide barrier). -/// -/// Runs only under `--opt-report`; it re-derives the candidate seeds purely -/// to name them, and returns nothing the collector consumes — the bail-out -/// itself is unchanged. -fn report_early_bail( - stmts: &[Stmt], - boxed_vars: &HashSet, - module_globals: &HashMap, - preamble: &CjsPreamble, - denial: ShapeDenial, -) { - if !opt_report::enabled() { - return; - } - let names = report::local_names(stmts); - let depths = report::loop_depths(stmts); - let seeds = report::candidate_seeds(stmts, boxed_vars, module_globals, preamble); - for (id, class_name) in &seeds { - report::deny_local(*id, &names, &depths, Some(class_name), denial); - } - for site in report::unbound_new_sites(stmts, preamble) { - report::deny_alloc_site(&site); - } -} - #[path = "ptr_shape_entry.rs"] mod entry; pub(crate) use entry::{ @@ -295,7 +267,7 @@ fn collect_shape_proven_ptr_locals_impl( None }; if let Some(denial) = bail { - report_early_bail(stmts, boxed_vars, module_globals, &preamble, denial); + report::early_bail(stmts, boxed_vars, module_globals, &preamble, denial); return (HashMap::new(), HashMap::new()); } // `--opt-report` (#6952): binding names and loop depths for the values @@ -317,7 +289,7 @@ fn collect_shape_proven_ptr_locals_impl( // replacement (excludes boxed and module-global locals — which also // excludes async/generator bodies, whose locals are boxed by the // async-to-generator transform), minus #7152's CommonJS module record. - // Shared with `report_early_bail` so the collector and the report can + // Shared with `report::early_bail` so the collector and the report can // never disagree about what a candidate is. let mut candidates = report::candidate_seeds(stmts, boxed_vars, module_globals, &preamble); // #7034 §4: `const r = producer(...)` where `producer` carries a @@ -411,6 +383,7 @@ fn collect_shape_proven_ptr_locals_impl( element_pushes: HashMap::new(), const_local_inits: HashMap::new(), disq_reasons: HashMap::new(), + field_reads: HashSet::new(), escape_ctx: report::ESC_BARE_REFERENCE, return_seeded, element_seeded: &element_seeded, @@ -428,6 +401,7 @@ fn collect_shape_proven_ptr_locals_impl( element_pushes, const_local_inits, disq_reasons, + field_reads, .. } = walk; // #7770: locals whose every write is number-producing by construction — @@ -584,6 +558,11 @@ fn collect_shape_proven_ptr_locals_impl( }), }; note_ptr_shape_local(*id, &fact, &names, &depths); + // #10793. Beside the `select()` it annuls, so the two cannot drift. + let accessed = field_reads.contains(id) + || field_stores.contains_key(id) + || method_calls.contains_key(id); + report::note_no_access_site(*id, &fact, accessed); // Aliases carry the same fact: they hold the same object, their slots // are equally shadow-bound, and access sites key on the local they // actually reference. @@ -819,6 +798,10 @@ struct UseWalk<'a> { /// disqualified it. Purely observational — `disqualified` is the set the /// proof consults; this only records why. disq_reasons: HashMap, + /// `--opt-report` (#10793): roots with a declared-field READ. The other + /// four access shapes already land in `field_stores`/`method_calls`, so + /// together they decide `report::note_no_access_site`. + field_reads: HashSet, /// The escape kind a bare `LocalGet` in the current position implies. /// Parent arms narrow it (`return`, call argument, array element, …) so /// the report can say *how* the object escaped, not just that it did. @@ -872,6 +855,13 @@ impl<'a> UseWalk<'a> { self.disq_reasons.entry(root).or_insert(why); } + /// #10793; report-only. See [`Self::field_reads`]. + fn note_field_read(&mut self, root: u32) { + if opt_report::enabled() { + self.field_reads.insert(root); + } + } + /// Run `f` with the bare-reference escape kind narrowed to `why`. fn with_ctx(&mut self, why: ShapeDenial, f: impl FnOnce(&mut Self)) { let previous = self.escape_ctx; @@ -1070,6 +1060,7 @@ impl<'a> UseWalk<'a> { } => { if let Expr::LocalGet(id) = object.as_ref() { if let Some(root) = self.tracked_root(*id) { + self.note_field_read(root); if !self.candidate_chain_has_field(root, property) { self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs index 1c826a2d62..64aeb432b2 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -981,3 +981,95 @@ fn a_bare_return_is_still_a_return_position_and_still_served() { assert_eq!(rows[0].alloc_context.as_deref(), Some("return")); assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served)); } + +/// #10793. A `Ptr` proof that no property access can ever spend must say +/// so, and the recorder must NOT fire for a proof that has one. +/// +/// This is the pair, not the positive alone. The check it answers — +/// `repsel_census.check_unconsumed_is_explained` — is satisfied by ANY named +/// mechanism, so a recorder that fired on every selected local would turn the +/// census permanently green while naming a reason that is false for most of +/// them. The second half is what makes the first half worth anything. +/// +/// `return ` is the shape that gets a local past rule 2 with no access +/// site at all (#7034 §4 exempts a bare return), which is the corpus shape too: +/// `buildRows`'s `row` in `benchmarks/app-patterns/kernels/batch.ts` survives +/// on a contained `rows.push(row)` and never reads a field. +#[test] +fn a_selected_promotion_with_no_access_site_names_its_mechanism() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + + // Proven, and nothing in the body reads or writes a field of it. + let session = Session::start(); + let facts = run( + &[let_c(1, "idle"), Stmt::Return(Some(Expr::LocalGet(1)))], + &classes, + ); + let entries = session.entries(); + drop(session); + + assert!( + facts.contains_key(&1), + "the recording must be observational: the local is still promoted" + ); + assert!( + entries + .iter() + .any(|e| e.name == "idle" && e.outcome == Outcome::Selected), + "the win must still be counted, or there is no wasted promotion to explain" + ); + let dropped = entries + .iter() + .find(|e| e.name == "idle" && e.outcome == Outcome::Unconsumed) + .expect("a promotion with no access site must name a mechanism"); + assert_eq!(dropped.rule.as_deref(), Some("no_access_site")); + assert_eq!(dropped.analysis, crate::opt_report::Analysis::PtrShape); + assert_eq!(dropped.local_id, Some(1)); + assert!( + dropped + .reason + .as_deref() + .unwrap_or("") + .contains("never itself the object of a property access"), + "the reason must state the mechanism, not a neighbouring one: {:?}", + dropped.reason + ); + + // The discriminating half: one declared-field store is enough, and the + // recorder must stay silent. + let session = Session::start(); + let facts = run( + &[ + let_c(2, "used"), + store_x(2), + Stmt::Return(Some(Expr::LocalGet(2))), + ], + &classes, + ); + let entries = session.entries(); + drop(session); + + assert!( + facts.contains_key(&2), + "the accessed local is still promoted" + ); + assert!( + entries + .iter() + .any(|e| e.name == "used" && e.outcome == Outcome::Selected), + "…and still counted as a win" + ); + assert!( + !entries + .iter() + .any(|e| e.name == "used" && e.outcome == Outcome::Unconsumed), + "a local with an access site must NOT be reported as having none: {:?}", + entries + .iter() + .filter(|e| e.name == "used") + .map(|e| (e.outcome, e.rule.clone())) + .collect::>() + ); +} diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs index 891b54ee1c..88ea3b9f1d 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_report.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -35,6 +35,81 @@ use perry_hir::{Class, Expr, Stmt}; use super::cjs_scaffolding::CjsPreamble; use crate::opt_report::{self, Analysis, Denial, Position, Tier}; +/// Record every candidate the collector will not even reach, on an +/// early-bail path (env gate off, or the rule-5 module-wide barrier). +/// +/// Runs only under `--opt-report`; it re-derives the candidate seeds purely +/// to name them, and returns nothing the collector consumes — the bail-out +/// itself is unchanged. +pub(super) fn early_bail( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + preamble: &CjsPreamble, + denial: ShapeDenial, +) { + if !opt_report::enabled() { + return; + } + let names = local_names(stmts); + let depths = loop_depths(stmts); + let seeds = candidate_seeds(stmts, boxed_vars, module_globals, preamble); + for (id, class_name) in &seeds { + deny_local(*id, &names, &depths, Some(class_name), denial); + } + for site in unbound_new_sites(stmts, preamble) { + deny_alloc_site(&site); + } +} + +/// Record that a SELECTED `Ptr` local has no access site to spend its +/// proof at (#10793), so its promotion is wasted for a nameable reason. +/// +/// Report-only, and the counterpart of `ptr_shape::note_ptr_shape_local` +/// rather than a second opinion about it: the same loop iteration that counts +/// the win records that nothing will consume it. The alternative — inferring it +/// at render time from `selected` minus `consumed` — would fabricate a +/// mechanism for every gap and permanently disarm +/// `repsel_census.check_unconsumed_is_explained`, which is the check this +/// exists to answer honestly. +/// +/// `accessed` is the caller's verdict over the walk's OWN bookkeeping: a +/// declared-field read (`UseWalk::field_reads`), a store of any of the three +/// writing shapes (`field_stores`), or a chain-resolved method call +/// (`method_calls`). Reading what the containment proof already had to collect +/// is what keeps this from drifting into a second, disagreeing definition of +/// "access site". +/// +/// The other two `Ptr` drop mechanisms are disjoint from this one by +/// construction, so one value cannot be counted twice: `MODULE_INIT_CONTEXT` +/// fires from `FnCtx::ptr_shape_receiver_fact`, which only runs AT an access +/// site, and [`crate::expr::PTR_SHAPE_SCALAR_REPLACED`] fires for objects whose +/// HIR field accesses are exactly what scalar replacement rewrites. +pub(super) fn note_no_access_site(id: u32, fact: &super::ptr_shape::PtrShapeLocal, accessed: bool) { + // Same `SuppressScope` guard as `note_ptr_shape_local`: the return-shape + // pre-pass re-runs this proof speculatively (#7034 §4). + if accessed || suppressed() || !opt_report::enabled() { + return; + } + let (reason, issue) = + crate::expr::ptr_shape_context_rule_text(crate::expr::PTR_SHAPE_NO_ACCESS_SITE); + opt_report::unconsumed(opt_report::Unconsumed { + position: Position::Local, + name: fact.report_name.as_deref().unwrap_or(""), + local_id: Some(id), + analysis: Analysis::PtrShape, + rep: "Ptr", + rule: crate::expr::PTR_SHAPE_NO_ACCESS_SITE, + reason, + tier: Tier::CompilerLimitation, + issue: Some(issue), + detail: Some(format!( + "proven Ptr of class {}; the local has no property access in this body", + fact.class_name + )), + }); +} + /// A named denial: the rule as the collector numbers it, a human expansion, /// the actionability tier, and the tracking issue when the fix is ours. #[derive(Debug, Clone, Copy)] diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index c8b492b0f2..88c168d226 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -216,7 +216,8 @@ pub(crate) use slot_rep::{ collect_canonical_str_ineligible_locals, collect_closure_referenced_locals, deny_canonical_context, deny_canonical_i32, load_canonical_local_boxed, local_is_canonical_str, local_rep_is_canonical_i32, note_canonical_local, ptr_shape_context_rule_text, - store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_SCALAR_REPLACED, + store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_NO_ACCESS_SITE, + PTR_SHAPE_SCALAR_REPLACED, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 8dc38ff77c..21e77799e8 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -491,6 +491,33 @@ fn context_rule_text(rule: &str) -> (&'static str, &'static str) { /// promotion that was wasted, and the two mean opposite things. pub(crate) const PTR_SHAPE_SCALAR_REPLACED: &str = "scalar_replaced"; +/// `Ptr` consumption rule: the proven local is never itself the object +/// of a property access, so no representation-selection lowering ever consults +/// it (#10793). +/// +/// A candidate survives rule 2 only through uses that PRESERVE containment: a +/// bare `return `, a contained `A.push()` into an +/// element-shape-proven array (#7034 §3), and single-`Let` alias bindings. Any +/// other bare reference disqualifies it. So a selected local whose uses are +/// exclusively of that kind is proven and then has nowhere to spend the proof. +/// `buildRows`'s `row` in `benchmarks/app-patterns/kernels/batch.ts` is the +/// corpus example: its only use is `rows.push(row)`. The field reads happen +/// later, through the array's own element-shape fact, on a different value. +/// +/// This is the mechanism `repsel_census.check_unconsumed_is_explained` called +/// "a promotion with no access site at all is dropped by nobody" and then +/// tolerated as an unnamed residue. Nobody is a mechanism; it just had no +/// recorder. #10769 made that visible by CONSUMING `batch`'s other wasted +/// promotion (`totals`, until then dropped by [`MODULE_INIT_CONTEXT`]), which +/// left this residue as the workload's only wasted promotion — and a wasted +/// promotion that names no rule is indistinguishable from an honest zero, which +/// is the one state that census exists to refuse. +/// +/// Not a defect in the analysis: the proof is sound and costs a report entry, +/// not an emitted byte. The defect was that it looked exactly like a promotion +/// codegen had silently refused to apply. +pub(crate) const PTR_SHAPE_NO_ACCESS_SITE: &str = "no_access_site"; + /// `(reason, issue)` for a rule that stopped a *selected* `Ptr` proof /// from being consumed by codegen. pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static str) { @@ -511,6 +538,15 @@ pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static nothing", PTR_SHAPE_SCALAR_REPLACED_ISSUE, ), + PTR_SHAPE_NO_ACCESS_SITE => ( + "the local is never itself the object of a property access: every use \ + that survived rule 2 is containment-preserving (a bare `return`, a \ + contained `A.push(x)` into an element-shape-proven array, an alias \ + binding), so no representation-selection lowering ever consults the \ + fact. The proof is sound and cost nothing to emit — it simply had \ + nowhere to be spent", + PTR_SHAPE_NO_ACCESS_SITE_ISSUE, + ), _ => ( "async / generator bodies set `repsel_context_allows_canonical_i32: \ false`, and `FnCtx::ptr_shape_receiver_fact` returns None for the \ @@ -524,6 +560,9 @@ pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static /// Tracking issue for the scalar-replacement consumption mechanism. const PTR_SHAPE_SCALAR_REPLACED_ISSUE: &str = "#7115"; +/// Tracking issue for the no-access-site consumption mechanism. +const PTR_SHAPE_NO_ACCESS_SITE_ISSUE: &str = "#10793"; + pub(crate) fn deny_canonical_context( ctx: &FnCtx<'_>, id: u32, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index b0a983ef5f..24955943dd 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -667,6 +667,63 @@ pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool { true } + // #10777: a `Ptr`-proven receiver whose field is in the fact's + // `numeric_fields` already lowers to a BARE `load double` — + // `expr/property_get.rs` and `expr/property_get/helpers.rs` emit + // `blk.load(DOUBLE, &field_ptr)` with `SemanticKind::JsNumber` / + // `NativeRep::F64` and, in their own words, "no volatile gate, no + // header checks, no fallback arm, no phi". The value this predicate is + // asked about is therefore ALREADY canonical raw f64 bits; what was + // missing is that the label never reached here. `lower_expr` returns a + // bare LLVM value name, and the `raw_f64_layout_fact` the read records + // goes to `native_value/verify`, an AUDIT channel — so this predicate + // re-derived "reads may be boxed" syntactically from the `Expr` node + // instead of asking what was emitted. That lost label is the whole of + // the 29-vs-9 gap in #10777: `o.a * 1` reaches 9 only because `Binary` + // is a shape the arm below recognises, not because the multiply + // normalises anything. + // + // The blanket refusal in this function's doc comment — "reads + // (`IndexGet`/`PropertyGet` — cold fallbacks return boxed bits)" — is + // about the GUARDED read diamond, whose cold arm really does hand back + // boxed bits. This arm is the one read shape that has no cold arm at + // all, so the refusal's reason does not apply to it. + // + // The two halves of this predicate's contract are discharged as + // follows: + // + // * "never NaN-boxed" — `collectors/ptr_shape_numeric.rs` proves, by a + // greatest fixpoint over EVERY reachable store (constructor chain, + // field initializers, method stores, in-function stores, with + // parameters resolved through the actual arguments at the provenance + // `new`s / recorded call sites), that every store into this slot is + // number-producing BY CONSTRUCTION, per spec — explicitly not "the + // declared type says number". `Ptr` rule 1 containment bounds + // the set of stores that fixpoint has to consider, which is why the + // read needs no runtime header check. That proof already licenses a + // bare `load double` claiming `JsNumber`/`F64`; it is strictly + // stronger than the claim made here. + // + // * "any NaN it produces must carry a non-tag payload" — the store + // universe above admits no call of any kind (that match has no + // `Call`/`MethodCall`/FFI arm and ends in `_ => false`), so an FFI + // return can never reach one of these slots. The one raw byte source + // that can is an `ArrayBuffer` float read, reachable through the + // `Add` arm's `numeric_view_value_or_undefined`, and #10779 removed + // it by canonicalising a tag-band NaN at that read. + // + // Requires the receiver to resolve to the fact's own class, exactly as + // `numeric_proof_is_declared_only` and the two read sites do: the + // `numeric_fields` set is keyed to the class it was proven for. + Expr::PropertyGet { + object, property, .. + } => { + let Some(fact) = ctx.ptr_shape_receiver_fact(object.as_ref()) else { + return false; + }; + fact.numeric_fields.contains(property.as_str()) + && receiver_class_name(ctx, object).as_deref() == Some(fact.class_name.as_str()) + } Expr::Binary { .. } => { is_numeric_expr(ctx, e) && is_provably_not_bigint(ctx, e)