diff --git a/changelog.d/6911-repsel-p3b-shape-proven-objects.md b/changelog.d/6911-repsel-p3b-shape-proven-objects.md new file mode 100644 index 0000000000..118ab3d659 --- /dev/null +++ b/changelog.d/6911-repsel-p3b-shape-proven-objects.md @@ -0,0 +1,17 @@ +perf(codegen): representation-selection Phase 3b — shape-proven object locals (`Ptr`) + +For a function-local proven to hold exactly one `new C(...)` object with a +statically-immutable shape (provenance + containment + `this`-flow + dispatch +stability, with a module-wide first-increment kill on any +defineProperty/delete/setPrototypeOf/Proxy/mutating-Reflect site), field +accesses lower to the bare fixed-offset form — no per-access guard diamond, no +volatile gate, no fallback arm, no phi — and method calls dispatch directly +with no shape guard. Anon-shape record literals and extends chains qualify; +the typed-receiver f64 method clone is widened from extends-free classes to +fully-modeled chains with chain-global field indexes. Raw-f64 stores keep the +plain-finite check with a boxed-setter downgrade side exit; boxed stores keep +the generational write barrier; the local's slot stays a tagged-at-rest, +shadow-bound GC root (raw pointers never stored at rest; the mark/rewrite +raw-asymmetry is tracked as #6910). Gated by `PERRY_PTR_SHAPE_LOCALS` +(default on, object-cache keyed). Implements Phase 3b of +`docs/representation-selection-rfc.md`. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 713cd747ee..fd888df672 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1340,6 +1340,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let mut typed_string_methods = std::collections::HashSet::new(); let mut typed_i1_method_param_reps = std::collections::HashMap::new(); let mut typed_f64_receiver_methods = std::collections::HashMap::new(); + // Phase 3b typed-receiver widening: chain-global field indexes need the + // full class table — and it must be the SAME table dynamic dispatch's + // call-site gating consults (`class_table`, incl. class-expression + // aliases), or a chain resolvable only through an alias would gate a + // clone call the emission loop never produced (undefined symbol at + // link). + let receiver_class_table = &class_table; for class in &hir.classes { for method in &class.methods { let source_function = format!("{}::{}", class.name, method.name); @@ -1364,15 +1371,17 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> ], ), } - match typed_abi::typed_f64_receiver_method_info(class, method) { + match typed_abi::typed_f64_receiver_method_info(class, method, receiver_class_table) { Some(info) => { typed_f64_receiver_methods .insert((class.name.clone(), method.name.clone()), info); } None => { - if let Some(reason) = - typed_abi::typed_f64_receiver_method_rejection_reason(class, method) - { + if let Some(reason) = typed_abi::typed_f64_receiver_method_rejection_reason( + class, + method, + &receiver_class_table, + ) { record_typed_clone_rejection( &mut typed_clone_rejection_records, source_function.clone(), diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 944caa35a3..88c9172004 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -229,6 +229,9 @@ pub(crate) enum TypedCloneRejectionReason { ReceiverClassHasAccessor, ReceiverClassHasComputedMember, ReceiverClassHasComputedField, + /// A subclass re-declares a parent chain field name: the flattened slot + /// layout would be ambiguous (Phase 3b chain widening). + ReceiverFieldShadowed, ReceiverFieldNotOwn, ReceiverFieldNotF64, ThisEscape, @@ -276,6 +279,7 @@ impl TypedCloneRejectionReason { Self::ReceiverClassHasAccessor => "receiver_class_has_accessor", Self::ReceiverClassHasComputedMember => "receiver_class_has_computed_member", Self::ReceiverClassHasComputedField => "receiver_class_has_computed_field", + Self::ReceiverFieldShadowed => "receiver_field_shadowed", Self::ReceiverFieldNotOwn => "receiver_field_not_own", Self::ReceiverFieldNotF64 => "receiver_field_not_f64", Self::ThisEscape => "this_escape", @@ -468,15 +472,17 @@ pub(crate) fn typed_f64_method_rejection_reason( pub(crate) fn typed_f64_receiver_method_rejection_reason( class: &perry_hir::Class, method: &Function, + classes: &HashMap, ) -> Option { - typed_f64_receiver_method_candidate(class, method).err() + typed_f64_receiver_method_candidate(class, method, classes).err() } pub(crate) fn typed_f64_receiver_method_info( class: &perry_hir::Class, method: &Function, + classes: &HashMap, ) -> Option { - typed_f64_receiver_method_candidate(class, method).ok() + typed_f64_receiver_method_candidate(class, method, classes).ok() } pub(crate) fn typed_i1_method_rejection_reason( @@ -887,22 +893,84 @@ fn integer_literal_fits_i32(n: i64) -> bool { (i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&n) } -fn typed_receiver_own_field_index( - class: &perry_hir::Class, - property: &str, -) -> Result { - let mut index = 0u32; - for field in &class.fields { - if field.key_expr.is_some() { +/// Flattened, chain-global field view of a receiver class: `(global slot +/// index, field)` in allocation order — parent-chain fields FIRST, matching +/// `class_field_global_index` / `js_object_alloc_with_parent` slot layout. +pub(crate) struct TypedReceiverChainFields<'a> { + fields: Vec<(u32, &'a perry_hir::ClassField)>, +} + +/// Build the chain-global field view, applying the receiver-shape admission +/// checks to EVERY link of the chain (representation-selection Phase 3b +/// widening of the original `extends_name.is_none()` restriction): each link +/// must be a modeled, statically-extended user class with no accessors, no +/// computed members, and no computed field keys; duplicate field names across +/// the chain (a subclass shadowing a parent field) are rejected — the slot +/// layout would be ambiguous. +fn typed_receiver_chain_fields<'a>( + classes: &HashMap, + class: &'a perry_hir::Class, +) -> Result, TypedCloneRejectionReason> { + // Chain, self first. + let mut chain: Vec<&perry_hir::Class> = Vec::new(); + let mut current: Option<&perry_hir::Class> = Some(class); + let mut seen: HashSet<&str> = HashSet::new(); + while let Some(link) = current { + if !seen.insert(link.name.as_str()) || chain.len() > 64 { + return Err(TypedCloneRejectionReason::ReceiverClassExtends); + } + if link.extends_expr.is_some() + || link.native_extends.is_some() + || link.heritage_lexically_shadowed + || (link.extends.is_some() && link.extends_name.is_none()) + { + return Err(TypedCloneRejectionReason::ReceiverClassExtends); + } + if !link.getters.is_empty() || !link.setters.is_empty() { + return Err(TypedCloneRejectionReason::ReceiverClassHasAccessor); + } + if !link.computed_members.is_empty() { + return Err(TypedCloneRejectionReason::ReceiverClassHasComputedMember); + } + if link.fields.iter().any(|field| field.key_expr.is_some()) { return Err(TypedCloneRejectionReason::ReceiverClassHasComputedField); } + chain.push(link); + current = match link.extends_name.as_deref() { + Some(parent) => match classes.get(parent) { + Some(parent_class) => Some(*parent_class), + None => return Err(TypedCloneRejectionReason::ReceiverClassExtends), + }, + None => None, + }; + } + // Parent fields first: walk root-most ancestor down to self. + let mut fields: Vec<(u32, &perry_hir::ClassField)> = Vec::new(); + let mut names: HashSet<&str> = HashSet::new(); + let mut index = 0u32; + for link in chain.iter().rev() { + for field in &link.fields { + if !names.insert(field.name.as_str()) { + return Err(TypedCloneRejectionReason::ReceiverFieldShadowed); + } + fields.push((index, field)); + index += 1; + } + } + Ok(TypedReceiverChainFields { fields }) +} + +fn typed_receiver_chain_field_index( + chain_fields: &TypedReceiverChainFields<'_>, + property: &str, +) -> Result { + for (index, field) in &chain_fields.fields { if field.name == property { if crate::typed_shape::type_is_raw_f64_candidate(&field.ty) { - return Ok(index); + return Ok(*index); } return Err(TypedCloneRejectionReason::ReceiverFieldNotF64); } - index += 1; } Err(TypedCloneRejectionReason::ReceiverFieldNotOwn) } @@ -910,6 +978,7 @@ fn typed_receiver_own_field_index( fn typed_f64_receiver_method_candidate( class: &perry_hir::Class, method: &Function, + classes: &HashMap, ) -> Result { if method.is_async || method.is_generator || method.was_plain_async { return Err(TypedCloneRejectionReason::AsyncOrGenerator); @@ -920,21 +989,9 @@ fn typed_f64_receiver_method_candidate( if !is_f64_type(&method.return_type) { return Err(TypedCloneRejectionReason::ReturnTypeNotF64); } - // Keep this first slice exact: only methods on a final known receiver shape - // with own string-keyed fields. Parent field offsets and inherited method - // resolution remain on the generic ABI until the proof is widened. - if class.extends_name.is_some() || class.extends.is_some() || class.extends_expr.is_some() { - return Err(TypedCloneRejectionReason::ReceiverClassExtends); - } - if !class.getters.is_empty() || !class.setters.is_empty() { - return Err(TypedCloneRejectionReason::ReceiverClassHasAccessor); - } - if !class.computed_members.is_empty() { - return Err(TypedCloneRejectionReason::ReceiverClassHasComputedMember); - } - if class.fields.iter().any(|field| field.key_expr.is_some()) { - return Err(TypedCloneRejectionReason::ReceiverClassHasComputedField); - } + // Phase 3b widening: extends chains are admitted when every link passes + // the receiver-shape checks; field indexes are chain-global. + let chain_fields = typed_receiver_chain_fields(classes, class)?; let mut locals = HashMap::new(); for param in &method.params { @@ -956,7 +1013,7 @@ fn typed_f64_receiver_method_candidate( let mut used_fields = Vec::new(); let mut used_field_names = HashSet::new(); typed_f64_receiver_body_rejection_reason( - class, + &chain_fields, &method.body, locals, &mut used_fields, @@ -971,7 +1028,7 @@ fn typed_f64_receiver_method_candidate( } fn typed_f64_receiver_body_rejection_reason( - class: &perry_hir::Class, + chain_fields: &TypedReceiverChainFields<'_>, body: &[Stmt], mut locals: HashMap, used_fields: &mut Vec, @@ -990,7 +1047,7 @@ fn typed_f64_receiver_body_rejection_reason( .. } if is_f64_type(ty) && receiver_expr_is_typed_f64_safe( - class, + chain_fields, expr, &locals, used_fields, @@ -1007,17 +1064,21 @@ fn typed_f64_receiver_body_rejection_reason( } } match last { - Stmt::Return(Some(expr)) => { - receiver_expr_is_typed_f64_safe(class, expr, &locals, used_fields, used_field_names) - .map(|_| ()) - .map_err(|_| TypedCloneRejectionReason::ReturnExprNotTypedF64Safe) - } + Stmt::Return(Some(expr)) => receiver_expr_is_typed_f64_safe( + chain_fields, + expr, + &locals, + used_fields, + used_field_names, + ) + .map(|_| ()) + .map_err(|_| TypedCloneRejectionReason::ReturnExprNotTypedF64Safe), _ => Err(TypedCloneRejectionReason::BodyNotSingleReturn), } } fn receiver_expr_is_typed_f64_safe( - class: &perry_hir::Class, + chain_fields: &TypedReceiverChainFields<'_>, expr: &Expr, locals: &HashMap, used_fields: &mut Vec, @@ -1030,7 +1091,7 @@ fn receiver_expr_is_typed_f64_safe( Expr::PropertyGet { object, property, .. } if matches!(object.as_ref(), Expr::This) => { - let index = typed_receiver_own_field_index(class, property)?; + let index = typed_receiver_chain_field_index(chain_fields, property)?; if used_field_names.insert(property.clone()) { used_fields.push(TypedReceiverField { name: property.clone(), @@ -1043,7 +1104,7 @@ fn receiver_expr_is_typed_f64_safe( Expr::Unary { op, operand } => { if matches!(op, UnaryOp::Pos | UnaryOp::Neg) { receiver_expr_is_typed_f64_safe( - class, + chain_fields, operand, locals, used_fields, @@ -1060,8 +1121,20 @@ fn receiver_expr_is_typed_f64_safe( ) { return Err(TypedCloneRejectionReason::ReturnExprNotTypedF64Safe); } - receiver_expr_is_typed_f64_safe(class, left, locals, used_fields, used_field_names)?; - receiver_expr_is_typed_f64_safe(class, right, locals, used_fields, used_field_names) + receiver_expr_is_typed_f64_safe( + chain_fields, + left, + locals, + used_fields, + used_field_names, + )?; + receiver_expr_is_typed_f64_safe( + chain_fields, + right, + locals, + used_fields, + used_field_names, + ) } _ => Err(TypedCloneRejectionReason::ReturnExprNotTypedF64Safe), } diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 9755fd9900..d75d726382 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -120,6 +120,13 @@ pub(crate) struct ShapeStabilityFacts { // codegen consumer reads it yet. #[allow(dead_code)] pub scalar_replaceable_object_locals: HashSet, + /// Representation-selection Phase 3b: function-locals proven to hold + /// exactly one object of a statically-immutable shape for their entire + /// lifetime (`collectors/ptr_shape.rs`). Consumers: guard-free fixed- + /// offset field access (`expr/property_get.rs`, `expr/property_set.rs`) + /// and unguarded direct method dispatch + /// (`lower_call/property_get/dynamic_dispatch.rs`). + pub shape_proven_ptr_locals: HashMap, } #[derive(Debug, Clone, Default)] @@ -296,6 +303,12 @@ impl TypeFacts { &self.shape_stability.scalar_replaceable_object_locals } + /// Representation-selection Phase 3b: the shape-proof fact for a local, + /// when it is a proven `Ptr` local (`collectors/ptr_shape.rs`). + pub(crate) fn shape_proven_ptr_local(&self, local_id: u32) -> Option<&super::PtrShapeLocal> { + self.shape_stability.shape_proven_ptr_locals.get(&local_id) + } + pub(crate) fn proves_scalar_replacement(&self, local_id: u32) -> bool { self.shape_stability .scalar_replaceable_object_locals @@ -423,6 +436,17 @@ pub(crate) fn collect_type_facts( .chain(non_escaping_object_literals.keys()) .copied() .collect(); + // Representation-selection Phase 3b: shape-proven pointer locals. Gated + // on `PERRY_PTR_SHAPE_LOCALS` and the module-wide §5.2 barrier scan + // inside the collector. + let shape_proven_ptr_locals = super::ptr_shape::collect_shape_proven_ptr_locals( + stmts, + boxed_vars, + module_globals, + classes, + module_dispatch, + ¬_bigint_locals, + ); let graph = TypeFacts { representation: RepresentationFacts { integer_locals: integer_locals.clone(), @@ -460,6 +484,7 @@ pub(crate) fn collect_type_facts( }, shape_stability: ShapeStabilityFacts { scalar_replaceable_object_locals, + shape_proven_ptr_locals, }, materialization_hazards, }; diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 990067ad93..99171291fc 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -23,6 +23,7 @@ mod local_refs; mod mutation; mod not_bigint_locals; mod pointer_locals; +mod ptr_shape; mod refs; mod scalar_method_dispatch; mod scalar_methods; @@ -60,6 +61,7 @@ pub(crate) use integer_locals::{ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::has_any_mutation; pub(crate) use pointer_locals::collect_pointer_typed_locals; +pub(crate) use ptr_shape::PtrShapeLocal; pub(crate) use refs::{ collect_let_ids, collect_ref_ids_in_expr, collect_ref_ids_in_stmts, is_clamp_call, }; diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs new file mode 100644 index 0000000000..455b5c8f22 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -0,0 +1,1715 @@ +//! Representation-selection Phase 3b (RFC `docs/representation-selection-rfc.md` +//! §4 Object row, §5.5-§5.7): shape-proven object locals (`Ptr`). +//! +//! ## What this proves +//! +//! A function-local `let o = new C(...)` qualifies as a **shape-proven pointer +//! local** when static analysis proves that the object's shape (class identity, +//! key set, field layout, descriptor-free-ness, method table) cannot change for +//! the local's entire lifetime. Every field access on such a local then lowers +//! to the bare fixed-offset form — `load i64` slot, `and` POINTER_MASK, `gep +//! +header`, `gep` index, `load` — with **no per-access guard diamond** (no +//! volatile gate load, no 7-header-load shape check, no +//! `js_typed_feedback_class_field_*_guard` fallback arm, no `phi`), and method +//! calls on it lower to a **direct call with no shape guard**. +//! +//! ## Why it is sound without the runtime invalidators +//! +//! Today's guarded fast path is per-SITE and runtime-checked: the process-global +//! sticky gate (`PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED`), the per-object +//! descriptor flag, and prototype-guard invalidation exist because a receiver +//! at an arbitrary site can be *any* object. A `Ptr` local is instead +//! proven by **provenance + containment**: +//! +//! 1. **Provenance**: the local is initialized by exactly one `Stmt::Let` whose +//! init is `new C(...)` — its dynamic class is *exactly* `C` (Perry class +//! constructors cannot return an override object). Anon-shape literals +//! (`{k: v}` closed shapes) and `{}` builder sites also lower to +//! `Expr::New { class_name: "__AnonShape_…" }`, so records qualify through +//! the same test. +//! 2. **Containment**: every use of the local is a declared-chain field +//! read/write/update or a vetted method call. Any other use — reassignment, +//! closure capture, call argument, array/object element, return, throw, +//! `delete`, freeze/seal, aliasing — disqualifies. The object is therefore +//! unreachable from anywhere except this local, so no §5.2 barrier +//! (defineProperty / delete / setPrototypeOf / Proxy / mutating Reflect) +//! can reach it *through an alias*. +//! 3. **`this`-flow containment**: the constructor chain, chain field +//! initializers, and every method called on the local are walked with a +//! strict `this`-usage discipline (field access on `this`, vetted +//! `this.m()` / `super` chains only). Any leak of `this` as a value — +//! which would create an alias the escape walk cannot see — disqualifies. +//! 4. **Dispatch stability**: method calls additionally require +//! [`ModuleDispatchFacts::prototype_is_stable`] (the same module-level scan +//! shipped for scalar-replacement method summaries, #5872) and no +//! own-property write that could shadow the method (subsumed by rule 2: +//! writes to non-declared-field names disqualify). +//! 5. **Module-wide unbounded-barrier policy (first increment)**: if the +//! module contains *any* `Object.defineProperty`-family site, `delete`, +//! `setPrototypeOf`/`__proto__` write, `Proxy` construction, or mutating +//! `Reflect.*` call — regardless of target — ALL `Ptr` promotion in +//! the module is disabled (`ModuleDispatchFacts::shape_barrier_sites`). +//! Rules 1-4 already bound every path to the object, so this module-wide +//! kill is belt-and-braces against analysis blind spots; it is the +//! conservative first-increment rule from the RFC §5.2 discussion and +//! still covers the common barrier-free module. (`eval` needs no kill: +//! Perry never executes a runtime code string — see +//! `perry-hir/src/eval_classifier.rs`.) +//! +//! ## Numeric-proven fields +//! +//! For the READ side to keep today's `JsNumber`/`NativeRep::F64` semantics on a +//! bare `load double`, the analysis additionally proves per raw-f64-declared +//! field that **every reachable store** (constructor, chain field initializers, +//! method bodies, and in-function stores — an exhaustive set, by rule 2) is +//! number-producing by construction. Constructor/method parameter stores are +//! resolved through the actual argument expressions at the provenance `new` / +//! call sites. Fields that fail keep the bare load but surface as generic +//! `JsValue` (bit-identical — a NaN-boxed number IS its own double bits). +//! Store-side raw-slot discipline (plain-finite check + boxed-setter side +//! exit) is emitted at the access site regardless, so a NaN/Inf/non-number +//! store can never corrupt a scalar-masked slot the GC does not scan. +//! +//! ## GC contract (tagged-at-rest) +//! +//! The local's storage is untouched: the existing NaN-boxed slot, registered +//! as a rewritable root via `js_shadow_slot_bind` (the GC marks and rewrites +//! **through the bound alloca**, `gc/roots/shadow_stack.rs`). The raw pointer +//! is region-local SSA only: every access re-derives it from the slot, and +//! because the slot address escapes to the shadow-stack registry, LLVM cannot +//! CSE the reload across a safepoint — rebase-after-safepoint (RFC §5.6) falls +//! out of alias analysis. Raw pointers are never stored at rest. +//! +//! Gated by `PERRY_PTR_SHAPE_LOCALS` (default on; `0`/`off`/`false` disables — +//! keyed into the object cache). `PERRY_REPSEL_DEBUG=1` prints one line per +//! proven local at compile time. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Class, Expr, Stmt}; + +use super::ModuleDispatchFacts; + +/// `PERRY_PTR_SHAPE_LOCALS` gate. Enabled by default; `=0`/`off`/`false` +/// disables shape-proven pointer-local selection (every access keeps today's +/// guarded lowering). Keyed into the object cache (`object_cache.rs`). +pub fn ptr_shape_locals_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_PTR_SHAPE_LOCALS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +fn repsel_debug_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1")) +} + +/// A function-local proven to hold exactly one object of `class_name` for its +/// entire lifetime, with a statically-immutable shape. See the module doc for +/// the full proof obligations. +#[derive(Debug, Clone)] +pub struct PtrShapeLocal { + /// The provenance class — the exact dynamic class of the object. + pub class_name: String, + /// Raw-f64-declared chain fields whose every reachable store is proven + /// number-producing: bare loads may claim `JsNumber`/`F64`. Other fields' + /// bare loads surface as generic `JsValue` (bit-identical). + pub numeric_fields: HashSet, +} + +/// Whether an expression node is a §5.2 shape barrier for the module-wide +/// first-increment kill rule. Targets are NOT inspected — any occurrence +/// disables all `Ptr` promotion in the module. +pub(crate) fn expr_is_shape_barrier(expr: &Expr) -> bool { + match expr { + Expr::ObjectDefineProperty(..) + | Expr::ObjectDefineProperties(..) + | Expr::ReflectDefineProperty { .. } + | Expr::ObjectSetPrototypeOf(..) + | Expr::ReflectSetPrototypeOf { .. } + | Expr::ReflectSet { .. } + | Expr::ReflectDelete { .. } + | Expr::ReflectPreventExtensions(..) + | Expr::Delete(..) + | Expr::ProxyNew { .. } => true, + // `__proto__` writes mutate the prototype chain of an arbitrary + // object. (Reads and `.prototype` naming are handled by the + // dispatch-stability facts; only writes are shape barriers.) + Expr::PropertySet { property, .. } | Expr::PropertyUpdate { property, .. } => { + property == "__proto__" + } + Expr::PutValueSet { key, .. } => { + matches!(key.as_ref(), Expr::String(k) if k == "__proto__") + } + Expr::IndexSet { index, .. } => { + matches!(index.as_ref(), Expr::String(k) if k == "__proto__") + } + _ => false, + } +} + +/// Compile-time visibility: one stderr line per shape-proven local, plus a +/// process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. +fn note_ptr_shape_local(id: u32, fact: &PtrShapeLocal) { + if !repsel_debug_enabled() { + return; + } + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNT: AtomicU64 = AtomicU64::new(0); + let n = COUNT.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + "repsel: ptr-shape local id {id} class '{}' numeric_fields {:?} (total {n})", + fact.class_name, fact.numeric_fields + ); +} + +/// Entry point: collect the shape-proven pointer locals of one lowered region. +/// +/// `not_bigint_locals` feeds the numeric-field proof (a `Sub`/`Div`/bitwise +/// over provably-non-BigInt operands is a Number by spec). +pub(crate) fn collect_shape_proven_ptr_locals( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + classes: &HashMap, + module_dispatch: &ModuleDispatchFacts, + not_bigint_locals: &HashSet, +) -> HashMap { + if !ptr_shape_locals_enabled() || module_dispatch.has_shape_barrier_sites() { + return HashMap::new(); + } + // Pass 1: `Stmt::Let { init: New }` candidates, same seed as scalar + // replacement (excludes boxed and module-global locals — which also + // excludes async/generator bodies, whose locals are boxed by the + // async-to-generator transform). + let mut candidates: HashMap = HashMap::new(); + super::find_new_candidates(stmts, boxed_vars, module_globals, &mut candidates); + if candidates.is_empty() { + return HashMap::new(); + } + // Class-level admission BEFORE the use walk so the walk's chain-field + // membership tests are meaningful. + candidates.retain(|_, class_name| chain_admissible(classes, class_name)); + if candidates.is_empty() { + return HashMap::new(); + } + + // Alias pre-pass: `const alias = candidate` (the exact-receiver inliner + // materializes compound-assign receivers this way — `__cmpd_base_N`). + // A non-mutable Let whose init is a bare LocalGet of a candidate (or of + // another alias) tracks the SAME object; its uses follow the same rules + // and attribute to the root. Mutable, boxed, or module-global aliases + // stay untracked — a bare reference to the candidate through them then + // disqualifies via the use walk, which is the sound default. + let mut alias_edges: Vec<(u32, u32)> = Vec::new(); + collect_alias_edges(stmts, &mut alias_edges); + let mut roots: HashMap = candidates.keys().map(|id| (*id, *id)).collect(); + loop { + let mut changed = false; + for (alias, src) in &alias_edges { + if candidates.contains_key(alias) + || boxed_vars.contains(alias) + || module_globals.contains_key(alias) + || roots.contains_key(alias) + { + continue; + } + if let Some(&root) = roots.get(src) { + roots.insert(*alias, root); + changed = true; + } + } + if !changed { + break; + } + } + + // Pass 2: strict use walk. + let mut walk = UseWalk { + candidates: &candidates, + roots: &roots, + classes, + disqualified: HashSet::new(), + let_counts: HashMap::new(), + field_stores: HashMap::new(), + method_calls: HashMap::new(), + new_args: HashMap::new(), + const_local_inits: HashMap::new(), + }; + walk.walk_stmts(stmts); + let UseWalk { + disqualified, + let_counts, + field_stores, + method_calls, + new_args, + const_local_inits, + .. + } = walk; + let mut out = HashMap::new(); + 'cand: for (id, class_name) in &candidates { + if disqualified.contains(id) || let_counts.get(id).copied().unwrap_or(0) != 1 { + continue; + } + // Every alias of this root must itself be single-Let (a re-declared + // alias id would leave a second binding the proof does not cover). + if roots + .iter() + .any(|(m, r)| r == id && m != id && let_counts.get(m).copied().unwrap_or(0) != 1) + { + continue; + } + let chain = chain_classes(classes, class_name); + let fields = chain_field_names(&chain); + let methods = chain_method_map(&chain); + // Constructor chain + field initializers must not leak `this`. All + // methods called on the local must be `this`-flow safe and the module + // must prove the method table stable. + let mut analysis = ThisFlowAnalysis { + chain: &chain, + fields: &fields, + methods: &methods, + visited: HashSet::new(), + store_records: Vec::new(), + super_call_args: HashMap::new(), + internally_invoked: HashSet::new(), + }; + if !analysis.ctor_chain_safe() { + continue; + } + let called = method_calls.get(id); + if let Some(called) = called { + if !module_dispatch.prototype_is_stable(classes, class_name) { + continue; + } + for m in called.keys() { + if fields.contains(m.as_str()) { + // A name that is both a field and a method is ambiguous + // under own-property shadowing — bail. + continue 'cand; + } + let Some((owner, func)) = methods.get(m.as_str()) else { + continue 'cand; + }; + if !analysis.method_safe(owner, func) { + continue 'cand; + } + } + } + let store_records = std::mem::take(&mut analysis.store_records); + let super_call_args = std::mem::take(&mut analysis.super_call_args); + let internally_invoked = std::mem::take(&mut analysis.internally_invoked); + let members: HashSet = roots + .iter() + .filter(|(_, r)| *r == id) + .map(|(m, _)| *m) + .collect(); + let numeric_fields = prove_numeric_fields( + &chain, + &members, + &store_records, + field_stores.get(id).map(Vec::as_slice).unwrap_or(&[]), + new_args.get(id).copied().unwrap_or(&[]), + called, + &super_call_args, + &internally_invoked, + not_bigint_locals, + &const_local_inits, + ); + let fact = PtrShapeLocal { + class_name: class_name.clone(), + numeric_fields, + }; + note_ptr_shape_local(*id, &fact); + // 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. + for (member, root) in &roots { + if root == id && member != id { + out.insert(*member, fact.clone()); + } + } + out.insert(*id, fact); + } + out +} + +/// Collect `Let { mutable: false, init: Some(LocalGet(src)) }` edges — the +/// alias shape the exact-receiver inliner emits for compound assigns. +fn collect_alias_edges(stmts: &[Stmt], out: &mut Vec<(u32, u32)>) { + for s in stmts { + match s { + Stmt::Let { + id, + mutable: false, + init: Some(Expr::LocalGet(src)), + .. + } => out.push((*id, *src)), + Stmt::If { + then_branch, + else_branch, + .. + } => { + collect_alias_edges(then_branch, out); + if let Some(eb) = else_branch { + collect_alias_edges(eb, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + collect_alias_edges(body, out); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + collect_alias_edges(std::slice::from_ref(init.as_ref()), out); + } + collect_alias_edges(body, out); + } + Stmt::Try { + body, + catch, + finally, + } => { + collect_alias_edges(body, out); + if let Some(c) = catch { + collect_alias_edges(&c.body, out); + } + if let Some(f) = finally { + collect_alias_edges(f, out); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + collect_alias_edges(&case.body, out); + } + } + Stmt::Labeled { body, .. } => { + collect_alias_edges(std::slice::from_ref(body.as_ref()), out); + } + _ => {} + } + } +} + +// ── Class-level admission ────────────────────────────────────────────────── + +/// The chain (self first) when every link is a modeled, accessor-free, +/// computed-free, statically-extended user class; `None`-equivalent (empty) +/// otherwise. +fn chain_classes<'a>(classes: &HashMap, class_name: &str) -> Vec<&'a Class> { + let mut out = Vec::new(); + let mut current = Some(class_name.to_string()); + let mut seen = HashSet::new(); + while let Some(name) = current { + if !seen.insert(name.clone()) || out.len() > 64 { + return Vec::new(); + } + let Some(class) = classes.get(&name).copied() else { + return Vec::new(); + }; + out.push(class); + current = class.extends_name.clone(); + } + out +} + +fn chain_admissible(classes: &HashMap, class_name: &str) -> bool { + let chain = chain_classes(classes, class_name); + if chain.is_empty() { + return false; + } + for class in &chain { + if class.extends_expr.is_some() + || class.native_extends.is_some() + || class.heritage_lexically_shadowed + || !class.getters.is_empty() + || !class.setters.is_empty() + || !class.computed_members.is_empty() + || class.fields.iter().any(|f| f.key_expr.is_some()) + { + return false; + } + // `extends` (ClassId) without a resolvable `extends_name` means the + // parent is not statically walkable here. + if class.extends.is_some() && class.extends_name.is_none() { + return false; + } + } + // Reuse the shipped scalar-replacement chain rejections: built-in Error + // bases install fields at runtime; unmodeled/native bases stamp their + // method surface as own properties. + let class = chain[0]; + if super::this_as_value::class_chain_extends_builtin_error(class, classes) + || super::this_as_value::class_chain_has_unmodeled_base(class, classes) + { + return false; + } + true +} + +fn chain_field_names(chain: &[&Class]) -> HashSet { + let mut out = HashSet::new(); + for class in chain { + out.extend(class.fields.iter().map(|f| f.name.clone())); + } + out +} + +/// name -> (owning class name, method function), first (most-derived) wins — +/// matching JS prototype-chain resolution for an exact-class instance. +fn chain_method_map<'a>(chain: &[&'a Class]) -> HashMap { + let mut out: HashMap = HashMap::new(); + for class in chain { + for method in &class.methods { + out.entry(method.name.clone()) + .or_insert_with(|| (class.name.clone(), method)); + } + } + out +} + +// ── Pass 2: strict use walk ──────────────────────────────────────────────── + +/// A recorded store into a candidate's field, with enough context to resolve +/// parameter-mediated values later. +enum StoreValue<'a> { + /// Value expression in function scope (a direct `o.f = expr` store). + Direct(&'a Expr), + /// `++`/`--` — always numeric when the old value is numeric (recorded as + /// unconditionally numeric: ToNumeric of a proven-number field is that + /// number; non-proven fields are not claimed numeric anyway). + Update, +} + +struct UseWalk<'a> { + candidates: &'a HashMap, + /// Tracked member id (candidate or const alias) -> root candidate id. + roots: &'a HashMap, + classes: &'a HashMap, + disqualified: HashSet, + let_counts: HashMap, + /// root candidate -> (field name, store value) for in-function stores. + field_stores: HashMap)>>, + /// root candidate -> method name -> per-call-site argument lists. + method_calls: HashMap>>, + /// root candidate -> the provenance `new C(...)` argument list. + new_args: HashMap, + /// Non-tracked `const` locals' init expressions (single-Let only; a + /// re-declared id is poisoned to `None`). Lets the numeric-field proof + /// chase one level through `const v = i * 0.5`-style temps. + const_local_inits: HashMap>, +} + +impl<'a> UseWalk<'a> { + /// Root candidate for a tracked member id (candidate or alias). + fn tracked_root(&self, id: u32) -> Option { + self.roots.get(&id).copied() + } + + fn disq(&mut self, id: u32) { + if let Some(root) = self.tracked_root(id) { + self.disqualified.insert(root); + } + } + + fn candidate_chain_has_field(&self, root: u32, property: &str) -> bool { + let Some(class_name) = self.candidates.get(&root) else { + return false; + }; + let chain = chain_classes(self.classes, class_name); + chain + .iter() + .any(|c| c.fields.iter().any(|f| f.name == property)) + } + + fn walk_stmts(&mut self, stmts: &'a [Stmt]) { + for s in stmts { + self.walk_stmt(s); + } + } + + fn walk_stmt(&mut self, s: &'a Stmt) { + match s { + Stmt::Let { id, init, .. } => { + if self.candidates.contains_key(id) { + *self.let_counts.entry(*id).or_insert(0) += 1; + if let Some(Expr::New { args, .. }) = init.as_ref() { + self.new_args.insert(*id, args.as_slice()); + for a in args { + self.walk_expr(a); + } + return; + } + // A candidate whose Let init is not the New (var-redecl + // seed) is not provenance-stable. + self.disq(*id); + } else if !self.roots.contains_key(id) { + // Plain local: remember single-Let const inits for the + // numeric proof; poison re-declared ids. + if let Stmt::Let { + mutable: false, + init: Some(init), + .. + } = s + { + match self.const_local_inits.entry(*id) { + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(Some(init)); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + e.insert(None); + } + } + } else { + self.const_local_inits.insert(*id, None); + } + if let Some(e) = init { + self.walk_expr(e); + } + return; + } else if let Some(root) = self.tracked_root(*id) { + // Alias binding: `const alias = ` is + // the tracked edge itself — count it, don't treat the + // init's LocalGet as an escape. Any other init shape for + // an alias id disqualifies the root (re-declared alias). + *self.let_counts.entry(*id).or_insert(0) += 1; + match init.as_ref() { + Some(Expr::LocalGet(src)) if self.tracked_root(*src) == Some(root) => { + return; + } + _ => self.disqualified.insert(root), + }; + } + if let Some(e) = init { + self.walk_expr(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => self.walk_expr(e), + Stmt::Return(opt) => { + if let Some(e) = opt { + self.walk_expr(e); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.walk_expr(condition); + self.walk_stmts(then_branch); + if let Some(eb) = else_branch { + self.walk_stmts(eb); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.walk_expr(condition); + self.walk_stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + self.walk_stmt(init.as_ref()); + } + if let Some(c) = condition { + self.walk_expr(c); + } + if let Some(u) = update { + self.walk_expr(u); + } + self.walk_stmts(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + self.walk_stmts(body); + if let Some(c) = catch { + self.walk_stmts(&c.body); + } + if let Some(f) = finally { + self.walk_stmts(f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.walk_expr(discriminant); + for case in cases { + if let Some(t) = &case.test { + self.walk_expr(t); + } + self.walk_stmts(&case.body); + } + } + Stmt::Labeled { body, .. } => self.walk_stmt(body.as_ref()), + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } + } + + fn walk_expr(&mut self, e: &'a Expr) { + match e { + // Safe: declared-chain field read on a tracked member. + Expr::PropertyGet { + object, property, .. + } => { + if let Expr::LocalGet(id) = object.as_ref() { + if let Some(root) = self.tracked_root(*id) { + if !self.candidate_chain_has_field(root, property) { + self.disqualified.insert(root); + } + return; + } + } + self.walk_expr(object); + } + // Safe: declared-chain field write on a tracked member (value must + // not reference the same object — that would embed an untracked + // alias reachable through a field read). + Expr::PropertySet { + object, + property, + value, + } => { + if let Expr::LocalGet(id) = object.as_ref() { + if let Some(root) = self.tracked_root(*id) { + if !self.candidate_chain_has_field(root, property) { + self.disqualified.insert(root); + } else { + self.field_stores + .entry(root) + .or_default() + .push((property.clone(), StoreValue::Direct(value))); + } + // The value walk is position-aware: a field read of the + // same object is safe; a BARE reference to it (e.g. + // `o.self = o`) hits the LocalGet arm and escapes. + self.walk_expr(value); + return; + } + } + self.walk_expr(object); + self.walk_expr(value); + } + Expr::PropertyUpdate { + object, property, .. + } => { + if let Expr::LocalGet(id) = object.as_ref() { + if let Some(root) = self.tracked_root(*id) { + if !self.candidate_chain_has_field(root, property) { + self.disqualified.insert(root); + } else { + self.field_stores + .entry(root) + .or_default() + .push((property.clone(), StoreValue::Update)); + } + return; + } + } + self.walk_expr(object); + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + if let (Expr::LocalGet(id), Expr::LocalGet(rid), Expr::String(property)) = + (target.as_ref(), receiver.as_ref(), key.as_ref()) + { + if id == rid { + if let Some(root) = self.tracked_root(*id) { + if !self.candidate_chain_has_field(root, property) { + self.disqualified.insert(root); + } else { + self.field_stores + .entry(root) + .or_default() + .push((property.clone(), StoreValue::Direct(value))); + } + self.walk_expr(value); + return; + } + } + } + self.walk_expr(target); + self.walk_expr(key); + self.walk_expr(value); + self.walk_expr(receiver); + } + // Method call on a tracked member: receiver-position use is safe + // when the method is chain-resolvable; `this`-flow safety is + // vetted in pass 3. Tracked references in ARGS still escape. + Expr::Call { callee, args, .. } => { + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { + if let Expr::LocalGet(id) = object.as_ref() { + if let Some(root) = self.tracked_root(*id) { + let class_name = &self.candidates[&root]; + let chain = chain_classes(self.classes, class_name); + let resolvable = chain_method_map(&chain).contains_key(property); + if !resolvable { + self.disqualified.insert(root); + } else { + self.method_calls + .entry(root) + .or_default() + .entry(property.clone()) + .or_default() + .push(args.as_slice()); + } + for a in args { + // Position-aware: `o.m(o.field)` is safe, + // `o.m(o)` escapes via the LocalGet arm. + self.walk_expr(a); + } + return; + } + } + } + self.walk_expr(callee); + for a in args { + self.walk_expr(a); + } + } + // Barriers / hard escapes on a tracked member itself. + Expr::Delete(inner) => { + match inner.as_ref() { + Expr::PropertyGet { object, .. } | Expr::IndexGet { object, .. } => { + if let Expr::LocalGet(id) = object.as_ref() { + if self.tracked_root(*id).is_some() { + self.disq(*id); + return; + } + } + } + _ => {} + } + self.walk_expr(inner); + } + Expr::ObjectFreeze(t) | Expr::ObjectSeal(t) | Expr::ObjectPreventExtensions(t) => { + if let Expr::LocalGet(id) = t.as_ref() { + if self.tracked_root(*id).is_some() { + self.disq(*id); + return; + } + } + self.walk_expr(t); + } + // Reassignment / bare reference / numeric update = escape. + Expr::LocalSet(id, v) => { + self.disq(*id); + self.walk_expr(v); + } + Expr::LocalGet(id) => { + self.disq(*id); + } + Expr::Update { id, .. } => { + self.disq(*id); + } + // Id-keyed variants the child walker cannot see. + Expr::ArrayPush { array_id, .. } + | Expr::ArrayPushSpread { array_id, .. } + | Expr::ArrayUnshift { array_id, .. } + | Expr::ArraySplice { array_id, .. } + | Expr::ArrayCopyWithin { array_id, .. } => { + self.disq(*array_id); + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + Expr::ArrayPop(id) | Expr::ArrayShift(id) => { + self.disq(*id); + } + Expr::SetAdd { set_id, .. } => { + self.disq(*set_id); + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + Expr::WithSet { fallback, .. } => { + match fallback { + perry_hir::WithSetFallback::Local(id) + | perry_hir::WithSetFallback::SloppyImplicit(id) => { + self.disq(*id); + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + // Closures: captured or body-referenced tracked members escape + // (capture machinery + frame lifetime). + Expr::Closure { + body, + captures, + mutable_captures, + .. + } => { + for c in captures.iter().chain(mutable_captures.iter()) { + self.disq(*c); + } + self.walk_stmts(body); + } + // Everything else: recurse into children; a bare LocalGet of a + // candidate in any unhandled position hits the LocalGet arm above + // and escapes. Note: closure BODIES are Vec, handled above; + // walk_expr_children yields only Expr children. + _ => { + perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + } + } + } +} + +// ── Pass 3: `this`-flow safety of constructors and called methods ────────── + +/// A `this.field = value` store observed inside a constructor, a field +/// initializer, or a method body, with the owning function's parameter ids so +/// parameter-mediated values can be resolved through call-site arguments. +struct ThisStoreRecord<'a> { + field: String, + /// `None` = `++`/`--` update (numeric); `Some` = the stored expression. + value: Option<&'a Expr>, + /// Owning context: `None` for field initializers; `Some((owner_class, + /// method_name, param_ids))` for constructor ("constructor") and methods. + context: Option<(String, String, Vec)>, +} + +struct ThisFlowAnalysis<'a, 'b> { + chain: &'b [&'a Class], + fields: &'b HashSet, + methods: &'b HashMap, + visited: HashSet<(String, String)>, + store_records: Vec>, + /// `super(...)` argument lists observed in chain constructors, keyed by + /// the PARENT (callee) class name. Feeds the parent-ctor parameter + /// resolution of the numeric-field proof. + super_call_args: HashMap>, + /// Method names invoked INTERNALLY — `this.m(...)` from a constructor or + /// another method, and `super.m(...)`. Their argument expressions live in + /// the CALLING method's scope, which the numeric-field proof's + /// `ParamEnv::Sites` (function-scope call-site args) cannot resolve — + /// so parameters of internally-invoked methods must stay unproven even + /// when every EXTERNAL call site passes numeric arguments. + internally_invoked: HashSet, +} + +impl<'a, 'b> ThisFlowAnalysis<'a, 'b> { + /// Walk the constructor chain (self-first `super(...)` order) and every + /// chain field initializer under the strict `this` discipline. + fn ctor_chain_safe(&mut self) -> bool { + for class in self.chain { + for field in &class.fields { + if let Some(init) = &field.init { + if expr_mentions_this(init) { + return false; + } + self.store_records.push(ThisStoreRecord { + field: field.name.clone(), + value: Some(init), + context: None, + }); + } + } + } + for class in self.chain { + if let Some(ctor) = &class.constructor { + if !self.function_this_safe(&class.name, "constructor", ctor) { + return false; + } + } + } + true + } + + fn method_safe(&mut self, owner: &str, func: &'a perry_hir::Function) -> bool { + self.function_this_safe(owner, &func.name, func) + } + + fn function_this_safe( + &mut self, + owner: &str, + name: &str, + func: &'a perry_hir::Function, + ) -> bool { + let key = (owner.to_string(), name.to_string()); + if !self.visited.insert(key) { + return true; // already vetted (or in-progress higher up the stack) + } + if self.visited.len() > 64 { + return false; + } + if func.is_async || func.is_generator || func.was_plain_async { + return false; + } + let param_ids: Vec = func.params.iter().map(|p| p.id).collect(); + let ctx = (owner.to_string(), name.to_string(), param_ids); + let mut safe = true; + for s in &func.body { + if !safe { + break; + } + safe &= self.stmt_this_safe(s, &ctx); + } + safe + } + + fn stmt_this_safe(&mut self, s: &'a Stmt, ctx: &(String, String, Vec)) -> bool { + match s { + Stmt::Let { init, .. } => init + .as_ref() + .map(|e| self.expr_this_safe(e, ctx)) + .unwrap_or(true), + Stmt::Expr(e) | Stmt::Throw(e) => self.expr_this_safe(e, ctx), + Stmt::Return(opt) => { + // A constructor `return ` can OVERRIDE the `new` result + // (`js_ctor_return_override`): the provenance proof "the local + // holds exactly a C instance" would be wrong. Disqualify any + // value-returning chain constructor (conservative — even + // primitive returns, which JS ignores). + if ctx.1 == "constructor" && opt.is_some() { + return false; + } + opt.as_ref() + .map(|e| self.expr_this_safe(e, ctx)) + .unwrap_or(true) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr_this_safe(condition, ctx) + && then_branch.iter().all(|s| self.stmt_this_safe(s, ctx)) + && else_branch + .as_ref() + .map(|b| b.iter().all(|s| self.stmt_this_safe(s, ctx))) + .unwrap_or(true) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.expr_this_safe(condition, ctx) + && body.iter().all(|s| self.stmt_this_safe(s, ctx)) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_ref() + .map(|s| self.stmt_this_safe(s, ctx)) + .unwrap_or(true) + && condition + .as_ref() + .map(|e| self.expr_this_safe(e, ctx)) + .unwrap_or(true) + && update + .as_ref() + .map(|e| self.expr_this_safe(e, ctx)) + .unwrap_or(true) + && body.iter().all(|s| self.stmt_this_safe(s, ctx)) + } + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().all(|s| self.stmt_this_safe(s, ctx)) + && catch + .as_ref() + .map(|c| c.body.iter().all(|s| self.stmt_this_safe(s, ctx))) + .unwrap_or(true) + && finally + .as_ref() + .map(|f| f.iter().all(|s| self.stmt_this_safe(s, ctx))) + .unwrap_or(true) + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr_this_safe(discriminant, ctx) + && cases.iter().all(|case| { + case.test + .as_ref() + .map(|t| self.expr_this_safe(t, ctx)) + .unwrap_or(true) + && case.body.iter().all(|s| self.stmt_this_safe(s, ctx)) + }) + } + Stmt::Labeled { body, .. } => self.stmt_this_safe(body.as_ref(), ctx), + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => true, + } + } + + fn expr_this_safe(&mut self, e: &'a Expr, ctx: &(String, String, Vec)) -> bool { + match e { + Expr::PropertyGet { + object, property, .. + } if matches!(object.as_ref(), Expr::This) => self.fields.contains(property), + Expr::PropertySet { + object, + property, + value, + } if matches!(object.as_ref(), Expr::This) => { + if !self.fields.contains(property) || expr_mentions_this(value) { + return false; + } + self.store_records.push(ThisStoreRecord { + field: property.clone(), + value: Some(value), + context: Some(ctx.clone()), + }); + self.expr_this_safe(value, ctx) + } + Expr::PropertyUpdate { + object, property, .. + } if matches!(object.as_ref(), Expr::This) => { + if !self.fields.contains(property) { + return false; + } + self.store_records.push(ThisStoreRecord { + field: property.clone(), + value: None, + context: Some(ctx.clone()), + }); + true + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } if matches!(target.as_ref(), Expr::This) + && matches!(receiver.as_ref(), Expr::This) => + { + let Expr::String(property) = key.as_ref() else { + return false; + }; + if !self.fields.contains(property) || expr_mentions_this(value) { + return false; + } + self.store_records.push(ThisStoreRecord { + field: property.clone(), + value: Some(value), + context: Some(ctx.clone()), + }); + self.expr_this_safe(value, ctx) + } + // `this.m(args)` — vet the callee method transitively. + Expr::Call { callee, args, .. } + if matches!( + callee.as_ref(), + Expr::PropertyGet { object, .. } if matches!(object.as_ref(), Expr::This) + ) => + { + let Expr::PropertyGet { property, .. } = callee.as_ref() else { + unreachable!() + }; + if self.fields.contains(property) { + return false; // calling a field-held closure: dynamic + } + let Some((owner, func)) = self.methods.get(property).cloned() else { + return false; + }; + self.internally_invoked.insert(property.clone()); + if !self.function_this_safe(&owner, property, func) { + return false; + } + args.iter() + .all(|a| !expr_mentions_this(a) && self.expr_this_safe(a, ctx)) + } + // `super(...)`: the parent constructor body was already vetted by + // `ctor_chain_safe` (whole chain). Args must not leak `this`; in + // constructor context, record them for parent-ctor parameter + // resolution in the numeric-field proof. + Expr::SuperCall(args) => { + if ctx.1 == "constructor" { + if let Some(pos) = self.chain.iter().position(|c| c.name == ctx.0) { + if let Some(parent) = self.chain.get(pos + 1) { + self.super_call_args + .entry(parent.name.clone()) + .or_default() + .push(args.as_slice()); + } + } + } + args.iter() + .all(|a| !expr_mentions_this(a) && self.expr_this_safe(a, ctx)) + } + // `super.m(...)` resolves on the parent chain with the same `this`. + Expr::SuperMethodCall { method, args, .. } => { + let Some((owner, func)) = self.methods.get(method).cloned() else { + return false; + }; + self.internally_invoked.insert(method.clone()); + if !self.function_this_safe(&owner, method, func) { + return false; + } + args.iter() + .all(|a| !expr_mentions_this(a) && self.expr_this_safe(a, ctx)) + } + // Shape barriers on `this` inside a method body (the module-wide + // kill already covers these; kept as defense in depth). + Expr::Delete(inner) + if matches!( + inner.as_ref(), + Expr::PropertyGet { object, .. } | Expr::IndexGet { object, .. } + if matches!(object.as_ref(), Expr::This) + ) => + { + false + } + // Any other appearance of `this` — including inside closures — + // is a potential leak. + Expr::This => false, + Expr::Closure { body, .. } => { + // A closure that touches `this` (captures_this or body use) + // leaks it; a `this`-free closure is fine but its body may + // reference nothing we track here (locals are the outer + // function's problem — the use walk already handled the + // candidate local itself). + if expr_mentions_this(e) { + return false; + } + let _ = body; + true + } + _ => { + let mut ok = true; + perry_hir::walker::walk_expr_children(e, &mut |c| { + if ok { + ok = self.expr_this_safe(c, ctx); + } + }); + ok + } + } + } +} + +/// Does the expression mention `this` anywhere (including closure bodies and +/// `captures_this`)? +fn expr_mentions_this(e: &Expr) -> bool { + let mut found = false; + fn visit(e: &Expr, found: &mut bool) { + if *found { + return; + } + match e { + Expr::This => { + *found = true; + } + Expr::Closure { + body, + captures_this, + .. + } => { + if *captures_this { + *found = true; + return; + } + for s in body { + stmt_visit(s, found); + } + } + _ => {} + } + if *found { + return; + } + perry_hir::walker::walk_expr_children(e, &mut |c| visit(c, found)); + } + fn stmt_visit(s: &Stmt, found: &mut bool) { + if *found { + return; + } + match s { + Stmt::Let { init, .. } => { + if let Some(e) = init { + visit(e, found); + } + } + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => visit(e, found), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit(condition, found); + for s in then_branch { + stmt_visit(s, found); + } + if let Some(eb) = else_branch { + for s in eb { + stmt_visit(s, found); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit(condition, found); + for s in body { + stmt_visit(s, found); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + stmt_visit(i, found); + } + if let Some(c) = condition { + visit(c, found); + } + if let Some(u) = update { + visit(u, found); + } + for s in body { + stmt_visit(s, found); + } + } + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + stmt_visit(s, found); + } + if let Some(c) = catch { + for s in &c.body { + stmt_visit(s, found); + } + } + if let Some(f) = finally { + for s in f { + stmt_visit(s, found); + } + } + } + Stmt::Switch { + discriminant, + cases, + } => { + visit(discriminant, found); + for case in cases { + if let Some(t) = &case.test { + visit(t, found); + } + for s in &case.body { + stmt_visit(s, found); + } + } + } + Stmt::Labeled { body, .. } => stmt_visit(body.as_ref(), found), + _ => {} + } + } + visit(e, &mut found); + found +} + +// ── Pass 4: numeric-proven fields ────────────────────────────────────────── + +/// Greatest-fixpoint proof that every reachable store into a raw-f64-declared +/// chain field is number-producing. Parameter-mediated stores resolve through +/// the actual argument expressions at the provenance `new` (constructor) or +/// at every recorded call site (methods). +/// Parameter environment for [`expr_numeric_by_construction`]. +enum ParamEnv<'x> { + /// Function scope: no parameters; const-local chasing applies. + None, + /// Method scope: params resolve through recorded call-site argument + /// lists (each argument evaluated in function scope). + Sites { + param_ids: &'x [u32], + sites: Vec<&'x [Expr]>, + }, + /// Constructor scope: params pre-resolved to a numeric verdict through + /// the provenance `new` / `super(...)` argument chain. + Resolved(&'x HashMap), +} + +#[allow(clippy::too_many_arguments)] +fn prove_numeric_fields( + chain: &[&Class], + members: &HashSet, + this_stores: &[ThisStoreRecord<'_>], + local_stores: &[(String, StoreValue<'_>)], + new_args: &[Expr], + method_calls: Option<&HashMap>>, + super_call_args: &HashMap>, + internally_invoked: &HashSet, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, +) -> HashSet { + let mut numeric: HashSet = HashSet::new(); + for class in chain { + for field in &class.fields { + if crate::typed_shape::type_is_raw_f64_candidate(&field.ty) { + numeric.insert(field.name.clone()); + } + } + } + if numeric.is_empty() { + return numeric; + } + // Resolve the argument expressions that can flow into a given + // (context, param position): the provenance `new` args feed the root + // constructor; each parent constructor's params resolve through the + // recorded `super(...)` argument lists, evaluated under the CALLING + // constructor's (already-resolved) parameter environment. Derived-first + // chain order makes this a single top-down pass. The environment is + // computed against an EMPTY numeric-field set (strictly conservative — + // `super(this.x)` cannot occur, `this` is banned in super args). + let mut ctor_param_env: HashMap> = HashMap::new(); + { + let empty_numeric: HashSet = HashSet::new(); + for (pos, class) in chain.iter().enumerate() { + let Some(ctor) = class.constructor.as_ref() else { + continue; + }; + let mut env: HashMap = HashMap::new(); + if pos == 0 { + for (i, param) in ctor.params.iter().enumerate() { + let ok = new_args + .get(i) + .map(|a| { + expr_numeric_by_construction( + a, + &ParamEnv::None, + members, + &empty_numeric, + not_bigint_locals, + const_local_inits, + 0, + ) + }) + .unwrap_or(false); + env.insert(param.id, ok); + } + } else { + let caller_env = chain + .get(pos - 1) + .and_then(|caller| ctor_param_env.get(caller.name.as_str())); + let lists = super_call_args.get(class.name.as_str()); + for (i, param) in ctor.params.iter().enumerate() { + let ok = match (lists, caller_env) { + (Some(lists), Some(caller_env)) if !lists.is_empty() => { + lists.iter().all(|args| { + args.get(i) + .map(|a| { + expr_numeric_by_construction( + a, + &ParamEnv::Resolved(caller_env), + members, + &empty_numeric, + not_bigint_locals, + const_local_inits, + 0, + ) + }) + .unwrap_or(false) + }) + } + _ => false, + }; + env.insert(param.id, ok); + } + } + ctor_param_env.insert(class.name.clone(), env); + } + } + + loop { + let before = numeric.len(); + let is_store_numeric = |field: &str, + value: Option<&Expr>, + context: Option<&(String, String, Vec)>, + numeric: &HashSet| + -> bool { + let _ = field; + let Some(value) = value else { + // `++`/`--` — ToNumeric of a proven-number field stays a + // number; if the field is currently claimed numeric the + // update preserves it. + return true; + }; + let param_env: ParamEnv<'_> = match context { + None => ParamEnv::None, + Some((owner, name, param_ids)) => { + if name == "constructor" { + match ctor_param_env.get(owner.as_str()) { + Some(env) => ParamEnv::Resolved(env), + None => ParamEnv::Sites { + param_ids: param_ids.as_slice(), + sites: Vec::new(), + }, + } + } else { + // A method that is ALSO invoked internally + // (`this.m(...)` / `super.m(...)`) receives argument + // expressions from method scope that the + // function-scope site resolution below cannot see — + // its parameters stay unproven even when every + // external site is numeric (an internal + // `this.m("s")` would otherwise poison a + // "proven" field). Purely-external methods resolve + // through their recorded call sites; purely-internal + // ones have no sites and stay unproven either way. + let sites: Vec<&[Expr]> = if internally_invoked.contains(name.as_str()) { + Vec::new() + } else { + method_calls + .and_then(|mc| mc.get(name)) + .map(|v| v.clone()) + .unwrap_or_default() + }; + ParamEnv::Sites { + param_ids: param_ids.as_slice(), + sites, + } + } + } + }; + expr_numeric_by_construction( + value, + ¶m_env, + members, + numeric, + not_bigint_locals, + const_local_inits, + 0, + ) + }; + // Field initializers + ctor/method stores. + let mut retained: HashSet = numeric.clone(); + for rec in this_stores { + if retained.contains(&rec.field) + && !is_store_numeric(&rec.field, rec.value, rec.context.as_ref(), &numeric) + { + retained.remove(&rec.field); + } + } + for (field, sv) in local_stores { + if retained.contains(field) { + let ok = match sv { + StoreValue::Update => true, + StoreValue::Direct(v) => expr_numeric_by_construction( + v, + &ParamEnv::None, + members, + &numeric, + not_bigint_locals, + const_local_inits, + 0, + ), + }; + if !ok { + retained.remove(field); + } + } + } + numeric = retained; + if numeric.len() == before || numeric.is_empty() { + break; + } + } + numeric +} + +/// Number-by-construction: the expression's runtime value is a JS Number for +/// every input, per spec — never a string/BigInt/bool/undefined/pointer. +fn expr_numeric_by_construction( + e: &Expr, + param_env: &ParamEnv<'_>, + members: &HashSet, + numeric_fields: &HashSet, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + depth: usize, +) -> bool { + if depth > 16 { + return false; + } + use perry_hir::BinaryOp; + let rec = |x: &Expr| { + expr_numeric_by_construction( + x, + param_env, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ) + }; + match e { + Expr::Number(_) | Expr::Integer(_) => true, + Expr::Unary { op, operand } => match op { + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot => { + rec(operand) + } + _ => false, + }, + Expr::Binary { op, left, right } => match op { + // `+` concatenates strings; both sides must be numbers. + BinaryOp::Add => rec(left) && rec(right), + // `- * / %` produce BigInt only for BigInt⊗BigInt; a provably + // non-BigInt operand forces the Number path. + // `- * / %` produce a BigInt only for BigInt⊗BigInt; mixing a + // BigInt with anything else THROWS (no value is stored). ONE + // provably-non-BigInt operand therefore forces the completed + // result onto the Number path. + BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { + (rec(left) && rec(right)) + || expr_provably_not_bigint(left, not_bigint_locals) + || expr_provably_not_bigint(right, not_bigint_locals) + } + // Same either-side argument for the BigInt-capable bitwise ops. + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr => { + (rec(left) && rec(right)) + || expr_provably_not_bigint(left, not_bigint_locals) + || expr_provably_not_bigint(right, not_bigint_locals) + } + // `>>>` throws for BigInt operands; result is always a Number. + BinaryOp::UShr => true, + _ => false, + }, + Expr::NumberCoerce(_) + | Expr::ParseFloat(_) + | Expr::ParseInt { .. } + | Expr::MathSqrt(_) + | Expr::MathFloor(_) + | Expr::MathCeil(_) + | Expr::MathRound(_) + | Expr::MathTrunc(_) + | Expr::MathSign(_) + | Expr::MathAbs(_) + | Expr::MathF16round(_) + | Expr::MathPow(..) + | Expr::MathMin(_) + | Expr::MathMax(_) + | Expr::MathMinSpread(_) + | Expr::MathMaxSpread(_) + | Expr::DateNow + | Expr::PerformanceNow => true, + // A proven-numeric field of the SAME object (fixpoint edge): `this` + // inside the candidate's ctor/method contexts (a non-None param env), + // or a tracked member local in function scope. A same-named field of + // a DIFFERENT object proves nothing. + Expr::PropertyGet { + object, property, .. + } if match object.as_ref() { + Expr::This => !matches!(param_env, ParamEnv::None), + Expr::LocalGet(id) => members.contains(id), + _ => false, + } => + { + numeric_fields.contains(property) + } + Expr::Conditional { + then_expr, + else_expr, + .. + } => rec(then_expr) && rec(else_expr), + Expr::Sequence(es) => es.last().map(|x| rec(x)).unwrap_or(false), + // A parameter: numeric iff every recorded call site passes a numeric + // argument at that position (missing argument = `undefined`, not + // numeric). No recorded sites = unproven. + Expr::LocalGet(id) => { + match param_env { + ParamEnv::Sites { param_ids, sites } => { + if let Some(pos) = param_ids.iter().position(|p| p == id) { + return !sites.is_empty() + && sites.iter().all(|args| { + args.get(pos).map(|a| { + expr_numeric_by_construction( + a, + &ParamEnv::None, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ) + }) == Some(true) + }); + } + } + ParamEnv::Resolved(env) => { + if let Some(&ok) = env.get(id) { + return ok; + } + } + ParamEnv::None => { + // A single-Let const temp: chase its init (function + // scope, so no parameter mapping applies to it). + if let Some(Some(init)) = const_local_inits.get(id) { + return expr_numeric_by_construction( + init, + &ParamEnv::None, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + depth + 1, + ); + } + } + } + false + } + _ => false, + } +} + +/// Conservative "cannot be a BigInt" for the spec Number-path argument. +fn expr_provably_not_bigint(e: &Expr, not_bigint_locals: &HashSet) -> bool { + match e { + Expr::Number(_) | Expr::Integer(_) | Expr::String(_) | Expr::Bool(_) => true, + Expr::LocalGet(id) => not_bigint_locals.contains(id), + Expr::Unary { op, operand } => match op { + perry_hir::UnaryOp::Pos => true, // `+x` throws for BigInt + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::BitNot => { + expr_provably_not_bigint(operand, not_bigint_locals) + } + _ => true, // !x, typeof x, … never produce BigInt + }, + Expr::Binary { .. } => false, // handled structurally by the caller + _ => false, + } +} + +// Note on Symbol operands in the either-side non-BigInt arithmetic argument: +// ToNumber(Symbol) THROWS, so the store never completes — throw behavior is +// identical on the guarded and bare paths, and no non-number value can reach +// the slot through these operators. diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index cb7de9842d..6ab2037628 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -50,15 +50,23 @@ pub struct ModuleDispatchFacts { /// a declared class (`k.prototype`, `x.constructor.prototype`, …). Nothing /// in the module can then be trusted to keep a stable method table. opaque_prototype_mutation: bool, + /// Representation-selection Phase 3b: the module contains at least one + /// §5.2 shape-barrier site (`Object.defineProperty` family, `delete`, + /// `setPrototypeOf`/`__proto__` write, `Proxy`, mutating `Reflect.*`). + /// Under the first-increment conservative policy, any such site disables + /// ALL `Ptr` promotion in the module. See + /// `collectors/ptr_shape.rs` for the rule's soundness discussion. + shape_barrier_sites: bool, } impl Default for ModuleDispatchFacts { /// Fail safe: a fact set that was never populated must not license the - /// scalar-method summary. + /// scalar-method summary (nor any `Ptr` promotion). fn default() -> Self { Self { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: true, + shape_barrier_sites: true, } } } @@ -95,6 +103,12 @@ impl ModuleDispatchFacts { } true } + + /// Representation-selection Phase 3b: does the module contain any §5.2 + /// shape-barrier site (first-increment module-wide kill rule)? + pub(crate) fn has_shape_barrier_sites(&self) -> bool { + self.shape_barrier_sites + } } /// Scan a whole module — top-level init, every function, and every class body @@ -104,6 +118,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { let mut facts = ModuleDispatchFacts { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: false, + shape_barrier_sites: false, }; note_stmts(&hir.init, &mut facts); @@ -141,11 +156,21 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { } fn note_stmts(stmts: &[Stmt], facts: &mut ModuleDispatchFacts) { - for_each_expr_in_stmts(stmts, &mut |expr| note_prototype_effect(expr, facts)); + for_each_expr_in_stmts(stmts, &mut |expr| { + note_prototype_effect(expr, facts); + if super::ptr_shape::expr_is_shape_barrier(expr) { + facts.shape_barrier_sites = true; + } + }); } fn note_expr_tree(expr: &Expr, facts: &mut ModuleDispatchFacts) { - for_each_expr(expr, &mut |node| note_prototype_effect(node, facts)); + for_each_expr(expr, &mut |node| { + note_prototype_effect(node, facts); + if super::ptr_shape::expr_is_shape_barrier(node) { + facts.shape_barrier_sites = true; + } + }); } /// Record what a single expression node does to some class's prototype. @@ -526,6 +551,7 @@ mod tests { ModuleDispatchFacts { prototype_touched_classes: HashSet::new(), opaque_prototype_mutation: false, + shape_barrier_sites: false, } } diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index 744bee0ca6..0281ac6d92 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -8,10 +8,22 @@ //! emits the cheap part of the guard's contract as inline IR: when the //! monomorphic shape holds (and, for raw-f64 fields, the per-object typed-layout //! intact bit is set), control branches straight to the fast slot load/store, -//! skipping the call. Because every operand is loaded from a loop-invariant -//! receiver, once the surrounding method is inlined (#5092) LLVM LICM can hoist -//! the whole shape check out of the hot loop, collapsing the body to a bare -//! `load`/`fadd`/`store`. +//! skipping the call. +//! +//! NOTE (repsel Phase 3b audit, verified with `--trace llvm` + `opt -O3` on a +//! `this.field`-in-loop method): LLVM LICM does NOT hoist this check out of +//! hot loops. The volatile gate load is never hoistable (volatile ⇒ +//! `!isUnordered`), and — decisively — even a plain or `atomic unordered` +//! gate load stays in the loop because the diamond's own guard-call/fallback +//! arm puts an unknown external call inside the loop body, which +//! clobber-blocks LICM for every load in the check. Per-access cost is +//! therefore paid on every iteration. The hoisted form exists as the #5093 +//! versioned-loop preheader check (`emit_class_field_loop_preheader_check`, +//! sound only for call-free clone bodies), and statically-proven receivers +//! skip the diamond entirely (`collectors/ptr_shape.rs`). Do not "fix" this +//! by de-volatilizing the gate: it buys nothing (the calls still block LICM) +//! and weakens the mid-loop sticky-flip visibility guarantee for loops whose +//! bodies CAN flip the gate through a call. //! //! The inline check is a strict subset of `class_field_fast_contract` (runtime //! `typed_feedback/guards.rs`): if it passes, the guard call would have returned diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 44cf70fee9..c11c524aab 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -9,7 +9,7 @@ use perry_hir::{BinaryOp, Expr, WithSetFallback}; use crate::nanbox::{double_literal, i64_literal, POINTER_MASK_I64}; use crate::type_analysis::is_string_expr; -use crate::types::{DOUBLE, I1, I32, I64, PTR}; +use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; use super::{ emit_root_nanbox_store_on_block, emit_shadow_slot_bind_for_local, emit_string_literal_global, @@ -1400,6 +1400,86 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(if *prefix { new } else { old_num }); } } + // Representation-selection Phase 3b: `o.f++` on a shape-proven + // Ptr local whose field is numeric-proven — bare + // load/fadd/store at the fixed offset, no by-name runtime calls. + // The store keeps the raw-slot plain-finite discipline (an + // Inf-crossing update side-exits to the by-name setter, which + // performs the layout downgrade the GC scan relies on). + if let Expr::LocalGet(recv_id) = object.as_ref() { + if ctx.repsel_context_allows_canonical_i32 { + let fact = ctx.native_facts.shape_proven_ptr_local(*recv_id).cloned(); + if let Some(fact) = fact { + if fact.numeric_fields.contains(property.as_str()) { + if let Some(field_index) = + crate::type_analysis::class_field_global_index( + ctx, + &fact.class_name, + property, + ) + { + let recv_box = lower_expr(ctx, object)?; + let field_idx_str = field_index.to_string(); + let header_skip = crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let (obj_handle, field_ptr, old, new) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let old = blk.load(DOUBLE, &field_ptr); + let new = match op { + BinaryOp::Sub => blk.fsub(&old, "1.0"), + _ => blk.fadd(&old, "1.0"), + }; + (obj_handle, field_ptr, old, new) + }; + let store_idx = ctx.new_block("ptr_shape_update.raw_store"); + let cold_idx = ctx.new_block("ptr_shape_update.downgrade"); + let merge_idx = ctx.new_block("ptr_shape_update.merge"); + let store_label = ctx.block_label(store_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + { + let blk = ctx.block(); + let new_bits = blk.bitcast_double_to_i64(&new); + let finite = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &new_bits); + blk.cond_br(&finite, &store_label, &cold_label); + } + ctx.current_block = store_idx; + { + let blk = ctx.block(); + blk.store(DOUBLE, &new, &field_ptr); + blk.br(&merge_label); + } + ctx.current_block = cold_idx; + { + let key_idx = ctx.strings.intern(property); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_handle = blk.and(I64, &key_bits, POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &new)], + ); + blk.br(&merge_label); + } + ctx.current_block = merge_idx; + return Ok(if *prefix { new } else { old }); + } + } + } + } + } let obj_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 720fa1b01e..d0833fab57 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1301,6 +1301,87 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .map(|(fact, _)| fact.obj_ptr.clone()), _ => None, }; + // Representation-selection Phase 3b: shape-proven + // Ptr local (collectors/ptr_shape.rs). The + // guard diamond is statically proven away — emit the + // bare fixed-offset load: slot reload (the local's + // shadow-bound alloca; rebase-after-safepoint falls + // out of alias analysis) → bitcast → POINTER_MASK → + // gep header → gep index → load. No volatile gate, no + // header checks, no fallback arm, no phi. + let ptr_shape_fact = match object.as_ref() { + Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => { + ctx.native_facts + .shape_proven_ptr_local(*recv_id) + .filter(|fact| fact.class_name == class_name) + .cloned() + } + _ => None, + }; + if let Some(fact) = ptr_shape_fact { + let recv_box = lower_expr(ctx, object)?; + let field_idx_str = field_index.to_string(); + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple) + .to_string(); + let numeric = fact.numeric_fields.contains(property); + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val = blk.load(DOUBLE, &field_ptr); + let (semantic, rep) = if numeric { + (SemanticKind::JsNumber, NativeRep::F64) + } else { + // Bit-identical NaN-boxed value; consumers + // keep generic dispatch (the field may hold + // any value class). + (SemanticKind::JsValue, NativeRep::JsValue) + }; + let fast = LoweredValue { + semantic, + rep, + llvm_ty: DOUBLE, + value: val.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldGet", + None, + "class_field_get.shape_proven_load", + &fast, + Some(BoundsState::Guarded { + guard_id: "ptr_shape_static_proof".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + if numeric { + vec![raw_f64_layout_fact( + None, + "consumed", + "ptr_shape_static_proof", + None, + )] + } else { + Vec::new() + }, + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=ptr_shape_local".to_string(), + format!("numeric_proven={}", numeric), + ], + ); + return Ok(val); + } if let Some(obj_ptr) = loop_fact_ptr { let field_idx_str = field_index.to_string(); let header_skip = diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index a3c7e93d04..eaffd43bbf 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -419,6 +419,71 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(Some(val)); } + // Representation-selection Phase 3b: shape-proven Ptr receiver + // whose field is numeric-proven (every reachable store is a number) — + // bare fixed-offset load, no guard diamond. The numeric proof is what + // licenses handing the raw load to a number context without the + // fallback's `js_number_coerce`; non-numeric-proven fields fall through + // to the guarded path below. + let ptr_shape_numeric = match object.as_ref() { + Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => ctx + .native_facts + .shape_proven_ptr_local(*recv_id) + .map(|fact| fact.class_name == class_name && fact.numeric_fields.contains(property)) + .unwrap_or(false), + _ => false, + }; + if ptr_shape_numeric { + let recv_box = lower_expr(ctx, object)?; + let field_idx_str = field_index.to_string(); + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val = blk.load(DOUBLE, &field_ptr); + let fast = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldGet", + None, + "class_field_get_number.shape_proven_load", + &fast, + Some(BoundsState::Guarded { + guard_id: "ptr_shape_static_proof".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "ptr_shape_static_proof", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=ptr_shape_local".to_string(), + "numeric_proven=true".to_string(), + ], + ); + return Ok(Some(val)); + } + let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index c0dcf720f5..a261413504 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -517,6 +517,146 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(val_double); } } + // Representation-selection Phase 3b: shape-proven + // Ptr receiver (collectors/ptr_shape.rs) — no + // guard call, no shape diamond. Raw-f64 slots keep the + // inline plain-finite value check with a cold + // `js_class_field_set_fallback` arm (a NaN/Inf/boxed + // value must never be stored raw into a scalar-masked + // slot — the runtime setter performs the layout + // downgrade the GC scan relies on). Boxed slots store + // inline with the existing generational write barrier + // for possibly-pointer values. + let ptr_shape_proven = match object.as_ref() { + Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => { + ctx.native_facts + .shape_proven_ptr_local(*recv_id) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false) + } + _ => false, + }; + if ptr_shape_proven { + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple) + .to_string(); + let field_set_barrier_needed = + !expr_produces_non_pointer_bits_by_construction(ctx, value); + let (obj_bits, obj_handle, field_ptr, val_bits) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val_bits = blk.bitcast_double_to_i64(&val_double); + (obj_bits, obj_handle, field_ptr, val_bits) + }; + if requires_raw_f64 { + let store_idx = ctx.new_block("ptr_shape_set.raw_store"); + let cold_idx = ctx.new_block("ptr_shape_set.downgrade"); + let merge_idx = ctx.new_block("ptr_shape_set.merge"); + let store_label = ctx.block_label(store_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + { + let blk = ctx.block(); + let finite = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &val_bits); + blk.cond_br(&finite, &store_label, &cold_label); + } + ctx.current_block = store_idx; + { + // The finite check proved a genuine + // unboxed double (INT32-boxed and every + // NaN-box tag share the all-ones + // exponent) — no canonicalization call, + // no barrier (pointer-free by proof). + let blk = ctx.block(); + blk.store(DOUBLE, &val_double, &field_ptr); + blk.br(&merge_label); + } + ctx.current_block = cold_idx; + { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); + blk.call_void( + "js_class_field_set_fallback", + &[ + (I64, &site_id), + (I64, &obj_bits), + (I64, &key_raw), + (DOUBLE, &val_double), + ], + ); + blk.br(&merge_label); + } + ctx.current_block = merge_idx; + } else { + let blk = ctx.block(); + let field_addr = blk.ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_on_block( + blk, + &field_ptr, + &val_double, + &obj_handle, + &field_idx_str, + true, + &obj_bits, + &field_addr, + field_set_barrier_needed, + ); + } + let (semantic, rep) = if requires_raw_f64 { + (SemanticKind::JsNumber, NativeRep::F64) + } else { + (SemanticKind::JsValue, NativeRep::JsValue) + }; + let stored = LoweredValue { + semantic, + rep, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.shape_proven_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "ptr_shape_static_proof".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + if requires_raw_f64 { + vec![raw_f64_layout_fact( + None, + "consumed", + "ptr_shape_static_proof", + None, + )] + } else { + Vec::new() + }, + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=ptr_shape_local".to_string(), + format!("field_layout_raw_f64={}", requires_raw_f64), + ], + ); + return Ok(val_double); + } // #5334 lever B: oversized modules full-outline the entire // class-field-SET IC diamond (guard + fast store + // fallback) to a single `js_class_field_set_ic(...)` call. diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index f5665c60ed..dafb87799d 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -736,7 +736,11 @@ pub(crate) fn try_lower_instance_method_call( .iter() .find(|method| method.name.as_str() == property) .and_then(|method| { - crate::codegen::typed_f64_receiver_method_info(class, method) + crate::codegen::typed_f64_receiver_method_info( + class, + method, + ctx.classes, + ) }) }); let typed_receiver_direct_name = if typed_receiver_info.is_some() @@ -848,6 +852,90 @@ pub(crate) fn try_lower_instance_method_call( .cloned() .map(|reps| (name.as_str(), reps)) }); + // Representation-selection Phase 3b: shape-proven Ptr + // receiver (collectors/ptr_shape.rs) — direct call with NO + // shape guard and NO own-override probe. Provenance proves + // the receiver's dynamic class is exactly `class_name` + // (subclass overrides cannot apply), the eligibility walk + // vetted every method called on the local (chain-resolvable, + // `this`-flow safe), no own-property write can shadow the + // method (non-declared-field writes disqualify), and + // `prototype_is_stable` held for the chain. + let ptr_shape_receiver = match object { + Expr::LocalGet(recv_id) if ctx.repsel_context_allows_canonical_i32 => ctx + .native_facts + .shape_proven_ptr_local(*recv_id) + .map(|fact| fact.class_name == class_name) + .unwrap_or(false), + _ => false, + }; + if ptr_shape_receiver && !fallback_fn.starts_with("perry_static_") { + // Prefer the typed-receiver clone (bare gep+load field + // access inside the body) when one exists: the receiver + // is proven, so only the ARGUMENT value classes need + // vetting — an inline plain-finite check per argument (no + // calls, no header loads). Any non-plain-double argument + // (NaN-box tag, INT32-boxed, NaN/Inf) falls to the + // generic direct call — still guard-free. + if let Some((typed_fn, typed_formal_count, _info)) = typed_receiver_direct { + let arg_values: Vec = arg_slices + .iter() + .skip(1) + .take(typed_formal_count) + .map(|(_, v)| (*v).to_string()) + .collect(); + let obj_handle = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + blk.and(I64, &obj_bits, crate::nanbox::POINTER_MASK_I64) + }; + let mut all_plain: Option = None; + for v in &arg_values { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(v); + let ok = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &bits); + all_plain = Some(match all_plain { + Some(prev) => ctx.block().and(crate::types::I1, &prev, &ok), + None => ok, + }); + } + let typed_idx = ctx.new_block("ptr_shape_method.typed"); + let generic_idx = ctx.new_block("ptr_shape_method.generic"); + let merge_idx = ctx.new_block("ptr_shape_method.merge"); + let typed_label = ctx.block_label(typed_idx); + let generic_label = ctx.block_label(generic_idx); + let merge_label = ctx.block_label(merge_idx); + match all_plain { + Some(cond) => ctx.block().cond_br(&cond, &typed_label, &generic_label), + None => ctx.block().br(&typed_label), + } + ctx.current_block = typed_idx; + let mut typed_args: Vec<(crate::types::LlvmType, &str)> = + vec![(I64, obj_handle.as_str())]; + for v in &arg_values { + typed_args.push((DOUBLE, v.as_str())); + } + let v_typed = ctx.block().call(DOUBLE, typed_fn, &typed_args); + let typed_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = generic_idx; + let v_generic = ctx.block().call(DOUBLE, &fallback_fn, &arg_slices); + let generic_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; + let merged = ctx.block().phi( + DOUBLE, + &[ + (v_typed.as_str(), &typed_end), + (v_generic.as_str(), &generic_end), + ], + ); + return Ok(Some(merged)); + } + let direct = ctx.block().call(DOUBLE, &fallback_fn, &arg_slices); + return Ok(Some(direct)); + } if let Some(guarded) = emit_guarded_direct_method_call( ctx, &recv_box, diff --git a/crates/perry-codegen/src/type_analysis/predicates.rs b/crates/perry-codegen/src/type_analysis/predicates.rs index 8612d2c8b8..0d922e95c4 100644 --- a/crates/perry-codegen/src/type_analysis/predicates.rs +++ b/crates/perry-codegen/src/type_analysis/predicates.rs @@ -249,6 +249,23 @@ pub(crate) fn receiver_is_error_type(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// pick the right `perry_method__` function. pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { match e { + // Representation-selection Phase 3b: a shape-proven Ptr local + // (or one of its const aliases — the exact-receiver inliner's + // `__cmpd_base_N` receivers are typed `Any`) has a provenance-exact + // class the declared type may not name. The proof is stronger than a + // declaration: the local holds exactly one `new ` for its + // whole lifetime (collectors/ptr_shape.rs), so class-keyed dispatch + // (field offsets, method resolution) is authoritative for it. + Expr::LocalGet(id) + if !matches!( + ctx.local_types.get(id), + Some(HirType::Named(_)) | Some(HirType::Generic { .. }) + ) && ctx.native_facts.shape_proven_ptr_local(*id).is_some() => + { + ctx.native_facts + .shape_proven_ptr_local(*id) + .map(|fact| fact.class_name.clone()) + } Expr::LocalGet(id) => match ctx.local_types.get(id)? { HirType::Named(name) => Some(name.clone()), // Generic instantiation `SimpleContainer`: prefer the diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 71ed431665..2fb4152e58 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -12584,9 +12584,14 @@ fn scalar_replaced_numeric_method_with_local_temps_inlines_without_dispatch_or_a fn scalar_method_local_temp_rejects_mutable_binding() { let module = scalar_method_numeric_local_temp_module("mutable", true); let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + // The scalar-method summary must reject the mutable temp (no inline — + // asserted on the artifact below). Representation-selection Phase 3b may + // still lower the call as a DIRECT dispatch to the resolved method on the + // shape-proven heap receiver; either dispatch form is a real method call. assert!( - ir.contains("call double @js_native_call_method"), - "mutable local temp must keep dynamic method dispatch fallback:\n{ir}" + ir.contains("call double @js_native_call_method") + || ir.contains("call double @perry_method_"), + "mutable local temp must dispatch to the real method (dynamic tower or direct):\n{ir}" ); assert!( ir.contains("call i64 @js_object_alloc"), @@ -12660,10 +12665,26 @@ fn scalar_method_boolean_predicate_rejects_mutation_call_accessor_and_dynamic_pr ] { let module = scalar_method_boolean_negative_module(case); let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); - assert!( - ir.contains("call double @js_native_call_method"), - "{case} must keep dynamic method dispatch fallback:\n{ir}" - ); + if case == "mutation" { + // Representation-selection Phase 3b: a method that WRITES a + // declared `this` field is (correctly) rejected by the + // scalar-method summary — this test's original point — but is + // legal for a shape-proven Ptr receiver, so the call now + // lowers to a DIRECT dispatch to the resolved method on the + // still-heap-allocated receiver (no dynamic tower, no shape + // guard). The receiver must stay heap-allocated either way. + assert!( + ir.contains( + "call double @perry_method_scalar_method_boolean_reject_mutation_ts__Point__isAbove" + ), + "mutation must dispatch directly to the resolved method on the heap receiver:\n{ir}" + ); + } else { + assert!( + ir.contains("call double @js_native_call_method"), + "{case} must keep dynamic method dispatch fallback:\n{ir}" + ); + } assert!( ir.contains("call i64 @js_object_alloc"), "{case} must keep heap allocation fallback for the receiver:\n{ir}" @@ -13130,9 +13151,13 @@ fn scalar_method_int32_bitwise_rejects_unproven_or_unsigned_shapes() { ), ] { let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + // Same Phase 3b caveat as the mutable-temp test: the scalar Int32 + // summary must reject (artifact assert below), but the call may + // lower as a direct resolved-method dispatch on the heap receiver. assert!( - ir.contains("call double @js_native_call_method"), - "{case} must keep dynamic method dispatch fallback:\n{ir}" + ir.contains("call double @js_native_call_method") + || ir.contains("call double @perry_method_"), + "{case} must dispatch to the real method (dynamic tower or direct):\n{ir}" ); assert!( ir.contains("call i64 @js_object_alloc"), diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index dd5a868962..8c85a77225 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -997,6 +997,15 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // Representation-selection Phase 3b — shape-proven Ptr locals: + // `=0`/`off`/`false` reverts proven object locals from bare fixed-offset + // access (no guard diamond, unguarded direct method calls) back to the + // guarded class-field path, which changes the emitted IR / .o bytes — a + // warm cache must not serve an object built under the other setting. + h.field( + "env_ptr_shape_locals", + env_var("PERRY_PTR_SHAPE_LOCALS").as_deref().unwrap_or(""), + ); // FEAT_JSCVT ToInt32 (`fjcvtzs` on apple-arm64): flipping it changes // every `toint32_wrap` emission site's IR, so it must key the cache. h.field("env_jscvt", env_var("PERRY_JSCVT").as_deref().unwrap_or("")); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 1e5401c67c..b2cd9ecef0 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -622,6 +622,8 @@ fn key_changes_with_codegen_env_vars() { // Representation-selection Phase 2: specialized calling convention. "PERRY_SPECIALIZED_ABI", "PERRY_SPECIALIZED_ABI_MAX", + // Representation-selection Phase 3b: shape-proven Ptr locals. + "PERRY_PTR_SHAPE_LOCALS", // FEAT_JSCVT single-instruction ToInt32 (apple-arm64). "PERRY_JSCVT", ] { diff --git a/test-files/test_gap_repsel_ptr_shape_barriers.ts b/test-files/test_gap_repsel_ptr_shape_barriers.ts new file mode 100644 index 0000000000..0725ca1bf4 --- /dev/null +++ b/test-files/test_gap_repsel_ptr_shape_barriers.ts @@ -0,0 +1,90 @@ +// Representation-selection Phase 3b: §5.2 soundness barriers. +// Every construct here DISQUALIFIES Ptr promotion (module-wide under +// the first-increment conservative rule — any defineProperty / delete / +// setPrototypeOf / Proxy / mutating-Reflect site disables promotion for the +// whole module); the observable behavior must remain byte-exact vs Node — +// the guarded/boxed paths handle the dynamic shape ops. +// +// (Lone `__proto__` writes and strict-mode throw-on-non-writable assignment +// are pre-existing categorical gaps unrelated to this phase and are not +// exercised here.) + +class Cfg { + host: string; + port: number; + constructor(host: string, port: number) { + this.host = host; + this.port = port; + } + url(): string { + return this.host + ":" + this.port; + } +} + +// 1. Object.defineProperty converts a data field into an accessor — the read +// AFTER it must observe the getter, not a stale fixed-offset slot. +function defineProp(): string { + const c = new Cfg("localhost", 8080); + let acc = 0; + for (let i = 0; i < 20; i++) acc += c.port; + Object.defineProperty(c, "port", { + get() { + return 9999; + }, + }); + return acc + ":" + c.port + ":" + c.url(); +} +console.log(defineProp()); + +// 2. delete removes an own field — reads fall through to undefined. +function deleteField(): string { + const c: any = new Cfg("a", 1); + let acc = 0; + for (let i = 0; i < 10; i++) acc += c.port; + delete c.port; + return acc + ":" + String(c.port) + ":" + ("port" in c); +} +console.log(deleteField()); + +// 3. setPrototypeOf swaps the prototype — a data-property lookup through the +// NEW prototype must be observed after the swap. +function protoData(): string { + const c = new Cfg("h", 2); + const before = (c as any).bonus; + Object.setPrototypeOf(c, { bonus: 42 }); + return String(before) + "->" + String((c as any).bonus) + ":" + c.host; +} +console.log(protoData()); + +// 4. Reflect.defineProperty (mutating Reflect) makes a builder field +// non-writable; Reflect.set reports the rejected write without throwing. +function reflectDefine(): string { + const b: any = {}; + b.a = 1; + Reflect.defineProperty(b, "a", { value: 77, writable: false }); + const ok = Reflect.set(b, "a", 100); + return b.a + ":" + ok + ":" + JSON.stringify(b); +} +console.log(reflectDefine()); + +// 5. Alias that escapes through a container: the object is reachable from +// outside, so shape mutation through the alias must be observed. +const registry: any[] = []; +function aliasEscape(): string { + const c = new Cfg("x", 3); + registry.push(c); + let acc = 0; + for (let i = 0; i < 10; i++) acc += c.port; + mutateRegistry(); + return acc + ":" + c.port; +} +function mutateRegistry(): void { + for (const o of registry) { + Object.defineProperty(o, "port", { + get() { + return -1; + }, + }); + } +} +console.log(aliasEscape()); diff --git a/test-files/test_gap_repsel_ptr_shape_locals.ts b/test-files/test_gap_repsel_ptr_shape_locals.ts new file mode 100644 index 0000000000..1a24b6b2d4 --- /dev/null +++ b/test-files/test_gap_repsel_ptr_shape_locals.ts @@ -0,0 +1,216 @@ +// Representation-selection Phase 3b: shape-proven object locals +// (PERRY_PTR_SHAPE_LOCALS, RFC docs/representation-selection-rfc.md §5.5-§5.7). +// +// Exercises the Ptr promotion seams against Node: +// - a provenance-proven `new C(...)` local: guard-free field reads in a hot +// loop, field writes (scalar and pointer-valued), direct method calls, +// - safepoints inside the loop (allocation + call) so the tagged-at-rest +// slot must be re-derived after each safepoint (GC may move the object), +// - an anon-shape record literal ({key, value} — the #6904 reporting shape), +// - the builder pattern (`const b = {}; b.a = …`), +// - exclusions that must stay byte-exact on the boxed/guarded protocol: +// reassigned locals, closure-referenced locals, escaping locals. + +// 1. Provenance-proven class instance: field sum loop + writes + method calls. +class Pt { + x: number; + y: number; + tag: string; + constructor(x: number, y: number, tag: string) { + this.x = x; + this.y = y; + this.tag = tag; + } + norm(): number { + return this.x * this.x + this.y * this.y; + } + label(): string { + return this.tag + ":" + (this.x + this.y); + } +} + +function fieldSum(n: number): string { + const o = new Pt(1.5, 2.25, "p"); + let acc = 0; + for (let i = 0; i < n; i++) { + acc += o.x + o.y; + } + o.x = acc / n; + o.y = o.norm(); + o.tag = o.label(); + return o.tag + "|" + o.x + "|" + o.y; +} +console.log(fieldSum(1000)); + +// 2. Safepoints in the loop body: allocation pressure forces minor GCs while +// the proven local is live — the object moves, the slot is rewritten, and +// every post-safepoint access must re-derive the pointer. +class Acc { + total: number; + count: number; + constructor() { + this.total = 0; + this.count = 0; + } + add(v: number): void { + this.total += v; + this.count++; + } +} + +function churn(n: number): string { + const a = new Acc(); + for (let i = 0; i < n; i++) { + const garbage = new Array(64).fill(i); // allocation safepoint + a.add(garbage[i % 64]); + if (i % 97 === 0) { + a.total = a.total + garbage.length; + } + } + return a.total + "/" + a.count; +} +console.log(churn(5000)); + +// 3. Anon-shape record literals (issue #6904's reporting shape). +function records(): string { + const src: [string, number][] = [ + ["alpha", 3], + ["beta", 1], + ["gamma", 4], + ["delta", 1], + ]; + let out = ""; + for (const [k, v] of src) { + const rec = { key: k, value: v }; + rec.value = rec.value * 2 + rec.key.length; + out += rec.key + "=" + rec.value + ";"; + } + return out; +} +console.log(records()); + +// 4. Builder pattern (`{}`-site class; object-write matrix w15 residual). +function builder(): string { + const b: { [k: string]: number } = {}; + b.first = 1; + b.second = 2; + b.third = 3; + let s = 0; + for (let i = 0; i < 100; i++) { + s += b.first + b.second + b.third; + } + return s + ":" + JSON.stringify(b); +} +console.log(builder()); + +// 5. Reassigned local: NOT provenance-stable, must stay on the guarded path. +function reassigned(flag: boolean): number { + let o = new Pt(1, 2, "a"); + if (flag) { + o = new Pt(10, 20, "b"); + } + let acc = 0; + for (let i = 0; i < 10; i++) acc += o.x + o.y; + return acc; +} +console.log(reassigned(true), reassigned(false)); + +// 6. Closure-referenced local: excluded (capture machinery stays boxed). +function capturedObj(): number { + const o = new Pt(3, 4, "c"); + const f = () => o.norm(); + let acc = 0; + for (let i = 0; i < 5; i++) acc += o.x; + return acc + f(); +} +console.log(capturedObj()); + +// 7. Escaping local (returned): escape_news must reject it. +function escapes(): Pt { + const o = new Pt(5, 6, "d"); + o.x += 1; + return o; +} +const escaped = escapes(); +console.log(escaped.x, escaped.y, escaped.label()); + +// 8. Two proven locals of the same class alive at once (distinct identities). +function twoLocals(): string { + const p = new Pt(1, 1, "p"); + const q = new Pt(2, 2, "q"); + let acc = 0; + for (let i = 0; i < 50; i++) { + acc += p.x + q.y; + } + p.x = q.x; + return acc + ":" + p.x + ":" + q.label(); +} +console.log(twoLocals()); + +// 9. Proven local passed BY FIELD VALUE to boxed consumers (materialization +// at the boundary): console.log observes exact values. +function boundary(): void { + const o = new Pt(0.1, 0.2, "b"); + console.log(o.x + o.y); // classic 0.30000000000000004 + console.log(o.norm()); + const arr = [o.x, o.y]; + console.log(arr.join(",")); +} +boundary(); + +// 10. Extends chain (target-1 widening): parent field offsets are chain- +// global; the straight-line method earns a typed-receiver clone. +class BasePos { + offset: number; + constructor(offset: number) { + this.offset = offset; + } +} +class Scaler extends BasePos { + factor: number; + constructor(offset: number, factor: number) { + super(offset); + this.factor = factor; + } + apply(x: number): number { + return x * this.factor + this.offset; + } +} +function chainRun(n: number): string { + const s = new Scaler(3, 1.5); + let acc = 0; + for (let i = 0; i < n; i++) { + acc += s.apply(i); + } + s.offset = acc / n; + return acc + ":" + s.offset + ":" + s.factor + ":" + s.apply(2); +} +console.log(chainRun(1000)); + +// 11. Internal `this.m(...)` argument sites: `set` is called externally with +// a number AND internally (via poke) with a string smuggled through an +// `any` param. The callee's params must stay numeric-unproven, so reads +// after the internal call observe the string exactly. +class Cnt { + total: number; + constructor() { + this.total = 0; + } + set(v: number): void { + this.total = v; + } + poke(x: any): void { + this.set(x); + } +} +function internalPoison(): string { + const c = new Cnt(); + c.set(5); + let acc = 0; + for (let i = 0; i < 8; i++) acc += c.total; + c.poke("12" as any); // numeric STRING: ToNumber("12")=12, raw bits != 12 + const t = Math.trunc(c.total as any); // spec ToNumber("12") -> 12 + const after = c.total; + return acc + ":" + t + ":" + after + ":" + typeof after; +} +console.log(internalPoison());