diff --git a/changelog.d/8029-method-return-shapes.md b/changelog.d/8029-method-return-shapes.md new file mode 100644 index 0000000000..20817318d2 --- /dev/null +++ b/changelog.d/8029-method-return-shapes.md @@ -0,0 +1,11 @@ +### Representation selection: preserve fresh shapes returned by methods (#7170 R2) + +Native compilation now propagates proven fresh return shapes through instance +method calls when the receiver has an exact contained shape and its prototype +dispatch is stable. Results can use guard-free fixed-offset field access just +like values returned by direct function calls. + +The proof stays fail-closed for decorated classes, mutable prototype dispatch, +unproven or escaping receivers, and modules containing shape barriers. The +optimization report also classifies allocations served by method return-shape +facts separately from the remaining rule-1 provenance wall. diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index b323824185..517ceb7462 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -371,9 +371,12 @@ pub(super) fn compile_method( let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); // `--opt-report` (#6952) attribution scope; no-op when off. - let _opt_report_scope = crate::opt_report::enter_region( + let _opt_report_scope = crate::opt_report::enter_method_region( &format!("{}.{}", class.name, method.name), - crate::opt_report::RegionKind::Method, + cross_module + .module_dispatch + .return_shape_method_class(&class.name, &method.name, method.id) + .is_some(), ); let native_facts = crate::collectors::collect_native_region_fact_graph( &method.body, diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 68a24165c1..239b2ba081 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -339,13 +339,15 @@ pub(crate) fn collect_shape_proven_ptr_locals( let mut candidates = report::candidate_seeds(stmts, boxed_vars, module_globals, &preamble); // #7034 §4: `const r = producer(...)` where `producer` carries a // return-shape fact is provenance of `new`-strength (module doc, rule 1). - let return_seeded = super::ptr_shape_returns::find_return_shape_candidates( + let return_seeds = super::ptr_shape_returns::find_return_shape_candidates( stmts, boxed_vars, module_globals, + classes, module_dispatch, &mut candidates, ); + let return_seeded = &return_seeds.seeded; // #7034 §3: `const r = A[i]` at an in-bounds site on an element-shape- // proven local array is provenance of `new C(...)` strength (module doc, // rule 2's array-element exception). The seeds are already filtered for @@ -418,7 +420,7 @@ pub(crate) fn collect_shape_proven_ptr_locals( const_local_inits: HashMap::new(), disq_reasons: HashMap::new(), escape_ctx: report::ESC_BARE_REFERENCE, - return_seeded: &return_seeded, + return_seeded, element_seeded: &element_seeded, element_facts, in_closure: false, @@ -628,6 +630,42 @@ pub(crate) fn collect_shape_proven_ptr_locals( } } } + // #7170 R2: a dynamic method call names one implementation only while + // its receiver keeps the exact shape/containment fact that licensed the + // resolution. Candidate discovery runs before the full use, constructor, + // and method-body proofs, so enforce that dependency after every ordinary + // rejection (including element-group all-or-nothing). Iterate to a + // fixpoint for chains such as `a.makeB().makeC()` represented by bound + // intermediate locals. + loop { + let doomed_roots: Vec = return_seeds + .method_receivers + .iter() + .filter_map(|(result, receiver)| { + (out.contains_key(result) && !out.contains_key(receiver)).then_some(*result) + }) + .collect(); + if doomed_roots.is_empty() { + break; + } + for root in doomed_roots { + let doomed: Vec = roots + .iter() + .filter_map(|(member, member_root)| (*member_root == root).then_some(*member)) + .collect(); + for member in doomed { + if out.remove(&member).is_some() { + report::deny_local( + member, + &names, + &depths, + candidates.get(&member).map(String::as_str), + report::RETURN_METHOD_RECEIVER_UNPROVEN, + ); + } + } + } + } out } @@ -737,6 +775,21 @@ pub(super) fn chain_field_names(chain: &[&Class]) -> HashSet { out } +/// Whether one class in the resolved chain declares the same instance method +/// name more than once. Overrides in different classes are intentional and +/// remain resolvable by the prototype chain; duplicate declarations within a +/// single class are different because JavaScript selects the last declaration +/// while several Perry symbol/collector paths still select the first. +pub(super) fn chain_has_duplicate_method_names(chain: &[&Class]) -> bool { + chain.iter().any(|class| { + let mut names = HashSet::new(); + class + .methods + .iter() + .any(|method| !names.insert(method.name.as_str())) + }) +} + /// name -> (owning class name, method function), first (most-derived) wins — /// matching JS prototype-chain resolution for an exact-class instance. pub(super) fn chain_method_map<'a>( @@ -895,13 +948,12 @@ impl<'a> UseWalk<'a> { // is the CALL. It records no `new_args` — the constructor // ran in the callee, so the numeric-field proof stands // down for these candidates entirely (see the `'cand` - // loop). The argument expressions are ordinary values; - // walk them so OTHER candidates passed there still escape. + // loop). Walk the complete call so OTHER candidates passed + // as arguments still escape and a tracked method receiver + // records the call for pass 3's `this`-flow audit. if self.return_seeded.contains(id) { - if let Some(Expr::Call { args, .. }) = init.as_ref() { - for a in args { - self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); - } + if let Some(call @ Expr::Call { .. }) = init.as_ref() { + self.walk_expr(call); return; } } diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs index 9ef3d2d055..1c826a2d62 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -520,6 +520,32 @@ fn a_return_in_a_return_shape_producer_is_reported_as_served() { assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served)); } +/// #7170 R2 extends the same report correction to instance-method producers. +/// A served allocation must not reappear in the rule-1 wall merely because +/// codegen labels its region `method` rather than `function`. +#[test] +fn a_return_in_a_method_shape_producer_is_reported_as_served() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + let stmts = vec![Stmt::Return(Some(new_c()))]; + + let session = Session::start(); + let guard = crate::opt_report::enter_method_region("Factory.make", true); + let _ = run(&stmts, &classes); + drop(guard); + let entries = session.entries(); + + let rows = alloc_rows(&entries); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].rule.as_deref(), + Some("rule 1 (provenance) — already served by return-shape") + ); + assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served)); + assert_eq!(rows[0].region, crate::opt_report::RegionKind::Method); +} + /// The same site in a function WITHOUT a return-shape fact stays an ordinary /// rule-1 denial. This is the anti-vacuity half: a classifier that answers /// "served" unconditionally passes the test above and fails this one. diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs index 3e3517d369..28232bb3ea 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_report.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -126,6 +126,15 @@ pub(super) const ALIAS_NOT_SINGLE_LET: ShapeDenial = ShapeDenial { issue: None, }; +pub(super) const RETURN_METHOD_RECEIVER_UNPROVEN: ShapeDenial = ShapeDenial { + rule: RULE1, + reason: "initialized by a fresh-returning method call, but the receiver's \ + exact shape or contained dispatch proof did not survive the full \ + region analysis.", + tier: Tier::Fixable, + issue: Some("#7170 R2"), +}; + /// #7112: `find_new_candidates` excludes cell-backed locals before the /// containment walk, so without an entry they look indistinguishable from /// values the analysis never considered. diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs index 18ef7f2522..08d3ca23a9 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_returns.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs @@ -66,6 +66,13 @@ //! `return flag && new C()`, or an expression with a non-fresh/disagreeing //! result — yields no fact. //! +//! #7170 R2 also applies the same producer proof to declared instance methods. +//! A method result is a caller-side seed only when the receiver is itself an +//! exact shape-proven local, the method name resolves unambiguously on that +//! class chain, and the module-wide prototype-stability proof holds. The +//! result fact depends on the receiver fact: if the receiver later fails the +//! full containment/`this`-flow proof, the result is removed too. +//! //! ## Why the producer must not fall off its end //! //! `function f(x) { if (x) return new C(); }` returns `undefined` on the other @@ -102,7 +109,10 @@ use std::collections::{HashMap, HashSet}; use perry_hir::types::Type; use perry_hir::{Class, Expr, LogicalOp, Module, Stmt}; -use super::ptr_shape::{chain_admissible, ptr_shape_locals_enabled}; +use super::ptr_shape::{ + chain_admissible, chain_classes, chain_field_names, chain_has_duplicate_method_names, + chain_method_map, ptr_shape_locals_enabled, +}; use super::ptr_shape_report as report; use super::ModuleDispatchFacts; @@ -248,6 +258,90 @@ pub(crate) fn collect_return_shape_functions( out } +/// Declared instance methods whose body returns one fresh exact class on every +/// path, keyed by the method implementation that owns the body. +/// +/// The method name is resolved separately from the receiver's exact class at +/// the call site. Keying the producer fact by `(owner class, method name, +/// FuncId)` prevents equal numeric ids in unrelated declarations (including +/// accessors) from aliasing. A key claimed by two bodies is dropped entirely: +/// transformed HIR can duplicate ids, and attributing either body by walk +/// order would make a guard-free load depend on an arbitrary choice. +pub(crate) fn collect_return_shape_methods( + facts: &ModuleDispatchFacts, + hir: &Module, +) -> HashMap<(String, String, u32), String> { + let mut out = HashMap::new(); + if !ptr_shape_locals_enabled() || facts.has_shape_barrier_sites() { + return out; + } + let classes: HashMap = hir + .classes + .iter() + .map(|class| (class.name.clone(), class)) + .collect(); + let mut claims: HashMap<(String, String, u32), usize> = HashMap::new(); + let mut proven = Vec::new(); + + for class in &hir.classes { + if class_has_legacy_decorators(class) { + continue; + } + for method in &class.methods { + let key = (class.name.clone(), method.name.clone(), method.id); + *claims.entry(key.clone()).or_insert(0) += 1; + let view = ProducerBody { + boxed_or_resumable: method.is_async + || method.is_generator + || method.was_plain_async, + return_type: &method.return_type, + body: &method.body, + }; + if let Some(class_name) = producer_return_class(&view, &classes, facts) { + proven.push((key, class_name)); + } + } + } + for (key, class_name) in proven { + if claims.get(&key) == Some(&1) { + out.insert(key, class_name); + } + } + out +} + +/// Legacy decorators execute arbitrary user code at class-definition time. +/// A decorator imported from another module can rewrite a prototype without +/// leaving a `.prototype` expression in this module for rule 4 to observe, so +/// decorated classes cannot participate in static method-return resolution. +fn class_has_legacy_decorators(class: &Class) -> bool { + let function_has_decorators = |function: &perry_hir::Function| { + !function.decorators.is_empty() + || function + .params + .iter() + .any(|param| !param.decorators.is_empty()) + }; + !class.decorators.is_empty() + || class + .fields + .iter() + .chain(class.static_fields.iter()) + .any(|field| !field.decorators.is_empty()) + || class + .constructor + .as_ref() + .is_some_and(|constructor| function_has_decorators(constructor)) + || class + .methods + .iter() + .chain(class.static_methods.iter()) + .chain(class.getters.iter().map(|(_, function)| function)) + .chain(class.setters.iter().map(|(_, function)| function)) + .chain(class.computed_members.iter().map(|member| &member.function)) + .any(function_has_decorators) +} + /// Export name -> anonymous-record class for source functions whose final HIR /// carries a return-shape fact. /// @@ -723,14 +817,24 @@ fn walk_stmts<'a>(stmts: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { /// Mirrors `find_new_candidates`' shape exactly — same exclusions (boxed, /// module-global), same nesting, and no descent into closure bodies (each is /// its own region). +pub(super) struct ReturnShapeSeeds { + pub(super) seeded: HashSet, + /// Result local -> exact receiver local whose final shape proof licenses + /// the method dispatch. The parent collector enforces these dependencies + /// after all ordinary candidate and element-group rejections have run. + pub(super) method_receivers: HashMap, +} + pub(crate) fn find_return_shape_candidates( stmts: &[Stmt], boxed_vars: &HashSet, module_globals: &HashMap, + classes: &HashMap, module_dispatch: &ModuleDispatchFacts, candidates: &mut HashMap, -) -> HashSet { +) -> ReturnShapeSeeds { let mut seeded = HashSet::new(); + let mut method_receivers = HashMap::new(); walk_stmts(stmts, &mut |s| { let Stmt::Let { id, @@ -743,17 +847,62 @@ pub(crate) fn find_return_shape_candidates( if boxed_vars.contains(id) || module_globals.contains_key(id) { return; } + let mut method_receiver = None; let class_name = match callee.as_ref() { - Expr::ExternFuncRef { name, .. } => module_dispatch.imported_return_shape_class(name), + Expr::ExternFuncRef { name, .. } => module_dispatch + .imported_return_shape_class(name) + .map(str::to_string), + Expr::PropertyGet { + object, property, .. + } => { + let Expr::LocalGet(receiver_id) = object.as_ref() else { + return; + }; + let Some(receiver_class) = candidates.get(receiver_id).cloned() else { + return; + }; + if !module_dispatch.prototype_is_stable(classes, &receiver_class) { + return; + } + let chain = chain_classes(classes, &receiver_class); + if chain.iter().any(|class| class_has_legacy_decorators(class)) { + return; + } + if chain_has_duplicate_method_names(&chain) { + return; + } + if chain_field_names(&chain).contains(property) { + return; + } + let methods = chain_method_map(&chain); + let Some((owner_class, method)) = methods.get(property) else { + return; + }; + let Some(class_name) = module_dispatch + .return_shape_method_class(owner_class, property, method.id) + .map(str::to_string) + else { + return; + }; + method_receiver = Some(*receiver_id); + Some(class_name) + } _ => callee_names_one_function(callee, module_dispatch) - .and_then(|func_id| module_dispatch.return_shape_class(func_id)), + .and_then(|func_id| module_dispatch.return_shape_class(func_id)) + .map(str::to_string), }; if let Some(class_name) = class_name { - candidates.insert(*id, class_name.to_string()); + candidates.insert(*id, class_name); seeded.insert(*id); + if let Some(receiver_id) = method_receiver { + method_receivers.insert(*id, receiver_id); + } } }); - seeded + ReturnShapeSeeds { + seeded, + method_receivers, + } } /// The one statically-known function a callee expression names, or `None`. diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs index 9a37c2a998..19b172d0cc 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs @@ -9,7 +9,7 @@ use super::*; use crate::collectors::PtrShapeLocal; use perry_hir::types::{FuncId, Type}; -use perry_hir::{ClassField, Function, Param}; +use perry_hir::{ClassField, Decorator, Function, Param}; fn field(name: &str) -> ClassField { ClassField { @@ -286,6 +286,280 @@ fn call_to_a_return_shape_producer_is_provenance() { ); } +fn returning_method_class(class_name: &str, method_id: u32) -> Class { + let mut class = class_c(); + class.id = 2; + class.name = class_name.to_string(); + class.fields.clear(); + class.methods = vec![function( + method_id, + "make", + vec![Stmt::Return(Some(new_c()))], + )]; + class +} + +fn method_call_result(receiver_id: u32, result_id: u32) -> Stmt { + Stmt::Let { + id: result_id, + name: "result".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(receiver_id)), + property: "make".to_string(), + byte_offset: 0, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + } +} + +/// #7170 R2: a fresh-returning instance method is the same producer proof as +/// a function, but its consumer additionally depends on exact receiver shape +/// and stable prototype dispatch. +/// +/// Sabotage: remove `collect_return_shape_methods` or the `PropertyGet` callee +/// arm in `find_return_shape_candidates`; the result loses its fact. +#[test] +fn method_return_shape_is_provenance_on_a_proven_receiver() { + let maker = returning_method_class("Maker", 60); + let (facts, c) = facts_for_classes(vec![maker.clone()], Vec::new()); + assert_eq!( + facts.return_shape_method_class("Maker", "make", 60), + Some("C") + ); + + let classes = HashMap::from([("C".to_string(), &c), ("Maker".to_string(), &maker)]); + let caller = vec![ + Stmt::Let { + id: 1, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Maker".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + method_call_result(1, 2), + store_x(2), + ]; + let promoted = promote(&caller, &classes, &facts); + assert!( + promoted.contains_key(&1), + "the exact receiver proof must survive" + ); + let result = promoted + .get(&2) + .expect("the method result must be a Ptr candidate"); + assert_eq!(result.class_name, "C"); + assert!( + result.numeric_fields.is_empty(), + "a method-seeded candidate cannot see producer-side stores" + ); +} + +/// The method result depends on the receiver's FINAL proof, not merely its +/// initial `new` seed. A later bare reference aliases the receiver and must +/// remove both facts. +/// +/// Sabotage: delete the method-receiver fixpoint at the end of +/// `collect_shape_proven_ptr_locals`; the result incorrectly survives. +#[test] +fn method_result_is_dropped_when_its_receiver_proof_fails() { + let maker = returning_method_class("Maker", 61); + let (facts, c) = facts_for_classes(vec![maker.clone()], Vec::new()); + let classes = HashMap::from([("C".to_string(), &c), ("Maker".to_string(), &maker)]); + let caller = vec![ + Stmt::Let { + id: 1, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Maker".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + method_call_result(1, 2), + store_x(2), + Stmt::Expr(Expr::LocalGet(1)), + ]; + let promoted = promote(&caller, &classes, &facts); + assert!(!promoted.contains_key(&1)); + assert!( + !promoted.contains_key(&2), + "the result cannot outlive the exact receiver proof that resolved its callee" + ); +} + +/// The seeded result's call remains part of the receiver's use walk. Skipping +/// the callee while handling `const result = maker.make()` would omit `make` +/// from `method_calls`, allowing an unsafe `this` escape in the producer and +/// leaving both the receiver and its dependent result incorrectly promoted. +#[test] +fn method_return_call_audits_the_receivers_this_flow() { + let mut maker = returning_method_class("Maker", 64); + maker.methods[0].body = vec![Stmt::Expr(Expr::This), Stmt::Return(Some(new_c()))]; + let (facts, c) = facts_for_classes(vec![maker.clone()], Vec::new()); + assert_eq!( + facts.return_shape_method_class("Maker", "make", 64), + Some("C"), + "fresh return provenance is independent of receiver containment" + ); + + let classes = HashMap::from([("C".to_string(), &c), ("Maker".to_string(), &maker)]); + let caller = vec![ + Stmt::Let { + id: 1, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Maker".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + method_call_result(1, 2), + store_x(2), + ]; + let promoted = promote(&caller, &classes, &facts); + assert!( + !promoted.contains_key(&1), + "the called method's bare `this` must disqualify its receiver" + ); + assert!( + !promoted.contains_key(&2), + "the method result must be dropped when its receiver proof fails" + ); +} + +/// Duplicate method declarations in one class use last-declaration semantics +/// in JavaScript. Until Perry's symbol and dispatch paths agree on that rule, +/// a first-declaration return fact must not license the call result. +#[test] +fn method_return_shape_refuses_duplicate_method_declarations() { + let mut maker = returning_method_class("Maker", 65); + maker.methods.push(function( + 66, + "make", + vec![Stmt::Return(Some(Expr::LocalGet(999)))], + )); + let (facts, c) = facts_for_classes(vec![maker.clone()], Vec::new()); + assert_eq!( + facts.return_shape_method_class("Maker", "make", 65), + Some("C"), + "the test requires a tempting fact on the first declaration" + ); + + let classes = HashMap::from([("C".to_string(), &c), ("Maker".to_string(), &maker)]); + let caller = vec![ + Stmt::Let { + id: 1, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Maker".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + method_call_result(1, 2), + store_x(2), + ]; + assert!( + !promote(&caller, &classes, &facts).contains_key(&2), + "duplicate declarations must keep return-shape dispatch fail-closed" + ); +} + +/// Merely declaring a receiver type does not establish its exact dynamic +/// class. Likewise, naming a prototype anywhere makes dispatch mutable. Both +/// cases must remain on the guarded protocol. +#[test] +fn method_return_shape_refuses_unproven_or_unstable_receivers() { + let maker = returning_method_class("Maker", 62); + let (facts, c) = facts_for_classes(vec![maker.clone()], Vec::new()); + let classes = HashMap::from([("C".to_string(), &c), ("Maker".to_string(), &maker)]); + let unproven = vec![ + Stmt::Let { + id: 1, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::Undefined), + }, + method_call_result(1, 2), + store_x(2), + ]; + assert!(!promote(&unproven, &classes, &facts).contains_key(&2)); + + let mut hir = Module::new("unstable"); + hir.classes = vec![c.clone(), maker.clone()]; + hir.init = vec![Stmt::Expr(Expr::PropertyGet { + object: Box::new(Expr::ClassRef("Maker".to_string())), + property: "prototype".to_string(), + byte_offset: 0, + })]; + let unstable = super::super::collect_module_dispatch_facts(&hir); + let proven_receiver = vec![ + Stmt::Let { + id: 3, + name: "maker".to_string(), + ty: Type::Named("Maker".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Maker".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + method_call_result(3, 4), + store_x(4), + ]; + assert!( + !promote(&proven_receiver, &classes, &unstable).contains_key(&4), + "a mutable prototype must prevent static method resolution" + ); +} + +/// A legacy decorator is arbitrary code and may replace `Maker.prototype.make` +/// from another module, beyond this module's prototype-expression scan. +#[test] +fn decorated_method_class_carries_no_return_shape_fact() { + let mut maker = returning_method_class("Maker", 63); + maker.methods[0].decorators.push(Decorator { + name: "replace".to_string(), + args: Vec::new(), + is_factory: false, + is_reflect_metadata: false, + }); + let (facts, _) = facts_for_classes(vec![maker], Vec::new()); + assert_eq!( + facts.return_shape_method_class("Maker", "make", 63), + None, + "arbitrary decorator code must keep method dispatch fail-closed" + ); +} + /// #7170 R2: the compile driver resolves an imported function binding to a /// source return-shape fact before parallel codegen. An `ExternFuncRef` /// carrying that exact LOCAL binding is therefore the same rule-1 provenance diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index b88dad8161..c812a0c7d4 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -88,6 +88,12 @@ pub struct ModuleDispatchFacts { /// (`collectors/ptr_shape_returns.rs`); a call to such a function is then /// a rule-1 provenance seed exactly as `new C(...)` is. return_shape_functions: HashMap, + /// Representation-selection Phase 3b, #7170 R2: `(owning class, method + /// name, method FuncId)` -> the exact class freshly returned by that + /// instance method. The caller-side proof additionally requires an exact + /// shape-proven receiver and stable prototype dispatch before consulting + /// this table. + return_shape_methods: HashMap<(String, String, u32), String>, /// Representation-selection Phase 3b, #7170 R2: LOCAL imported function /// name -> exact anonymous-record class returned by its source body. /// Populated by the compile driver from a whole-program pre-pass over @@ -120,6 +126,7 @@ impl Default for ModuleDispatchFacts { numarray_prototype_index_barriers: true, freeze_barrier_sites: true, return_shape_functions: HashMap::new(), + return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), closure_bindings: HashMap::new(), } @@ -197,6 +204,20 @@ impl ModuleDispatchFacts { .map(String::as_str) } + /// The exact fresh class returned by one declared instance method body. + /// This fact alone does not license a caller seed: the caller must also + /// prove the receiver's exact class and stable method dispatch. + pub(crate) fn return_shape_method_class( + &self, + owner_class: &str, + method_name: &str, + func_id: u32, + ) -> Option<&str> { + self.return_shape_methods + .get(&(owner_class.to_string(), method_name.to_string(), func_id)) + .map(String::as_str) + } + /// The anonymous-record class returned by one statically-resolved native /// import, if the whole-program pre-pass proved that source body. pub(crate) fn imported_return_shape_class(&self, local_name: &str) -> Option<&str> { @@ -234,6 +255,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { numarray_prototype_index_barriers: false, freeze_barrier_sites: false, return_shape_functions: HashMap::new(), + return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), // #7170 R1. Purely structural — no barrier flag feeds it, and it is // read only through `closure_binding_func`, whose every consumer treats @@ -284,8 +306,11 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { // every return-shape fact. `facts.return_shape_functions` is still empty // while this runs, so the per-function proof (which re-enters // `collect_shape_proven_ptr_locals`) can never seed itself recursively. - facts.return_shape_functions = + let return_shape_functions = super::ptr_shape_returns::collect_return_shape_functions(&facts, hir); + let return_shape_methods = super::ptr_shape_returns::collect_return_shape_methods(&facts, hir); + facts.return_shape_functions = return_shape_functions; + facts.return_shape_methods = return_shape_methods; facts } @@ -702,6 +727,7 @@ mod tests { numarray_prototype_index_barriers: false, freeze_barrier_sites: false, return_shape_functions: HashMap::new(), + return_shape_methods: HashMap::new(), imported_return_shapes: HashMap::new(), closure_bindings: HashMap::new(), } diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index f7a915479c..83b330873f 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -601,9 +601,9 @@ struct Scope { /// (`collectors/ptr_shape_returns.rs`, #7107), so its `return new C(...)` /// sites already feed an existing mechanism. /// - /// Set by exactly two callers — [`enter_function_region`] from - /// `codegen/function.rs` and [`enter_closure`] from `codegen/closure.rs`, - /// the only two places that hold both a `FuncId` and `ModuleDispatchFacts`. + /// Set by [`enter_function_region`], [`enter_closure`], and + /// [`enter_method_region`] at the three codegen sites that hold both the + /// producer body's identity and `ModuleDispatchFacts`. /// /// #7170 R1: the closure arm is not a widening of the report, it tracks a /// widening of the mechanism. R0 recorded here that a closure could never @@ -611,9 +611,9 @@ struct Scope { /// for `hir.functions` entries and the caller-side seed fired only on a /// bare `Expr::FuncRef`. R1 makes both halves reach a closure, so a closure /// region CAN now be a producer and reporting otherwise would put a served - /// site back in the rule-1 bucket schedulers read. Method and module-init - /// regions still leave it `false` and that is still correct — neither is a - /// `FuncId`-keyed producer. + /// site back in the rule-1 bucket schedulers read. #7170 R2 extends the + /// same accounting to statically-resolved instance methods. Module-init + /// regions still leave it `false`. return_shape_producer: bool, } @@ -687,6 +687,31 @@ pub(crate) fn enter_function_region(function: &str, return_shape_producer: bool) } } +/// [`enter_region`] for an instance method body, additionally recording +/// whether the exact `(owning class, method name, FuncId)` implementation +/// carries a return-shape producer fact (#7170 R2). +pub(crate) fn enter_method_region(function: &str, return_shape_producer: bool) -> ScopeGuard { + if !enabled() { + return ScopeGuard { + previous: None, + active: false, + }; + } + let scope = Scope { + module: current_module(), + function: function.to_string(), + region: RegionKind::Method, + invoked_per_element: None, + local_source_spans: current_local_source_spans(), + return_shape_producer, + }; + let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); + ScopeGuard { + previous, + active: true, + } +} + /// Whether the region currently being lowered carries a return-shape fact. /// /// `false` with no scope at all, which is the safe direction: an unattributed diff --git a/test-files/test_gap_repsel_return_shape.ts b/test-files/test_gap_repsel_return_shape.ts index 752b6496e1..30728c27c7 100644 --- a/test-files/test_gap_repsel_return_shape.ts +++ b/test-files/test_gap_repsel_return_shape.ts @@ -28,6 +28,8 @@ // fall-through-to-undefined path, an indirect callee, // 8. an anonymous-record producer imported under a renamed local binding, // kept live across collection-triggering churn before its fields are read. +// 9. an instance-method producer reached through an exact shape-proven +// receiver, with the result consumed by fixed-field stores and reads. import { makeBarrelRow as makeImportedRow, @@ -98,6 +100,27 @@ function readShaped(i: number): string { return s.key + "=" + (s.value + 1); } +// 9. `factory.make(...)` is not a direct function symbol. R2 resolves it only +// while `factory` retains its exact contained shape and the class prototype is +// stable; the returned anonymous record then carries the same fresh-shape fact +// as `shapeOne(...)` above. +class ShapeFactory { + prefix: string; + constructor(prefix: string) { + this.prefix = prefix; + } + make(i: number): Shaped { + return { key: this.prefix + i, value: i * 3 }; + } +} + +function readMethodShaped(i: number): string { + const factory = new ShapeFactory("m"); + const shaped = factory.make(i); + shaped.value = shaped.value + 2; + return shaped.key + "=" + shaped.value; +} + // 4. Values the caller's region never saw stored. The constructor stores a // plain finite number into `v`; the PRODUCER then stores a non-plain-finite // one. The caller must not fold the constructor's store into its read — its @@ -225,6 +248,7 @@ const b = bumpedRec(6); out.push("bumped:" + b.name + ":" + b.score); out.push(foldRecs(10).name + "/" + foldRecs(10).score); out.push(readShaped(4)); +out.push(readMethodShaped(6)); out.push(readMixed(0)); out.push(readMixed(1)); out.push(readMixed(2));