diff --git a/changelog.d/8787-argument-shape-clones.md b/changelog.d/8787-argument-shape-clones.md
new file mode 100644
index 0000000000..979da5f7b4
--- /dev/null
+++ b/changelog.d/8787-argument-shape-clones.md
@@ -0,0 +1,6 @@
+Guarded direct method calls now propagate exact class and shape facts into
+eligible object arguments, including unannotated JavaScript parameters with a
+unique declared-field signature. The internal tagged-ABI clones use direct
+field offsets while exact runtime guards, shadow roots, and the ordinary method
+fallback preserve behavior for subclasses, proxies, mutated shapes, and other
+dynamic values.
diff --git a/changelog.d/8792-captured-closure-cache-hints.md b/changelog.d/8792-captured-closure-cache-hints.md
new file mode 100644
index 0000000000..31c8dcb383
--- /dev/null
+++ b/changelog.d/8792-captured-closure-cache-hints.md
@@ -0,0 +1 @@
+Captured-closure singleton reuse now fingerprints each exact capture tuple and maintains a direct-mapped, collision-safe hint into its bounded LRU. Hits no longer move vector entries, hash or hint collisions still require bit-exact capture equality, and copying GC recomputes fingerprints while discarding stale hints after rewriting pointer-bearing captures.
diff --git a/changelog.d/8793-static-method-object-literals.md b/changelog.d/8793-static-method-object-literals.md
new file mode 100644
index 0000000000..b0551eedbf
--- /dev/null
+++ b/changelog.d/8793-static-method-object-literals.md
@@ -0,0 +1 @@
+Static-key object literals containing methods now use the ordinary final-shape object lowering when every value is independent of the hidden home object. This removes the synthetic builder closure and property-by-property mutation while preserving evaluation order, dynamic `this`, capture semantics, and inferred method names; `super`, computed keys, spreads, accessors, prototype setters, and other source-ordered forms retain the fail-closed builder path.
diff --git a/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs
new file mode 100644
index 0000000000..27fbf9ff31
--- /dev/null
+++ b/crates/perry-codegen/src/codegen/argument_shape_clone_tests.rs
@@ -0,0 +1,300 @@
+//! #8774 exact-shape ordinary-argument clone ratchets.
+
+use crate::{compile_module, AppMetadata, CompileOptions};
+use perry_hir::types::Type;
+use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt};
+
+fn opts() -> CompileOptions {
+ CompileOptions {
+ emit_ir_only: true,
+ is_entry_module: true,
+ output_type: "executable".to_string(),
+ app_metadata: AppMetadata::default(),
+ ..CompileOptions::default()
+ }
+}
+
+fn param(id: u32, name: &str, ty: Type) -> Param {
+ Param {
+ id,
+ name: name.to_string(),
+ ty,
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }
+}
+
+fn function(id: u32, name: &str, params: Vec, body: Vec) -> Function {
+ Function {
+ id,
+ name: name.to_string(),
+ type_params: Vec::new(),
+ params,
+ return_type: Type::Void,
+ body,
+ is_async: false,
+ is_generator: false,
+ is_strict: true,
+ is_exported: false,
+ captures: Vec::new(),
+ decorators: Vec::new(),
+ was_plain_async: false,
+ was_unrolled: false,
+ }
+}
+
+fn class(id: u32, name: &str, fields: Vec<&str>, methods: Vec) -> Class {
+ Class {
+ id,
+ name: name.to_string(),
+ type_params: Vec::new(),
+ extends: None,
+ extends_name: None,
+ native_extends: None,
+ extends_expr: None,
+ heritage_lexically_shadowed: false,
+ fields: fields
+ .into_iter()
+ .map(|name| ClassField {
+ name: name.to_string(),
+ key_expr: None,
+ ty: Type::Any,
+ init: None,
+ is_private: false,
+ is_readonly: false,
+ decorators: Vec::new(),
+ })
+ .collect(),
+ constructor: None,
+ methods,
+ getters: Vec::new(),
+ setters: Vec::new(),
+ static_accessor_names: Vec::new(),
+ static_accessor_fn_ids: Vec::new(),
+ static_fields: Vec::new(),
+ static_methods: Vec::new(),
+ computed_members: Vec::new(),
+ decorators: Vec::new(),
+ is_exported: false,
+ is_nested: false,
+ alloc_width_hint: 0,
+ specialized_from: None,
+ aliases: Vec::new(),
+ }
+}
+
+fn field_get(local: u32, property: &str) -> Expr {
+ Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(local)),
+ property: property.to_string(),
+ byte_offset: 0,
+ }
+}
+
+fn fixture() -> Module {
+ let entity_param = 20;
+ let read = function(
+ 200,
+ "read",
+ vec![param(
+ entity_param,
+ "entity",
+ Type::Named("Entity".to_string()),
+ )],
+ vec![Stmt::Expr(field_get(entity_param, "id"))],
+ );
+ let mut module = Module::new("argument_shape_clone.ts");
+ module.classes.push(class(1, "Entity", vec!["id"], vec![]));
+ module
+ .classes
+ .push(class(2, "Registry", vec![], vec![read]));
+ module.init.extend([
+ Stmt::Let {
+ id: 10,
+ name: "registry".to_string(),
+ ty: Type::Named("Registry".to_string()),
+ mutable: false,
+ init: Some(Expr::New {
+ class_name: "Registry".to_string(),
+ args: Vec::new(),
+ type_args: Vec::new(),
+ byte_offset: 0,
+ cap_args_appended: 0,
+ }),
+ },
+ Stmt::Let {
+ id: 11,
+ name: "entity".to_string(),
+ ty: Type::Named("Entity".to_string()),
+ mutable: false,
+ init: Some(Expr::New {
+ class_name: "Entity".to_string(),
+ args: Vec::new(),
+ type_args: Vec::new(),
+ byte_offset: 0,
+ cap_args_appended: 0,
+ }),
+ },
+ Stmt::Expr(Expr::Call {
+ callee: Box::new(Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(10)),
+ property: "read".to_string(),
+ byte_offset: 0,
+ }),
+ args: vec![Expr::LocalGet(11)],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }),
+ ]);
+ module
+}
+
+fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str {
+ let start = ir
+ .match_indices("define ")
+ .find(|(index, _)| {
+ let end = ir[*index..]
+ .find('\n')
+ .map(|offset| index + offset)
+ .unwrap_or(ir.len());
+ ir[*index..end].contains(marker)
+ })
+ .map(|(index, _)| index)
+ .unwrap_or_else(|| panic!("missing function {marker}:\n{ir}"));
+ let end = ir[start..]
+ .find("\n}")
+ .map(|offset| start + offset)
+ .expect("function terminator");
+ &ir[start..end]
+}
+
+#[test]
+fn guarded_call_routes_to_shadow_rooted_direct_field_clone() {
+ // Native statepoint roots are the host default. Pin the shadow-stack
+ // lowering because this assertion specifically ratchets the portable
+ // tagged-slot fallback required by the clone ABI.
+ let _shadow = crate::codegen::helpers::NativeRootsPin::shadow();
+ let ir = String::from_utf8(compile_module(&fixture(), opts()).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ let clone_name = "perry_method_argument_shape_clone_ts__Registry__read$pshape_args";
+ let clone = function_ir(&ir, &format!("@{clone_name}("));
+ let generic = function_ir(
+ &ir,
+ "@perry_method_argument_shape_clone_ts__Registry__read(",
+ );
+
+ assert!(
+ ir.contains(&format!("call double @{clone_name}(")),
+ "the guarded call site must route to the argument clone:\n{ir}"
+ );
+ assert!(
+ ir.contains("pshape_arg.fallback")
+ && ir.contains("call double @perry_method_argument_shape_clone_ts__Registry__read("),
+ "guard failure must retain the ordinary method body:\n{ir}"
+ );
+ assert!(
+ clone.contains("@js_shadow_slot_bind(")
+ && clone.find("@js_shadow_slot_bind(")
+ < clone
+ .find("getelementptr double")
+ .or_else(|| clone.find("inttoptr i64")),
+ "the tagged parameter slot must be bound before fixed-offset access:\n{clone}"
+ );
+ assert!(
+ clone.contains("inttoptr i64") && clone.contains("getelementptr double"),
+ "the clone must use direct declared-field addressing:\n{clone}"
+ );
+ assert!(
+ !clone.contains("js_typed_feedback_class_field_get_guard")
+ && !clone.contains("shape_descriptor_by_id"),
+ "the clone fast body must not rebuild the field IC diamond:\n{clone}"
+ );
+ assert!(
+ generic.contains("js_typed_feedback_class_field_get_guard")
+ || generic.contains("js_object_get_field"),
+ "the generic fallback must retain guarded field semantics:\n{generic}"
+ );
+}
+
+#[test]
+fn routed_argument_is_a_contained_ptr_shape_win_in_the_opt_report() {
+ let session = crate::opt_report::test_support::Session::start();
+ compile_module(&fixture(), opts()).expect("module compiles");
+ let entries = session.entries();
+ let entity_entries: Vec<_> = entries
+ .iter()
+ .filter(|entry| entry.name == "entity" && entry.local_id == Some(11))
+ .collect();
+
+ assert!(
+ entity_entries
+ .iter()
+ .any(|entry| entry.outcome == crate::opt_report::Outcome::Selected),
+ "the guarded argument route must preserve the caller's Ptr fact: {entries:#?}"
+ );
+ assert!(
+ entity_entries.iter().all(|entry| {
+ entry.outcome != crate::opt_report::Outcome::Denied
+ || !entry
+ .reason
+ .as_deref()
+ .unwrap_or("")
+ .contains("passed as a call argument")
+ }),
+ "the retired call-argument denial must not survive a selected clone route: {entries:#?}"
+ );
+}
+
+#[test]
+fn unannotated_parameter_uses_the_runtime_validated_class_overlay() {
+ let mut module = fixture();
+ module.classes[1].methods[0].params[0].ty = Type::Any;
+ let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ let clone = function_ir(&ir, "Registry__read$pshape_args(");
+ assert!(
+ clone.contains("getelementptr double") && clone.contains("inttoptr i64"),
+ "the exact runtime guard must recover the class layout for an unannotated parameter:\n{clone}"
+ );
+ assert!(
+ !clone.contains("js_typed_feedback_class_field_get_guard")
+ && !clone.contains("shape_descriptor_by_id"),
+ "the unannotated clone must not rebuild the field IC diamond:\n{clone}"
+ );
+}
+
+#[test]
+fn ambiguous_unannotated_field_signature_stays_generic() {
+ let mut module = fixture();
+ module.classes[1].methods[0].params[0].ty = Type::Any;
+ module
+ .classes
+ .push(class(3, "OtherEntity", vec!["id"], vec![]));
+ let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ assert!(
+ !ir.contains("Registry__read$pshape_args"),
+ "an unannotated field signature matching multiple classes must not nominate either:\n{ir}"
+ );
+}
+
+#[test]
+fn aliased_or_reassigned_parameter_does_not_get_a_clone() {
+ let mut module = fixture();
+ let method = &mut module.classes[1].methods[0];
+ method.body.insert(
+ 0,
+ Stmt::Expr(Expr::LocalSet(
+ method.params[0].id,
+ Box::new(Expr::Undefined),
+ )),
+ );
+ let ir = String::from_utf8(compile_module(&module, opts()).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ assert!(
+ !ir.contains("Registry__read$pshape_args"),
+ "a reassigned parameter must keep only generic semantics:\n{ir}"
+ );
+}
diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs
index 11e9f4f7d7..e37593235b 100644
--- a/crates/perry-codegen/src/codegen/artifacts.rs
+++ b/crates/perry-codegen/src/codegen/artifacts.rs
@@ -26,6 +26,9 @@ use super::method::{
compile_typed_string_method,
};
use super::native_namespace_exports::emit_native_namespace_reexport_getters;
+use super::ordinary_method_artifacts::{
+ compile_ordinary_method_artifacts, OrdinaryMethodArtifactsCtx,
+};
use super::spec_function_length;
use super::string_pool::emit_string_pool;
use super::typed_abi::TypedFunctionTrampolineKind;
@@ -323,52 +326,19 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
nonnegative_index_params,
)?;
}
- compile_method(
- llmod,
- class,
- method,
- func_names,
- strings,
- class_table,
- method_names,
- module_globals,
- module_global_types,
- opts.import_function_prefixes,
- enum_table,
- static_field_globals,
- class_ids,
- func_signatures,
- func_synthetic_arguments,
- module_boxed_vars,
- closure_rest_params,
- cross_module,
- typed_public_trampoline,
- cross_module
- .typed_f64_receiver_methods
- .contains_key(&(class.name.clone(), method.name.clone())),
- None,
- None,
- false,
- false,
- false,
- )
- .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?;
- if cross_module
- .guarded_undefined_method_params
- .contains_key(&(class.name.clone(), method.name.clone()))
- {
- compile_method(
+ compile_ordinary_method_artifacts(
+ OrdinaryMethodArtifactsCtx {
llmod,
class,
method,
func_names,
strings,
- class_table,
- method_names,
+ classes: class_table,
+ methods: method_names,
module_globals,
module_global_types,
- opts.import_function_prefixes,
- enum_table,
+ import_function_prefixes: opts.import_function_prefixes,
+ enums: enum_table,
static_field_globals,
class_ids,
func_signatures,
@@ -376,148 +346,9 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
module_boxed_vars,
closure_rest_params,
cross_module,
- None,
- false,
- None,
- None,
- false,
- false,
- true,
- )
- .with_context(|| {
- format!(
- "lowering exact-undefined clone of method '{}::{}'",
- class.name, method.name
- )
- })?;
- }
- // Representation-selection Phase 5a: the additive `internal`
- // proven-`this` clone. Same HIR, same ABI, same shadow-bound
- // tagged-at-rest receiver slot — only `this.field` lowering
- // differs (bare fixed-offset access instead of the per-access
- // guard diamond). Reached ONLY from the two call sites that
- // already prove the receiver's exact shape; never registered into
- // a runtime vtable.
- if let Some(fact) = cross_module
- .pshape_methods
- .get(&(class.name.clone(), method.name.clone()))
- {
- compile_method(
- llmod,
- class,
- method,
- func_names,
- strings,
- class_table,
- method_names,
- module_globals,
- module_global_types,
- opts.import_function_prefixes,
- enum_table,
- static_field_globals,
- class_ids,
- func_signatures,
- func_synthetic_arguments,
- module_boxed_vars,
- closure_rest_params,
- cross_module,
- None,
- false,
- Some(fact.clone()),
- None,
- false,
- false,
- false,
- )
- .with_context(|| {
- format!(
- "lowering proven-`this` clone of method '{}::{}'",
- class.name, method.name
- )
- })?;
- if cross_module
- .guarded_undefined_method_params
- .contains_key(&(class.name.clone(), method.name.clone()))
- {
- compile_method(
- llmod,
- class,
- method,
- func_names,
- strings,
- class_table,
- method_names,
- module_globals,
- module_global_types,
- opts.import_function_prefixes,
- enum_table,
- static_field_globals,
- class_ids,
- func_signatures,
- func_synthetic_arguments,
- module_boxed_vars,
- closure_rest_params,
- cross_module,
- None,
- false,
- Some(fact.clone()),
- None,
- false,
- false,
- true,
- )
- .with_context(|| {
- format!(
- "lowering proven-`this` exact-undefined clone of method '{}::{}'",
- class.name, method.name
- )
- })?;
- }
-
- // #8607: a second, stricter clone for the Phase 3b
- // provenance+containment route. Its synthetic immutable
- // aliases keep stable array-valued fields in local slots, so
- // existing local-array loop optimizations can see through
- // repeated `this.field` uses. It is never selected by the
- // guarded or dispatch-tower `$pshape` routes.
- if let Some(cached_method) =
- crate::collectors::ptr_array_cached_method(class, method)
- {
- compile_method(
- llmod,
- class,
- &cached_method,
- func_names,
- strings,
- class_table,
- method_names,
- module_globals,
- module_global_types,
- opts.import_function_prefixes,
- enum_table,
- static_field_globals,
- class_ids,
- func_signatures,
- func_synthetic_arguments,
- module_boxed_vars,
- closure_rest_params,
- cross_module,
- None,
- false,
- Some(fact.clone()),
- None,
- false,
- true,
- false,
- )
- .with_context(|| {
- format!(
- "lowering contained-receiver array-cache clone of method '{}::{}'",
- class.name, method.name
- )
- })?;
- }
- }
+ },
+ typed_public_trampoline,
+ )?;
}
for member in class
.computed_members
@@ -550,6 +381,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
false,
false,
false,
+ false,
)
.with_context(|| {
format!(
@@ -619,6 +451,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
false,
false,
false,
+ false,
)
.with_context(|| format!("lowering getter '{}::{}'", class.name, prop))?;
}
@@ -676,6 +509,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
false,
false,
false,
+ false,
)
.with_context(|| format!("lowering setter '{}::{}'", class.name, prop))?;
}
@@ -775,6 +609,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
false,
false,
false,
+ false,
)
.with_context(|| format!("lowering constructor for '{}'", class.name))?;
}
diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs
index 0cd51ad02f..6c5b4ca67c 100644
--- a/crates/perry-codegen/src/codegen/closure.rs
+++ b/crates/perry-codegen/src/codegen/closure.rs
@@ -1154,12 +1154,14 @@ pub(super) fn compile_closure(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: HashMap::new(),
versioned_indexed_loop_facts: Vec::new(),
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this: None,
+ proven_shape_params: std::collections::HashMap::new(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs
index 0012ed02c7..7fc4ac852c 100644
--- a/crates/perry-codegen/src/codegen/entry.rs
+++ b/crates/perry-codegen/src/codegen/entry.rs
@@ -954,12 +954,14 @@ pub(super) fn compile_module_entry(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: HashMap::new(),
versioned_indexed_loop_facts: Vec::new(),
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this: None,
+ proven_shape_params: std::collections::HashMap::new(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
@@ -1657,12 +1659,14 @@ pub(super) fn compile_module_entry(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: HashMap::new(),
versioned_indexed_loop_facts: Vec::new(),
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this: None,
+ proven_shape_params: std::collections::HashMap::new(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs
index a265be8f14..0e59ec451f 100644
--- a/crates/perry-codegen/src/codegen/function.rs
+++ b/crates/perry-codegen/src/codegen/function.rs
@@ -1204,12 +1204,14 @@ pub(super) fn compile_function(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: HashMap::new(),
versioned_indexed_loop_facts: Vec::new(),
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this: None,
+ proven_shape_params: std::collections::HashMap::new(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
diff --git a/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs
index cf0416d261..2f30812736 100644
--- a/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs
+++ b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs
@@ -83,6 +83,7 @@ pub(super) fn compile_indexed_method_clones(
false,
false,
false,
+ false,
)
.with_context(|| {
format!(
@@ -122,6 +123,7 @@ pub(super) fn compile_indexed_method_clones(
true,
false,
false,
+ false,
)
.with_context(|| {
format!(
diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs
index cc9d116252..f5ad672265 100644
--- a/crates/perry-codegen/src/codegen/method.rs
+++ b/crates/perry-codegen/src/codegen/method.rs
@@ -56,6 +56,7 @@ pub(super) fn compile_method(
fast_array_handle_clone: bool,
ptr_array_cache_clone: bool,
guarded_undefined_clone: bool,
+ pshape_arg_clone: bool,
) -> Result<()> {
let public_llvm_name = methods
.get(&(class.name.clone(), method.name.clone()))
@@ -72,9 +73,16 @@ pub(super) fn compile_method(
// lowerer. It never replaces the public symbol and never participates in
// the typed-trampoline / generic-body split — those are emitted by the
// primary (`proven_this: None`) invocation for this same method.
- let is_pshape_clone = proven_this.is_some();
+ let is_pshape_clone = proven_this.is_some() && !pshape_arg_clone;
let is_index_clone = nonnegative_index_params.is_some();
- let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone)
+ let pshape_arg_plan = pshape_arg_clone
+ .then(|| {
+ cross_module
+ .pshape_arg_methods
+ .get(&(class.name.clone(), method.name.clone()))
+ })
+ .flatten();
+ let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone)
.then(|| {
cross_module
.guarded_undefined_method_params
@@ -97,7 +105,12 @@ pub(super) fn compile_method(
debug_assert!(!guarded_undefined_clone || guarded_undefined_param.is_some());
debug_assert!(!guarded_undefined_clone || !is_index_clone);
debug_assert!(!guarded_undefined_clone || !ptr_array_cache_clone);
- let family_name = if ptr_array_cache_clone {
+ debug_assert!(!pshape_arg_clone || pshape_arg_plan.is_some());
+ debug_assert!(!pshape_arg_clone || !is_index_clone);
+ debug_assert!(!pshape_arg_clone || !ptr_array_cache_clone);
+ let family_name = if pshape_arg_clone {
+ crate::collectors::pshape_args_method_name(&public_llvm_name)
+ } else if ptr_array_cache_clone {
crate::collectors::ptr_array_cache_method_name(&public_llvm_name)
} else if is_pshape_clone {
crate::collectors::pshape_method_name(&public_llvm_name)
@@ -118,7 +131,7 @@ pub(super) fn compile_method(
)
} else if guarded_undefined_param.is_some() {
generic_method_body_name(&family_name)
- } else if ptr_array_cache_clone || is_pshape_clone {
+ } else if ptr_array_cache_clone || is_pshape_clone || pshape_arg_clone {
family_name.clone()
} else if typed_public_trampoline.is_some() || force_generic_body {
generic_method_body_name(&public_llvm_name)
@@ -151,10 +164,11 @@ pub(super) fn compile_method(
|| typed_public_trampoline.is_some()
|| force_generic_body
|| guarded_undefined_param.is_some()
+ || pshape_arg_clone
{
lf.linkage = "internal".to_string();
}
- super::helpers::apply_pshape_inline_policy(lf, method, is_pshape_clone);
+ super::helpers::apply_pshape_inline_policy(lf, method, is_pshape_clone || pshape_arg_clone);
if is_index_clone {
lf.pre_statepoint_inline = true;
}
@@ -309,6 +323,19 @@ pub(super) fn compile_method(
if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) {
guarded_param_proofs.insert(method.params[index].id, perry_hir::types::Type::Void);
}
+ if let Some(plan) = pshape_arg_plan {
+ // Unlike a source annotation, this overlay is backed by the exact
+ // class+shape guard that exclusively routes into `$pshape_args`.
+ // Supplying it to property dispatch lets an unannotated (`Any`) JS
+ // parameter resolve the declared field before the Ptr overlay
+ // removes that field access's ordinary IC diamond.
+ guarded_param_proofs.extend(plan.args.iter().map(|arg| {
+ (
+ arg.param_id,
+ perry_hir::types::Type::Named(arg.fact.class_name.clone()),
+ )
+ }));
+ }
let mut reassigned_locals = crate::collectors::reassigned_locals(&method.body);
if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) {
// Candidate discovery already rejected every user-authored write and
@@ -507,6 +534,7 @@ pub(super) fn compile_method(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: fast_array_param_ids
.iter()
@@ -517,6 +545,14 @@ pub(super) fn compile_method(
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this,
+ proven_shape_params: pshape_arg_plan
+ .map(|plan| {
+ plan.args
+ .iter()
+ .map(|arg| (arg.param_id, arg.fact.clone()))
+ .collect()
+ })
+ .unwrap_or_default(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
@@ -1770,12 +1806,14 @@ pub(super) fn compile_static_method(
typed_i1_function_param_reps: &cross_module.typed_i1_function_param_reps,
typed_f64_methods: &cross_module.typed_f64_methods,
pshape_methods: &cross_module.pshape_methods,
+ pshape_arg_methods: &cross_module.pshape_arg_methods,
nonnegative_index_methods: &cross_module.nonnegative_index_methods,
trusted_array_param_handles: HashMap::new(),
versioned_indexed_loop_facts: Vec::new(),
stable_packed_loop_facts: Vec::new(),
pshape_tower_routable: &cross_module.pshape_tower_routable,
proven_this: None,
+ proven_shape_params: std::collections::HashMap::new(),
typed_i32_methods: &cross_module.typed_i32_methods,
typed_i1_methods: &cross_module.typed_i1_methods,
typed_string_methods: &cross_module.typed_string_methods,
diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs
index 7257ad8967..0f21557b32 100644
--- a/crates/perry-codegen/src/codegen/mod.rs
+++ b/crates/perry-codegen/src/codegen/mod.rs
@@ -192,9 +192,12 @@ mod hoisted_callback_method_tests;
#[cfg(test)]
mod index_method_clone_tests;
mod indexed_method_artifacts;
+mod ordinary_method_artifacts;
// `pub(crate)` so `crate::linker` can read the inline-hot-small policy
// (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`).
#[cfg(test)]
+mod argument_shape_clone_tests;
+#[cfg(test)]
mod clone_suffix_tests;
#[cfg(test)]
mod declared_string_add_tests;
@@ -2016,11 +2019,65 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
&& !nonnegative_index_methods.contains_key(key)
});
guarded_undefined_method_candidates.sort_unstable_by(|left, right| left.cmp(right));
- let guarded_undefined_method_params = guarded_undefined_method_candidates
- .into_iter()
- .take(16)
- .map(|(_, key, param_index)| (key, param_index))
+ let guarded_undefined_method_params: std::collections::HashMap<(String, String), usize> =
+ guarded_undefined_method_candidates
+ .into_iter()
+ .take(16)
+ .map(|(_, key, param_index)| (key, param_index))
+ .collect();
+ // #8774: one non-combinatorial tagged-ABI clone per local method. Source
+ // annotations or a unique unannotated field signature only nominate a
+ // class; every routed call emits an exact runtime class+shape guard. Keep
+ // this disjoint from typed/index/undefined clone families, whose
+ // trampolines have separate routing conventions.
+ let local_class_names: std::collections::HashSet<&str> = hir
+ .classes
+ .iter()
+ .map(|class| class.name.as_str())
.collect();
+ let mut pshape_arg_methods = std::collections::HashMap::new();
+ for class in &hir.classes {
+ for method in &class.methods {
+ let key = (class.name.clone(), method.name.clone());
+ if typed_f64_methods.contains(&key)
+ || typed_i32_methods.contains(&key)
+ || typed_i1_methods.contains(&key)
+ || typed_string_methods.contains(&key)
+ || typed_f64_receiver_methods.contains_key(&key)
+ || nonnegative_index_methods.contains_key(&key)
+ || guarded_undefined_method_params.contains_key(&key)
+ {
+ continue;
+ }
+ let Some(mut plan) = crate::collectors::method_proven_shape_args(
+ method,
+ receiver_class_table,
+ &local_class_names,
+ &module_dispatch_facts,
+ ) else {
+ continue;
+ };
+ // Imported argument classes need producer-authored shape metadata
+ // and clone publication. Until that capability is explicit, keep
+ // imports/re-exports on the generic path.
+ plan.args
+ .retain(|arg| local_class_names.contains(arg.fact.class_name.as_str()));
+ if !plan.args.is_empty() {
+ pshape_arg_methods.insert(key, plan);
+ }
+ }
+ }
+ module_dispatch_facts.install_argument_shape_routes(pshape_arg_methods.iter().map(
+ |(key, plan)| {
+ (
+ key.clone(),
+ plan.args
+ .iter()
+ .map(|arg| (arg.param_index, arg.fact.class_name.clone()))
+ .collect(),
+ )
+ },
+ ));
let mut compiler_private_async_i32_control_locals = std::collections::HashSet::new();
let mut compiler_private_async_i1_control_locals = std::collections::HashSet::new();
crate::boxed_vars::collect_compiler_private_async_control_locals_in_stmts(
@@ -2301,6 +2358,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
nonnegative_index_methods,
guarded_undefined_method_params,
pshape_methods,
+ pshape_arg_methods,
pshape_tower_routable,
typed_f64_closures: std::collections::HashSet::new(),
typed_i32_closures: std::collections::HashSet::new(),
diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs
index 998c542c04..97d21fe568 100644
--- a/crates/perry-codegen/src/codegen/opts.rs
+++ b/crates/perry-codegen/src/codegen/opts.rs
@@ -933,6 +933,12 @@ pub(crate) struct CrossModuleCtx {
/// compiled for and `this` cannot have a different subclass chain.
pub pshape_methods:
std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>,
+ /// Module-local method bodies with a guarded exact-shape parameter clone.
+ /// The plan names every formal that must pass its class+shape guard before
+ /// the tagged-ABI `$pshape_args` body may be entered. These capabilities
+ /// are intentionally not exported in the first increment.
+ pub pshape_arg_methods:
+ std::collections::HashMap<(String, String), crate::collectors::ProvenShapeArgPlan>,
/// #7142: the subset of [`Self::pshape_methods`] whose clone the class-id
/// dispatch tower may route to. The other two routing sites are dominated by
/// a shape guard they pay regardless, so the clone is free for them; the
diff --git a/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs
new file mode 100644
index 0000000000..1837a38aab
--- /dev/null
+++ b/crates/perry-codegen/src/codegen/ordinary_method_artifacts.rs
@@ -0,0 +1,305 @@
+//! Emits the ordinary tagged-ABI method body and its additive guarded clones.
+//! Kept separate from the artifact traversal so clone families stay grouped
+//! without pushing that orchestration module over the source-size gate.
+
+use std::collections::{HashMap, HashSet};
+
+use anyhow::{Context, Result};
+
+use crate::module::LlModule;
+use crate::strings::StringPool;
+
+use super::method::compile_method;
+use super::opts::CrossModuleCtx;
+use super::typed_abi::TypedFunctionTrampolineKind;
+
+pub(super) struct OrdinaryMethodArtifactsCtx<'a> {
+ pub llmod: &'a mut LlModule,
+ pub class: &'a perry_hir::Class,
+ pub method: &'a perry_hir::Function,
+ pub func_names: &'a HashMap,
+ pub strings: &'a mut StringPool,
+ pub classes: &'a HashMap,
+ pub methods: &'a HashMap<(String, String), String>,
+ pub module_globals: &'a HashMap,
+ pub module_global_types: &'a HashMap,
+ pub import_function_prefixes: &'a HashMap,
+ pub enums: &'a HashMap<(String, String), perry_hir::EnumValue>,
+ pub static_field_globals: &'a HashMap<(String, String), String>,
+ pub class_ids: &'a HashMap,
+ pub func_signatures: &'a HashMap,
+ pub func_synthetic_arguments: &'a HashSet,
+ pub module_boxed_vars: &'a HashSet,
+ pub closure_rest_params: &'a HashMap,
+ pub cross_module: &'a CrossModuleCtx,
+}
+
+pub(super) fn compile_ordinary_method_artifacts(
+ c: OrdinaryMethodArtifactsCtx<'_>,
+ typed_public_trampoline: Option,
+) -> Result<()> {
+ let OrdinaryMethodArtifactsCtx {
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ } = c;
+
+ compile_method(
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ typed_public_trampoline,
+ cross_module
+ .typed_f64_receiver_methods
+ .contains_key(&(class.name.clone(), method.name.clone())),
+ None,
+ None,
+ false,
+ false,
+ false,
+ false,
+ )
+ .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?;
+
+ if cross_module
+ .guarded_undefined_method_params
+ .contains_key(&(class.name.clone(), method.name.clone()))
+ {
+ compile_method(
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ None,
+ false,
+ None,
+ None,
+ false,
+ false,
+ true,
+ false,
+ )
+ .with_context(|| {
+ format!(
+ "lowering exact-undefined clone of method '{}::{}'",
+ class.name, method.name
+ )
+ })?;
+ }
+
+ // Representation-selection Phase 5a: the additive `internal`
+ // proven-`this` clone. Same HIR, same ABI, same shadow-bound tagged-at-rest
+ // receiver slot; only `this.field` lowering differs. It is reached solely
+ // from call sites that already prove the receiver's exact shape.
+ if let Some(fact) = cross_module
+ .pshape_methods
+ .get(&(class.name.clone(), method.name.clone()))
+ {
+ compile_method(
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ None,
+ false,
+ Some(fact.clone()),
+ None,
+ false,
+ false,
+ false,
+ false,
+ )
+ .with_context(|| {
+ format!(
+ "lowering proven-`this` clone of method '{}::{}'",
+ class.name, method.name
+ )
+ })?;
+
+ if cross_module
+ .guarded_undefined_method_params
+ .contains_key(&(class.name.clone(), method.name.clone()))
+ {
+ compile_method(
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ None,
+ false,
+ Some(fact.clone()),
+ None,
+ false,
+ false,
+ true,
+ false,
+ )
+ .with_context(|| {
+ format!(
+ "lowering proven-`this` exact-undefined clone of method '{}::{}'",
+ class.name, method.name
+ )
+ })?;
+ }
+
+ // #8607: a stricter provenance+containment clone. Synthetic immutable
+ // aliases keep stable array-valued fields in local slots so existing
+ // local-array loop optimizations see through repeated `this.field`.
+ if let Some(cached_method) = crate::collectors::ptr_array_cached_method(class, method) {
+ compile_method(
+ llmod,
+ class,
+ &cached_method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ None,
+ false,
+ Some(fact.clone()),
+ None,
+ false,
+ true,
+ false,
+ false,
+ )
+ .with_context(|| {
+ format!(
+ "lowering contained-receiver array-cache clone of method '{}::{}'",
+ class.name, method.name
+ )
+ })?;
+ }
+ }
+
+ // #8774: internal exact-shape argument clone with the ordinary tagged ABI.
+ // Every route guards all selected arguments before entry. If the receiver
+ // is also proven, compose that fact into this same clone.
+ if cross_module
+ .pshape_arg_methods
+ .contains_key(&(class.name.clone(), method.name.clone()))
+ {
+ compile_method(
+ llmod,
+ class,
+ method,
+ func_names,
+ strings,
+ classes,
+ methods,
+ module_globals,
+ module_global_types,
+ import_function_prefixes,
+ enums,
+ static_field_globals,
+ class_ids,
+ func_signatures,
+ func_synthetic_arguments,
+ module_boxed_vars,
+ closure_rest_params,
+ cross_module,
+ None,
+ false,
+ cross_module
+ .pshape_methods
+ .get(&(class.name.clone(), method.name.clone()))
+ .cloned(),
+ None,
+ false,
+ false,
+ false,
+ true,
+ )
+ .with_context(|| {
+ format!(
+ "lowering exact-shape argument clone of method '{}::{}'",
+ class.name, method.name
+ )
+ })?;
+ }
+
+ Ok(())
+}
diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs
index 780ca10d48..09add949a7 100644
--- a/crates/perry-codegen/src/collectors/mod.rs
+++ b/crates/perry-codegen/src/collectors/mod.rs
@@ -32,6 +32,7 @@ mod not_bigint_locals;
mod number_by_construction;
mod param_ranges;
mod pointer_locals;
+mod proven_args;
mod proven_this;
#[cfg(test)]
mod proven_this_routing_tests;
@@ -86,6 +87,9 @@ pub(crate) use mutation::{body_contains_call, body_contains_closure, has_any_mut
pub(crate) use number_by_construction::collect_number_by_construction_locals;
pub(crate) use param_ranges::{collect_param_int_ranges, ParamIntRanges};
pub(crate) use pointer_locals::collect_pointer_typed_locals;
+pub(crate) use proven_args::{
+ method_proven_shape_args, pshape_args_method_name, ProvenShapeArgPlan,
+};
pub(crate) use proven_this::{
exportable_method_capabilities as exportable_proven_this_method_capabilities,
method_proven_this, prune_unregistered_clones, pshape_method_name, ptr_array_cache_fields,
diff --git a/crates/perry-codegen/src/collectors/proven_args.rs b/crates/perry-codegen/src/collectors/proven_args.rs
new file mode 100644
index 0000000000..e45598205e
--- /dev/null
+++ b/crates/perry-codegen/src/collectors/proven_args.rs
@@ -0,0 +1,349 @@
+//! Exact-shape facts carried into selected method parameters.
+//!
+//! A TypeScript parameter annotation can nominate a candidate, but is never
+//! proof. For an unannotated JavaScript parameter, a unique local class whose
+//! declared fields cover every direct read may nominate the candidate instead.
+//! A call site must still prove the argument's exact runtime class and shape
+//! before it may enter the clone described here; the ordinary method body
+//! remains the fallback for every other value. The clone keeps the public
+//! tagged ABI, so the parameter is stored in (and reloaded from) its ordinary
+//! shadow-bound slot at every fixed-offset field access.
+//!
+//! This first increment deliberately accepts read-only declared-field uses.
+//! Stores through an aliased parameter have additional frozen/sealed-object
+//! semantics, and a bare use can publish the object to code the proof cannot
+//! inspect. Both therefore keep the generic body.
+
+use std::collections::{HashMap, HashSet};
+
+use perry_hir::types::Type;
+use perry_hir::{Class, Expr, Function, Stmt};
+
+use super::ptr_shape::{chain_admissible, chain_classes, chain_field_names, PtrShapeLocal};
+use super::ModuleDispatchFacts;
+
+/// One parameter proven by the call-site guard on entry to `$pshape_args`.
+#[derive(Debug, Clone)]
+pub struct ProvenShapeArg {
+ /// Zero-based source/formal argument position (the method receiver is not
+ /// counted).
+ pub param_index: usize,
+ pub param_id: u32,
+ pub fact: PtrShapeLocal,
+}
+
+/// The single, non-combinatorial exact-shape argument clone for a method.
+/// Every listed argument must pass before the call enters the clone.
+#[derive(Debug, Clone)]
+pub struct ProvenShapeArgPlan {
+ pub args: Vec,
+}
+
+/// Reserved generated symbol for the method's exact-shape argument clone.
+pub(crate) fn pshape_args_method_name(public_name: &str) -> String {
+ format!("{public_name}$pshape_args")
+}
+
+/// Nominate read-only parameters whose body uses declared class fields.
+///
+/// A declared `C` type or unique unannotated field signature only chooses the
+/// expected class for the runtime guard emitted at every routed call site.
+/// Classes absent from `classes`, optional/default/rest/`arguments`
+/// parameters, async bodies, and any module carrying the conservative
+/// shape-barrier latch stand down.
+pub(crate) fn method_proven_shape_args(
+ method: &Function,
+ classes: &HashMap,
+ local_class_names: &HashSet<&str>,
+ module_dispatch: &ModuleDispatchFacts,
+) -> Option {
+ if !super::ptr_shape::ptr_shape_locals_enabled()
+ || module_dispatch.has_shape_barrier_sites()
+ || method.is_async
+ || method.is_generator
+ || method.was_plain_async
+ || !method.captures.is_empty()
+ {
+ return None;
+ }
+
+ let mut args = Vec::new();
+ for (param_index, param) in method.params.iter().enumerate() {
+ if param.default.is_some() || param.is_rest || param.arguments_object.is_some() {
+ continue;
+ }
+ let mut use_check = ReadOnlyParamUse {
+ param_id: param.id,
+ field_reads: HashSet::new(),
+ safe: true,
+ };
+ use_check.walk_stmts(&method.body);
+ if !use_check.safe || use_check.field_reads.is_empty() {
+ continue;
+ }
+ let class_name = match ¶m.ty {
+ Type::Named(class_name) => {
+ if !local_class_names.contains(class_name.as_str())
+ || !class_fields_cover(classes, class_name, &use_check.field_reads)
+ {
+ continue;
+ }
+ class_name.clone()
+ }
+ // An unannotated JS parameter lowers to `Any`. The field signature
+ // is only a nomination mechanism: runtime guards still prove the
+ // exact class and shape at every route.
+ Type::Any => {
+ let mut candidates = local_class_names.iter().filter(|class_name| {
+ class_fields_cover(classes, class_name, &use_check.field_reads)
+ });
+ let Some(candidate) = candidates.next() else {
+ continue;
+ };
+ if candidates.next().is_some() {
+ continue;
+ }
+ (*candidate).to_string()
+ }
+ _ => continue,
+ };
+ if !chain_admissible(classes, &class_name) {
+ continue;
+ }
+ args.push(ProvenShapeArg {
+ param_index,
+ param_id: param.id,
+ fact: PtrShapeLocal {
+ class_name,
+ // An exact shape proves offsets, not the representation of a
+ // caller-owned field value.
+ numeric_fields: HashSet::new(),
+ report_name: crate::opt_report::enabled().then(|| param.name.clone()),
+ },
+ });
+ }
+
+ (!args.is_empty()).then_some(ProvenShapeArgPlan { args })
+}
+
+fn class_fields_cover(
+ classes: &HashMap,
+ class_name: &str,
+ field_reads: &HashSet,
+) -> bool {
+ if !chain_admissible(classes, class_name) {
+ return false;
+ }
+ let fields = chain_field_names(&chain_classes(classes, class_name));
+ !fields.is_empty() && field_reads.is_subset(&fields)
+}
+
+/// The guarded clone's audited body cannot retain or reshape a matching
+/// tracked argument, so that exact route preserves caller-side containment.
+pub(super) fn route_preserves_argument_containment(
+ module_dispatch: &ModuleDispatchFacts,
+ candidates: &HashMap,
+ roots: &HashMap,
+ owner_class: &str,
+ method: &str,
+ param_index: usize,
+ arg: &Expr,
+) -> bool {
+ let Expr::LocalGet(id) = arg else {
+ return false;
+ };
+ let Some(root) = roots.get(id) else {
+ return false;
+ };
+ let Some(expected) = module_dispatch.argument_shape_class(owner_class, method, param_index)
+ else {
+ return false;
+ };
+ candidates.get(root).is_some_and(|got| got == expected)
+}
+
+struct ReadOnlyParamUse {
+ param_id: u32,
+ field_reads: HashSet,
+ safe: bool,
+}
+
+impl ReadOnlyParamUse {
+ fn walk_stmts(&mut self, stmts: &[Stmt]) {
+ for stmt in stmts {
+ self.walk_stmt(stmt);
+ }
+ }
+
+ fn walk_stmt(&mut self, stmt: &Stmt) {
+ if !self.safe {
+ return;
+ }
+ match stmt {
+ Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => self.walk_expr(expr),
+ Stmt::Let {
+ init: Some(expr), ..
+ } => self.walk_expr(expr),
+ Stmt::If {
+ condition,
+ then_branch,
+ else_branch,
+ } => {
+ self.walk_expr(condition);
+ self.walk_stmts(then_branch);
+ if let Some(branch) = else_branch {
+ self.walk_stmts(branch);
+ }
+ }
+ Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => {
+ self.walk_expr(condition);
+ self.walk_stmts(body);
+ }
+ Stmt::For {
+ init,
+ condition,
+ update,
+ body,
+ } => {
+ if let Some(init) = init {
+ self.walk_stmt(init);
+ }
+ if let Some(condition) = condition {
+ self.walk_expr(condition);
+ }
+ if let Some(update) = update {
+ self.walk_expr(update);
+ }
+ self.walk_stmts(body);
+ }
+ Stmt::Try {
+ body,
+ catch,
+ finally,
+ } => {
+ self.walk_stmts(body);
+ if let Some(catch) = catch {
+ self.walk_stmts(&catch.body);
+ }
+ if let Some(finally) = finally {
+ self.walk_stmts(finally);
+ }
+ }
+ Stmt::Switch {
+ discriminant,
+ cases,
+ } => {
+ self.walk_expr(discriminant);
+ for case in cases {
+ if let Some(test) = &case.test {
+ self.walk_expr(test);
+ }
+ self.walk_stmts(&case.body);
+ }
+ }
+ Stmt::Labeled { body, .. } => self.walk_stmt(body),
+ Stmt::Return(None)
+ | Stmt::Let { init: None, .. }
+ | Stmt::Break
+ | Stmt::Continue
+ | Stmt::LabeledBreak(_)
+ | Stmt::LabeledContinue(_)
+ | Stmt::PreallocateBoxes(_)
+ | Stmt::PreallocateTdzBoxes(_)
+ | Stmt::ReleaseBoxes(_) => {}
+ }
+ }
+
+ fn walk_expr(&mut self, expr: &Expr) {
+ if !self.safe {
+ return;
+ }
+ match expr {
+ // This is the only position that consumes the proof. Do not walk
+ // the receiver child: its otherwise-bare LocalGet is licensed by
+ // this declared-field operation.
+ Expr::PropertyGet {
+ object, property, ..
+ } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == self.param_id) => {
+ self.field_reads.insert(property.clone());
+ }
+ // A direct store/update has frozen/sealed and setter semantics not
+ // implied by an exact-shape entry guard.
+ Expr::PropertySet { object, .. } | Expr::PropertyUpdate { object, .. } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == self.param_id) =>
+ {
+ self.safe = false;
+ }
+ Expr::LocalGet(id) if *id == self.param_id => self.safe = false,
+ Expr::LocalSet(id, _) if *id == self.param_id => self.safe = false,
+ Expr::Closure { body, .. } => {
+ perry_hir::walker::walk_expr_children(expr, &mut |child| self.walk_expr(child));
+ self.walk_stmts(body);
+ }
+ _ => perry_hir::walker::walk_expr_children(expr, &mut |child| self.walk_expr(child)),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ /// `$pshape_args` is an internal direct-call capability. Keep every place
+ /// that can spell its suffix visible here so a future vtable/indirect-call
+ /// registration fails the same kind of reachability ratchet as the
+ /// proven-`this` family.
+ #[test]
+ fn pshape_argument_symbol_reachability() {
+ let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let allowed: [&str; 10] = [
+ "collectors/proven_args.rs", // naming + this test
+ "collectors/proven_this.rs", // exact-suffix reachability split
+ "collectors/ptr_shape.rs", // containment route contract
+ "collectors/scalar_method_dispatch.rs", // emitted-route metadata
+ "codegen/argument_shape_clone_tests.rs", // IR/report ratchets
+ "codegen/method.rs", // clone emission
+ "codegen/opts.rs", // cross-module context
+ "expr/mod.rs", // clone parameter proof overlay
+ "lower_call/method_override.rs", // guarded direct routing
+ "lower_call/property_get/dynamic_dispatch.rs", // Ptr receiver routing
+ ];
+ let mut offenders = Vec::new();
+
+ fn visit(
+ dir: &std::path::Path,
+ root: &std::path::Path,
+ allowed: &[&str],
+ out: &mut Vec,
+ ) {
+ for entry in std::fs::read_dir(dir).expect("read src dir") {
+ let path = entry.expect("dir entry").path();
+ if path.is_dir() {
+ visit(&path, root, allowed, out);
+ continue;
+ }
+ if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
+ continue;
+ }
+ let rel = path
+ .strip_prefix(root)
+ .unwrap()
+ .to_string_lossy()
+ .replace('\\', "/");
+ if allowed.contains(&rel.as_str()) {
+ continue;
+ }
+ if std::fs::read_to_string(&path)
+ .expect("read source file")
+ .contains("$pshape_args")
+ {
+ out.push(rel);
+ }
+ }
+ }
+
+ visit(&src_root, &src_root, &allowed, &mut offenders);
+ assert!(
+ offenders.is_empty(),
+ "argument-shape clone symbol fragments found outside the direct-call allowlist: \
+ {offenders:?}"
+ );
+ }
+}
diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs
index 87fac47328..58a621b5c6 100644
--- a/crates/perry-codegen/src/collectors/proven_this.rs
+++ b/crates/perry-codegen/src/collectors/proven_this.rs
@@ -829,7 +829,10 @@ mod tests {
continue;
}
let text = std::fs::read_to_string(&path).expect("read source file");
- if text.contains("$pshape") {
+ if text
+ .match_indices("$pshape")
+ .any(|(offset, _)| !text[offset..].starts_with("$pshape_args"))
+ {
out.push(rel);
}
}
diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
index 1967b56d3f..efee6f2212 100644
--- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
+++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
@@ -225,11 +225,12 @@ fn pshape_call_targets(ir: &str) -> Vec {
fn pshape_definitions(ir: &str) -> Vec {
ir.lines()
- .filter(|l| l.starts_with("define") && l.contains("$pshape"))
+ .filter(|l| l.starts_with("define"))
.filter_map(|l| {
let at = l.find('@')?;
let paren = l[at..].find('(')?;
- Some(l[at + 1..at + paren].to_string())
+ let name = &l[at + 1..at + paren];
+ name.ends_with("$pshape").then(|| name.to_string())
})
.collect()
}
diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs
index 223ece44ad..c3e96ff574 100644
--- a/crates/perry-codegen/src/collectors/ptr_shape.rs
+++ b/crates/perry-codegen/src/collectors/ptr_shape.rs
@@ -445,6 +445,7 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields(
candidates: &candidates,
roots: &roots,
classes,
+ module_dispatch,
disqualified: HashSet::new(),
let_counts: HashMap::new(),
field_stores: HashMap::new(),
@@ -841,6 +842,7 @@ struct UseWalk<'a> {
/// Tracked member id (candidate or const alias) -> root candidate id.
roots: &'a HashMap,
classes: &'a HashMap,
+ module_dispatch: &'a ModuleDispatchFacts,
disqualified: HashSet,
let_counts: HashMap,
/// root candidate -> (field name, store value) for in-function stores.
@@ -1214,9 +1216,22 @@ impl<'a> UseWalk<'a> {
.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.
+ for (param_index, a) in args.iter().enumerate() {
+ // `o.m(o)` escapes unless the audited exact-class
+ // argument clone makes this position contained.
+ if resolvable
+ && super::proven_args::route_preserves_argument_containment(
+ self.module_dispatch,
+ self.candidates,
+ self.roots,
+ class_name,
+ property,
+ param_index,
+ a,
+ )
+ {
+ continue;
+ }
self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a));
}
return;
diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs
index 8acbf41d89..830e3aa05f 100644
--- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs
+++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs
@@ -99,6 +99,11 @@ pub struct ModuleDispatchFacts {
/// Populated by the compile driver from a whole-program pre-pass over
/// final HIR, after this module's own barrier/producer facts are collected.
imported_return_shapes: HashMap,
+ /// #8774: exact-shape argument clones installed after clone eligibility is
+ /// known. The containment walk consults this table only for a statically
+ /// resolved method call whose tracked argument class exactly matches the
+ /// clone's guarded parameter.
+ argument_shape_routes: HashMap<(String, String, usize), String>,
/// Representation-selection Phase 3b, #7170 R1: `LocalId` -> `FuncId` for
/// every local that provably names one closure literal, module-wide.
///
@@ -128,6 +133,7 @@ impl Default for ModuleDispatchFacts {
return_shape_functions: HashMap::new(),
return_shape_methods: HashMap::new(),
imported_return_shapes: HashMap::new(),
+ argument_shape_routes: HashMap::new(),
closure_bindings: HashMap::new(),
}
}
@@ -234,6 +240,39 @@ impl ModuleDispatchFacts {
self.imported_return_shapes = shapes;
}
+ /// Install the guarded argument-clone capabilities emitted by this
+ /// module. Clone admission depends on typed-ABI family selection performed
+ /// by codegen, while ordinary region facts are collected later during
+ /// artifact emission.
+ pub(crate) fn install_argument_shape_routes(
+ &mut self,
+ routes: impl IntoIterator- )>,
+ ) {
+ self.argument_shape_routes.clear();
+ for ((owner, method), args) in routes {
+ for (index, class_name) in args {
+ self.argument_shape_routes
+ .insert((owner.clone(), method.clone(), index), class_name);
+ }
+ }
+ }
+
+ /// Expected exact argument class for one emitted `$pshape_args` route.
+ pub(crate) fn argument_shape_class(
+ &self,
+ owner_class: &str,
+ method_name: &str,
+ param_index: usize,
+ ) -> Option<&str> {
+ self.argument_shape_routes
+ .get(&(
+ owner_class.to_string(),
+ method_name.to_string(),
+ param_index,
+ ))
+ .map(String::as_str)
+ }
+
/// Representation-selection Phase 3b, #7170 R1: the `FuncId` that
/// `LocalGet(local_id)` in callee position provably names, or `None`.
///
@@ -257,6 +296,7 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts {
return_shape_functions: HashMap::new(),
return_shape_methods: HashMap::new(),
imported_return_shapes: HashMap::new(),
+ argument_shape_routes: HashMap::new(),
// #7170 R1. Purely structural — no barrier flag feeds it, and it is
// read only through `closure_binding_func`, whose every consumer treats
// `None` as "take no seed". Computed here rather than lazily so the one
@@ -730,6 +770,7 @@ mod tests {
return_shape_functions: HashMap::new(),
return_shape_methods: HashMap::new(),
imported_return_shapes: HashMap::new(),
+ argument_shape_routes: HashMap::new(),
closure_bindings: HashMap::new(),
}
}
diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs
index e9f48237eb..54eb58b38b 100644
--- a/crates/perry-codegen/src/expr/mod.rs
+++ b/crates/perry-codegen/src/expr/mod.rs
@@ -1061,12 +1061,21 @@ pub(crate) struct FnCtx<'a> {
/// keeps today's guarded lowering because its receiver is unproven.
pub proven_this: Option,
+ /// #8774: parameter-local exact-shape proofs installed only in a guarded
+ /// `$pshape_args` method clone. Like `proven_this`, each value remains a
+ /// tagged JSValue in its ordinary shadow-bound slot; field lowering reloads
+ /// that slot before deriving a raw pointer.
+ pub proven_shape_params: std::collections::HashMap,
+
/// Phase 5a: `(class, method)` pairs with an emitted proven-`this` clone.
/// The two proven call sites consult this before routing; a hit also
/// proves the receiver's exact class DECLARES the method (own
/// declarations only), which is what rules out a subclass `this`.
pub pshape_methods:
&'a std::collections::HashMap<(String, String), crate::collectors::PtrShapeLocal>,
+ /// #8774: module-local guarded exact-shape parameter clone plans.
+ pub pshape_arg_methods:
+ &'a std::collections::HashMap<(String, String), crate::collectors::ProvenShapeArgPlan>,
/// Module-local methods whose nonnegative-index clone was actually
/// emitted. Call lowering gates on this registry rather than re-running
@@ -2089,7 +2098,10 @@ impl<'a> FnCtx<'a> {
return None;
}
match e {
- perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id),
+ perry_hir::Expr::LocalGet(id) => self
+ .proven_shape_params
+ .get(id)
+ .or_else(|| self.native_facts.shape_proven_ptr_local(*id)),
perry_hir::Expr::This => self.proven_this.as_ref(),
_ => None,
}
@@ -2103,7 +2115,10 @@ impl<'a> FnCtx<'a> {
e: &perry_hir::Expr,
) -> Option<&crate::collectors::PtrShapeLocal> {
match e {
- perry_hir::Expr::LocalGet(id) => self.native_facts.shape_proven_ptr_local(*id),
+ perry_hir::Expr::LocalGet(id) => self
+ .proven_shape_params
+ .get(id)
+ .or_else(|| self.native_facts.shape_proven_ptr_local(*id)),
perry_hir::Expr::This => self.proven_this.as_ref(),
_ => None,
}
diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs
index 8d3a1e08a5..c6276cb405 100644
--- a/crates/perry-codegen/src/lower_call/method_override.rs
+++ b/crates/perry-codegen/src/lower_call/method_override.rs
@@ -124,6 +124,166 @@ pub(crate) fn emit_inline_direct_method_shape_guard(
}
}
+/// Emit the exact ordinary-object `(class_id, ShapeId)` guard used by a
+/// `$pshape_args` route. Unlike the method-receiver guard above this does not
+/// consult prototype-method invalidation state: it proves field offsets only.
+/// Descriptor-bearing, forwarded, proxy, subclass, wrong-class, and mutated-
+/// shape values all take `fallback_label` before any clone field access.
+fn emit_inline_exact_argument_shape_guard(
+ ctx: &mut FnCtx<'_>,
+ value: &str,
+ expected_class_id: u32,
+ expected_shape_id: &str,
+ fast_label: &str,
+ fallback_label: &str,
+) {
+ let deref_idx = ctx.new_block("pshape_arg.guard_deref");
+ let deref_label = ctx.block_label(deref_idx);
+ let heap_floor =
+ crate::target_layout::heap_addr_lower_bound_inclusive(ctx.target_triple).to_string();
+ let heap_ceiling =
+ crate::target_layout::heap_addr_upper_bound_exclusive(ctx.target_triple).to_string();
+
+ {
+ let blk = ctx.block();
+ let bits = blk.bitcast_double_to_i64(value);
+ let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64);
+ let tag = blk.lshr(I64, &bits, "48");
+ let tagged = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16);
+ let above_floor = blk.icmp_uge(I64, &handle, &heap_floor);
+ let below_ceiling = blk.icmp_ult(I64, &handle, &heap_ceiling);
+ let in_heap = blk.and(I1, &above_floor, &below_ceiling);
+ let safe_to_deref = blk.and(I1, &tagged, &in_heap);
+ blk.cond_br(&safe_to_deref, &deref_label, fallback_label);
+ }
+
+ ctx.current_block = deref_idx;
+ {
+ let blk = ctx.block();
+ let bits = blk.bitcast_double_to_i64(value);
+ let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64);
+ let obj_ptr = blk.inttoptr(I64, &handle);
+ let gc_header_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]);
+ let gc_header = blk.load(I32, &gc_header_ptr);
+ let guarded_gc_bits = blk.and(I32, &gc_header, GC_OBJECT_METHOD_GUARD_MASK_I32);
+ let gc_header_ok = blk.icmp_eq(I32, &guarded_gc_bits, GC_TYPE_OBJECT);
+
+ let class_shape = blk.load(I64, &obj_ptr);
+ let expected_shape_i64 = blk.zext(I32, expected_shape_id, I64);
+ let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32");
+ let expected_class_shape =
+ blk.or(I64, &expected_shape_high, &expected_class_id.to_string());
+ let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape);
+ let shape_id_rel = blk.add(I32, expected_shape_id, SHAPE_ID_BASE_NEG_I32);
+ let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN);
+ let pass = blk.and(I1, &gc_header_ok, &class_shape_ok);
+ let pass = blk.and(I1, &pass, &shape_valid);
+ blk.cond_br(&pass, fast_label, fallback_label);
+ }
+}
+
+/// Route a receiver-proven method call through its exact-shape argument clone.
+/// `generic_fn` is the already-selected receiver-safe body for guard failure.
+pub(super) fn emit_pshape_argument_dispatch(
+ ctx: &mut FnCtx<'_>,
+ receiver_class_name: &str,
+ property: &str,
+ direct_fn: &str,
+ generic_fn: &str,
+ direct_arg_slices: &[(crate::types::LlvmType, &str)],
+) -> Option {
+ let key = (receiver_class_name.to_string(), property.to_string());
+ let plan = ctx.pshape_arg_methods.get(&key)?.clone();
+ let clone_fn = crate::collectors::pshape_args_method_name(direct_fn);
+
+ let mut guarded = Vec::with_capacity(plan.args.len());
+ for arg in &plan.args {
+ let value = direct_arg_slices.get(arg.param_index + 1)?.1.to_string();
+ let class_id = *ctx.class_ids.get(&arg.fact.class_name)?;
+ let keys_global = ctx.class_keys_globals.get(&arg.fact.class_name)?.clone();
+ let shape_id =
+ crate::typed_shape::load_class_shape_id(ctx, &arg.fact.class_name, &keys_global);
+ guarded.push((arg.clone(), value, class_id, shape_id));
+ }
+ if guarded.is_empty() {
+ return None;
+ }
+
+ let fast_idx = ctx.new_block("pshape_arg.fast");
+ let fallback_idx = ctx.new_block("pshape_arg.fallback");
+ let merge_idx = ctx.new_block("pshape_arg.merge");
+ let intermediate_idxs: Vec = (1..guarded.len())
+ .map(|_| ctx.new_block("pshape_arg.guard_next"))
+ .collect();
+ let fast_label = ctx.block_label(fast_idx);
+ let fallback_label = ctx.block_label(fallback_idx);
+ let merge_label = ctx.block_label(merge_idx);
+
+ for (index, (_, value, class_id, shape_id)) in guarded.iter().enumerate() {
+ let pass_label = intermediate_idxs
+ .get(index)
+ .map(|block| ctx.block_label(*block))
+ .unwrap_or_else(|| fast_label.clone());
+ emit_inline_exact_argument_shape_guard(
+ ctx,
+ value,
+ *class_id,
+ shape_id,
+ &pass_label,
+ &fallback_label,
+ );
+ if let Some(next) = intermediate_idxs.get(index) {
+ ctx.current_block = *next;
+ }
+ }
+
+ ctx.current_block = fast_idx;
+ let fast_value = ctx.block().call(DOUBLE, &clone_fn, direct_arg_slices);
+ let fast_end = ctx.block().label.clone();
+ ctx.block().br(&merge_label);
+
+ ctx.current_block = fallback_idx;
+ let fallback_value = ctx.block().call(DOUBLE, generic_fn, direct_arg_slices);
+ let fallback_end = ctx.block().label.clone();
+ ctx.block().br(&merge_label);
+
+ ctx.current_block = merge_idx;
+ let merged = ctx.block().phi(
+ DOUBLE,
+ &[
+ (fast_value.as_str(), fast_end.as_str()),
+ (fallback_value.as_str(), fallback_end.as_str()),
+ ],
+ );
+ let mut notes = vec![
+ format!("argument_clone={clone_fn}"),
+ format!("generic_method={generic_fn}"),
+ format!("receiver_class={receiver_class_name}"),
+ format!("method={property}"),
+ "argument_abi=tagged_js_value_shadow_rooted".to_string(),
+ "guard_failure_fallback=generic_method".to_string(),
+ ];
+ for (arg, _, _, _) in &guarded {
+ notes.push(format!("argument_index={}", arg.param_index));
+ notes.push(format!("argument_class={}", arg.fact.class_name));
+ notes.push("argument_guard=exact_class_and_shape".to_string());
+ notes.push("argument_provenance=runtime_guarded_declared_candidate".to_string());
+ }
+ ctx.record_lowered_value(
+ "MethodCall",
+ None,
+ "proven_shape_argument_method_call",
+ &LoweredValue::js_value(merged.clone()),
+ None,
+ None,
+ None,
+ false,
+ false,
+ notes,
+ );
+ Some(merged)
+}
+
#[cfg(test)]
mod packed_guard_tests {
use super::*;
@@ -1036,70 +1196,88 @@ pub(super) fn emit_guarded_direct_method_call(
);
result
} else {
- // Representation-selection Phase 5a: this arm is reached ONLY
- // after `js_method_direct_shape_guard` /
- // `js_typed_feedback_method_direct_call_guard` matched the exact
- // class id AND the keys token — i.e. the receiver's shape is
- // already proven, and the proof is then thrown away by calling the
- // guard-ridden public body. Route to the proven-`this` clone
- // instead; identical ABI, so only the callee name changes.
- //
- // A `pshape_methods` hit additionally proves `receiver_class_name`
- // DECLARES `property` (locally by analysis or across modules by a
- // producer-authored capability), so the clone's `this` is exactly
- // the class it was compiled for — an inherited `Base::m` reached
- // through a subclass receiver never routes here.
- //
- // NOTE: the per-field `js_typed_feedback_class_field_get_guard`
- // loop above is deliberately LEFT IN PLACE. It guards the
- // `$typed_f64_recv` clone's bare `load double` field access, and
- // the whole-object shape guard does NOT subsume it: an external
- // `obj.f = "s"` preserves both the class id and the key set while
- // downgrading the slot's raw-f64 layout. The `$pshape` clone
- // needs no such guard because it never claims `JsNumber` — its
- // bare loads carry generic `JsValue` semantics (see
- // `collectors/proven_this.rs`).
- //
- // `pshape_fn` (computed once at the top of this function, where the
- // `perry_static_` exclusion and the declaring-class argument are
- // written out) is the same clone the typed arms above now route
- // their generic fallbacks to.
- let target = nonnegative_index_direct_fn
- .or(pshape_fn.as_deref())
- .unwrap_or(direct_fn);
- let result = ctx.block().call(DOUBLE, target, direct_arg_slices);
- if nonnegative_index_direct_fn.is_none() {
- if let Some(pshape) = pshape_fn.as_deref() {
- let receiver_provenance =
- if ctx.imported_class_sources.contains_key(receiver_class_name) {
- "imported_class_metadata"
- } else {
- "module_local_analysis"
- };
- ctx.record_lowered_value(
- "MethodCall",
- None,
- "proven_this_method_direct_call",
- &LoweredValue::js_value(result.clone()),
- None,
- None,
- None,
- false,
- false,
- vec![
- format!("typed_clone={pshape}"),
- format!("generic_method={direct_fn}"),
- format!("receiver_class={receiver_class_name}"),
- format!("method={property}"),
- format!("receiver_provenance={receiver_provenance}"),
- "this_representation=tagged_js_value_exact_shape".to_string(),
- "method_identity_guard=required".to_string(),
- "generic_dispatch_fallback=js_native_call_method_by_id".to_string(),
- ],
- );
+ // #8774: the receiver guard dominating this block already proves
+ // method identity. Guard the selected object arguments here and
+ // enter the tagged `$pshape_args` body only when every exact shape
+ // matches. An argument miss stays on the receiver-safe ordinary
+ // body; a receiver miss is still handled by the outer dynamic
+ // fallback.
+ let pshape_arg_fallback = pshape_fn.as_deref().unwrap_or(direct_fn);
+ if let Some(argument_specialized) = emit_pshape_argument_dispatch(
+ ctx,
+ receiver_class_name,
+ property,
+ direct_fn,
+ pshape_arg_fallback,
+ direct_arg_slices,
+ ) {
+ argument_specialized
+ } else {
+ // Representation-selection Phase 5a: this arm is reached ONLY
+ // after `js_method_direct_shape_guard` /
+ // `js_typed_feedback_method_direct_call_guard` matched the exact
+ // class id AND the keys token — i.e. the receiver's shape is
+ // already proven, and the proof is then thrown away by calling the
+ // guard-ridden public body. Route to the proven-`this` clone
+ // instead; identical ABI, so only the callee name changes.
+ //
+ // A `pshape_methods` hit additionally proves `receiver_class_name`
+ // DECLARES `property` (locally by analysis or across modules by a
+ // producer-authored capability), so the clone's `this` is exactly
+ // the class it was compiled for — an inherited `Base::m` reached
+ // through a subclass receiver never routes here.
+ //
+ // NOTE: the per-field `js_typed_feedback_class_field_get_guard`
+ // loop above is deliberately LEFT IN PLACE. It guards the
+ // `$typed_f64_recv` clone's bare `load double` field access, and
+ // the whole-object shape guard does NOT subsume it: an external
+ // `obj.f = "s"` preserves both the class id and the key set while
+ // downgrading the slot's raw-f64 layout. The `$pshape` clone
+ // needs no such guard because it never claims `JsNumber` — its
+ // bare loads carry generic `JsValue` semantics (see
+ // `collectors/proven_this.rs`).
+ //
+ // `pshape_fn` (computed once at the top of this function, where the
+ // `perry_static_` exclusion and the declaring-class argument are
+ // written out) is the same clone the typed arms above now route
+ // their generic fallbacks to.
+ let target = nonnegative_index_direct_fn
+ .or(pshape_fn.as_deref())
+ .unwrap_or(direct_fn);
+ let result = ctx.block().call(DOUBLE, target, direct_arg_slices);
+ if nonnegative_index_direct_fn.is_none() {
+ if let Some(pshape) = pshape_fn.as_deref() {
+ let receiver_provenance =
+ if ctx.imported_class_sources.contains_key(receiver_class_name) {
+ "imported_class_metadata"
+ } else {
+ "module_local_analysis"
+ };
+ ctx.record_lowered_value(
+ "MethodCall",
+ None,
+ "proven_this_method_direct_call",
+ &LoweredValue::js_value(result.clone()),
+ None,
+ None,
+ None,
+ false,
+ false,
+ vec![
+ format!("typed_clone={pshape}"),
+ format!("generic_method={direct_fn}"),
+ format!("receiver_class={receiver_class_name}"),
+ format!("method={property}"),
+ format!("receiver_provenance={receiver_provenance}"),
+ "this_representation=tagged_js_value_exact_shape".to_string(),
+ "method_identity_guard=required".to_string(),
+ "generic_dispatch_fallback=js_native_call_method_by_id".to_string(),
+ ],
+ );
+ }
}
+ result
}
- result
}
};
let after_fast = ctx.block().label.clone();
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 ed6316c519..fdd441aec7 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
@@ -15,7 +15,8 @@ use crate::types::{DOUBLE, I1, I32, I64};
// Reach the override-emit helpers (`pub(super)` of `lower_call`) by their
// canonical crate-relative path.
use crate::lower_call::method_override::{
- emit_guarded_direct_method_call, emit_own_method_override_check, SubclassDispatchArm,
+ emit_guarded_direct_method_call, emit_own_method_override_check, emit_pshape_argument_dispatch,
+ SubclassDispatchArm,
};
/// Cap on the number of extra `(class id, keys token)` arms a shape-guarded
@@ -1457,6 +1458,20 @@ pub(crate) fn try_lower_instance_method_call(
.or(ptr_array_cache_target.as_deref())
.or(pshape_target.as_deref())
.unwrap_or(fallback_fn.as_str());
+ // #8774: containment proves the receiver here, while the
+ // dedicated argument guards prove every selected callee
+ // parameter. A miss calls the same receiver-safe generic
+ // target this block used before argument specialization.
+ if let Some(argument_specialized) = emit_pshape_argument_dispatch(
+ ctx,
+ &class_name,
+ property,
+ &fallback_fn,
+ generic_target,
+ &arg_slices,
+ ) {
+ return Ok(Some(argument_specialized));
+ }
// 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
diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs
index 96f2f922fe..fe3d5372a8 100644
--- a/crates/perry-hir/src/lower/expr_object.rs
+++ b/crates/perry-hir/src/lower/expr_object.rs
@@ -19,7 +19,8 @@ use swc_common::Spanned;
use swc_ecma_ast as ast;
use crate::analysis::{
- closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_stmt, uses_this_stmt,
+ closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_expr,
+ collect_local_refs_stmt, uses_this_stmt,
};
use crate::ir::{EnumValue, Expr, Function, Param, Stmt};
use crate::lower_decl::{
@@ -1055,6 +1056,55 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R
}
}
+ // A static-key, no-spread method literal does not need the synthetic
+ // IIFE once all of its lowered values are independent of the hidden
+ // home-object parameter. Emit a normal `Expr::Object` instead: codegen
+ // allocates its final shape once and fills slots by index, preserving
+ // source evaluation order without allocating/calling a closure merely
+ // to mutate `{}` one property at a time.
+ //
+ // Methods containing `super` capture `param_id` as their home object.
+ // Those fail closed and retain the IIFE, as do computed keys, spreads,
+ // accessors, prototype setters, and any other source-ordered op. A
+ // method that only observes dynamic `this` is safe here: the ordinary
+ // object-literal lowering already patches its reserved receiver slot.
+ let value_is_home_independent = |value: &Expr| {
+ let mut refs = Vec::new();
+ let mut visited_closures = std::collections::HashSet::new();
+ collect_local_refs_expr(value, &mut refs, &mut visited_closures);
+ !refs.contains(¶m_id)
+ };
+ let can_emit_static_object = has_method
+ && !has_spread
+ && !has_accessor
+ && !has_computed
+ && !has_proto_setter
+ && ops.iter().all(|op| match op {
+ SpreadOp::Set {
+ key: Expr::String(_),
+ value,
+ infer_name: false,
+ } => value_is_home_independent(value),
+ SpreadOp::MethodByName { closure, .. } => value_is_home_independent(closure),
+ _ => false,
+ });
+ if can_emit_static_object {
+ let props = ops
+ .into_iter()
+ .map(|op| match op {
+ SpreadOp::Set {
+ key: Expr::String(key),
+ value,
+ infer_name: false,
+ } => (key, value),
+ SpreadOp::MethodByName { key, closure } => (key, closure),
+ _ => unreachable!("static object admission checked every op"),
+ })
+ .collect();
+ ctx.exit_scope(scope_mark);
+ return Ok(Expr::Object(props));
+ }
+
// Pass 2: build the IIFE wrapper. `__o` starts as an empty object
// and each op mutates it in source order.
let extern_call = |name: &str, args: Vec| Expr::Call {
diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs
index bdc52af2a3..d93aeefc75 100644
--- a/crates/perry-hir/src/lower/tests.rs
+++ b/crates/perry-hir/src/lower/tests.rs
@@ -8,7 +8,7 @@
#![cfg(test)]
use super::*;
-use crate::ir::{EnumValue, Stmt};
+use crate::ir::{EnumValue, Expr, Stmt};
use crate::types::{Type, TypeParam};
fn make_ctx() -> LoweringContext {
@@ -87,6 +87,74 @@ function build(paramBox: unknown) {
}
}
+#[test]
+fn static_method_literals_skip_the_builder_iife_but_home_objects_fail_closed() {
+ let source = r#"
+const outer = 4;
+const fast = {
+ plain: 1,
+ captured(x: number) { return outer + x; },
+ dynamicThis(x: number) { return this.plain + x; },
+};
+const withSuper = { read() { return super.value; } };
+const key = "computed";
+const computed = { [key]() { return 1; } };
+"#;
+ let module = perry_parser::parse_typescript(source, "method-object.ts").expect("source parses");
+ let hir =
+ super::lower_module(&module, "method-object", "method-object.ts").expect("source lowers");
+
+ let local_init = |name: &str| {
+ hir.init
+ .iter()
+ .find_map(|stmt| match stmt {
+ Stmt::Let {
+ name: local_name,
+ init: Some(init),
+ ..
+ } if local_name == name => Some(init),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("missing init for {name}"))
+ };
+
+ let Expr::Object(props) = local_init("fast") else {
+ panic!(
+ "static method literal should be a direct object: {:#?}",
+ hir.init
+ );
+ };
+ assert_eq!(
+ props
+ .iter()
+ .map(|(key, _)| key.as_str())
+ .collect::>(),
+ ["plain", "captured", "dynamicThis"]
+ );
+ assert!(matches!(
+ &props[2].1,
+ Expr::Closure {
+ captures_this: true,
+ ..
+ }
+ ));
+
+ for name in ["withSuper", "computed"] {
+ assert!(
+ matches!(
+ local_init(name),
+ Expr::Call { callee, .. }
+ if matches!(
+ callee.as_ref(),
+ Expr::Closure { params, .. }
+ if params.first().is_some_and(|param| param.name == "__perry_obj_iife")
+ )
+ ),
+ "{name} must retain the source-ordered home-object IIFE"
+ );
+ }
+}
+
#[test]
fn test_lower_function_registration() {
let mut ctx = make_ctx();
diff --git a/crates/perry-runtime/src/closure/alloc.rs b/crates/perry-runtime/src/closure/alloc.rs
index 70c85b989a..f0c5b3da2b 100644
--- a/crates/perry-runtime/src/closure/alloc.rs
+++ b/crates/perry-runtime/src/closure/alloc.rs
@@ -12,20 +12,7 @@ crate::perry_thread_local! {
static SINGLETON_CLOSURES: RefCell> =
RefCell::new(crate::fast_hash::new_ptr_hash_map());
- /// Per-`func_ptr` single-slot cache for closures with captures.
- /// Each value is `(last_captures, last_closure)` — when the same
- /// closure literal is created again with the SAME capture bits,
- /// we return the cached closure; otherwise we allocate a fresh
- /// one and replace the slot.
- ///
- /// One entry per closure literal (bounded by the number of
- /// `Expr::Closure` sites in the program), not per
- /// `(func_ptr, capture-tuple)` pair — this prevents a closure
- /// whose captures vary per call (e.g.
- /// `getOrCompute(map, key, () => new Foo(sortedTypes))` capturing
- /// a fresh array per call) from filling the cache and crowding
- /// out closures with stable captures.
- /// Per-`func_ptr` small-LRU cache. Each entry holds up to
+ /// Per-`func_ptr` small-LRU cache. Each value holds up to
/// `MAX_CAPTURED_CLOSURE_SLOTS` (captures-bits, ClosureHeader)
/// pairs. Multiple slots are critical for the parallel-instance
/// async-await pattern (e.g. `Promise.all` of N async closures
@@ -34,10 +21,207 @@ crate::perry_thread_local! {
/// `PtrHasher`-keyed for the same reason as the other registries
/// here — on `promise_all_chains` this is hit on every closure
/// alloc (150 k/run).
- static SINGLETON_CAPTURED_CLOSURES: RefCell, *mut ClosureHeader)>>> =
+ static SINGLETON_CAPTURED_CLOSURES: RefCell> =
RefCell::new(crate::fast_hash::new_ptr_hash_map());
}
+#[derive(Clone)]
+struct CapturedClosureEntry {
+ /// Non-semantic prefilter for `captures`. Hash collisions always fall
+ /// through to the exact bitwise tuple comparison below.
+ fingerprint: u64,
+ captures: Vec,
+ closure: *mut ClosureHeader,
+ last_used: u64,
+}
+
+/// Direct-mapped index into the exact entry vector. A collision only falls
+/// back to the bounded scan; neither the fingerprint nor this hint is ever
+/// trusted without exact capture-bit equality.
+const CAPTURED_HINT_SLOTS: usize = 128;
+
+struct CapturedClosureCache {
+ entries: Vec,
+ hint_fingerprints: [u64; CAPTURED_HINT_SLOTS],
+ hint_indices_plus_one: [u8; CAPTURED_HINT_SLOTS],
+ clock: u64,
+}
+
+impl CapturedClosureCache {
+ fn new() -> Self {
+ Self {
+ entries: Vec::new(),
+ hint_fingerprints: [0; CAPTURED_HINT_SLOTS],
+ hint_indices_plus_one: [0; CAPTURED_HINT_SLOTS],
+ clock: 0,
+ }
+ }
+
+ #[inline]
+ fn hint_slot(fingerprint: u64) -> usize {
+ // Fold high bits before masking: capture pointers are aligned and FNV's
+ // low bits alone otherwise make avoidable direct-map collisions.
+ (fingerprint ^ (fingerprint >> 32)) as usize & (CAPTURED_HINT_SLOTS - 1)
+ }
+
+ #[inline]
+ fn touch(&mut self, index: usize) -> *mut ClosureHeader {
+ self.clock = self.clock.wrapping_add(1);
+ self.entries[index].last_used = self.clock;
+ self.entries[index].closure
+ }
+
+ fn lookup(&mut self, fingerprint: u64, captures: &[u64]) -> Option<*mut ClosureHeader> {
+ let hint_slot = Self::hint_slot(fingerprint);
+ let hinted = self.hint_indices_plus_one[hint_slot];
+ if hinted != 0 && self.hint_fingerprints[hint_slot] == fingerprint {
+ let index = (hinted - 1) as usize;
+ if self
+ .entries
+ .get(index)
+ .is_some_and(|entry| entry.captures.as_slice() == captures)
+ {
+ return Some(self.touch(index));
+ }
+ }
+
+ let index = self.entries.iter().position(|entry| {
+ entry.fingerprint == fingerprint && entry.captures.as_slice() == captures
+ })?;
+ self.hint_fingerprints[hint_slot] = fingerprint;
+ self.hint_indices_plus_one[hint_slot] = (index + 1) as u8;
+ Some(self.touch(index))
+ }
+
+ fn insert(&mut self, fingerprint: u64, captures: Vec, closure: *mut ClosureHeader) {
+ self.clock = self.clock.wrapping_add(1);
+ let entry = CapturedClosureEntry {
+ fingerprint,
+ captures,
+ closure,
+ last_used: self.clock,
+ };
+ let index = if self.entries.len() < MAX_CAPTURED_CLOSURE_SLOTS {
+ let index = self.entries.len();
+ self.entries.push(entry);
+ index
+ } else {
+ let (index, _) = self
+ .entries
+ .iter()
+ .enumerate()
+ .min_by_key(|(_, entry)| entry.last_used)
+ .expect("full captured-closure cache must have an LRU entry");
+ self.entries[index] = entry;
+ index
+ };
+ let hint_slot = Self::hint_slot(fingerprint);
+ self.hint_fingerprints[hint_slot] = fingerprint;
+ self.hint_indices_plus_one[hint_slot] = (index + 1) as u8;
+ }
+
+ fn clear_hints(&mut self) {
+ self.hint_indices_plus_one.fill(0);
+ }
+}
+
+#[cfg(test)]
+mod captured_closure_cache_tests {
+ use super::*;
+
+ fn fake_closure(id: usize) -> *mut ClosureHeader {
+ // The cache treats these as opaque values. No test dereferences them.
+ (0x1000 + id * std::mem::align_of::()) as *mut ClosureHeader
+ }
+
+ #[test]
+ fn direct_hint_collision_falls_back_to_exact_capture_match() {
+ let first = [0u64];
+ let first_fingerprint = capture_fingerprint(&first);
+ let first_slot = CapturedClosureCache::hint_slot(first_fingerprint);
+ let second_word = (1..10_000u64)
+ .find(|&word| {
+ let fingerprint = capture_fingerprint(&[word]);
+ fingerprint != first_fingerprint
+ && CapturedClosureCache::hint_slot(fingerprint) == first_slot
+ })
+ .expect("the bounded search must find a direct-hint collision");
+ let second = [second_word];
+ let second_fingerprint = capture_fingerprint(&second);
+
+ let mut cache = CapturedClosureCache::new();
+ cache.insert(first_fingerprint, first.to_vec(), fake_closure(1));
+ cache.insert(second_fingerprint, second.to_vec(), fake_closure(2));
+
+ // Inserting `second` displaced `first` from their shared hint slot.
+ // Both lookups must still find their exact tuple via fallback, and
+ // each fallback must repair the hint for the next lookup.
+ assert_eq!(
+ cache.lookup(first_fingerprint, &first),
+ Some(fake_closure(1))
+ );
+ assert_eq!(
+ cache.lookup(first_fingerprint, &first),
+ Some(fake_closure(1))
+ );
+ assert_eq!(
+ cache.lookup(second_fingerprint, &second),
+ Some(fake_closure(2))
+ );
+ assert_eq!(cache.lookup(first_fingerprint, &second), None);
+ }
+
+ #[test]
+ fn full_cache_evicts_least_recently_used_entry() {
+ let mut cache = CapturedClosureCache::new();
+ for word in 0..MAX_CAPTURED_CLOSURE_SLOTS as u64 {
+ let captures = vec![word];
+ cache.insert(
+ capture_fingerprint(&captures),
+ captures,
+ fake_closure(word as usize + 1),
+ );
+ }
+
+ let retained = [0u64];
+ assert_eq!(
+ cache.lookup(capture_fingerprint(&retained), &retained),
+ Some(fake_closure(1))
+ );
+
+ let replacement = [MAX_CAPTURED_CLOSURE_SLOTS as u64];
+ cache.insert(
+ capture_fingerprint(&replacement),
+ replacement.to_vec(),
+ fake_closure(MAX_CAPTURED_CLOSURE_SLOTS + 1),
+ );
+
+ let evicted = [1u64];
+ assert_eq!(cache.lookup(capture_fingerprint(&evicted), &evicted), None);
+ assert_eq!(
+ cache.lookup(capture_fingerprint(&retained), &retained),
+ Some(fake_closure(1))
+ );
+ assert_eq!(
+ cache.lookup(capture_fingerprint(&replacement), &replacement),
+ Some(fake_closure(MAX_CAPTURED_CLOSURE_SLOTS + 1))
+ );
+ }
+}
+
+#[inline]
+fn capture_fingerprint(captures: &[u64]) -> u64 {
+ // FNV-1a over fixed-width capture words. The cache never trusts this as an
+ // identity: it only avoids calling slice equality (and its outlined
+ // `memcmp`) for entries that cannot possibly match.
+ let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ captures.len() as u64;
+ for &word in captures {
+ hash ^= word;
+ hash = hash.wrapping_mul(0x0100_0000_01b3);
+ }
+ hash
+}
+
/// Header for heap-allocated closures
#[repr(C)]
pub struct ClosureHeader {
@@ -222,13 +406,21 @@ pub fn scan_singleton_closure_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi
});
SINGLETON_CAPTURED_CLOSURES.with(|s| {
let mut captured = s.borrow_mut();
- for slots in captured.values_mut() {
- for (capture_key, closure) in slots.iter_mut() {
- visitor.visit_raw_mut_ptr_slot(closure);
- for word in capture_key.iter_mut() {
+ for cache in captured.values_mut() {
+ for entry in cache.entries.iter_mut() {
+ visitor.visit_raw_mut_ptr_slot(&mut entry.closure);
+ for word in entry.captures.iter_mut() {
visitor.visit_heap_word_u64_slot(word);
}
+ // A copying collection may have rewritten pointer-bearing
+ // capture words. Keep the non-semantic prefilter synchronized
+ // with the exact tuple that remains authoritative.
+ entry.fingerprint = capture_fingerprint(&entry.captures);
}
+ // Fingerprints and exact tuples were just rewritten in place. A
+ // hint is disposable acceleration state; clearing avoids an
+ // address-derived pre-GC fingerprint/index ever being consulted.
+ cache.clear_hints();
}
});
}
@@ -256,8 +448,8 @@ pub(crate) fn test_seed_captured_singleton_closure_cache(
SINGLETON_CAPTURED_CLOSURES.with(|s| {
s.borrow_mut()
.entry(func_ptr as usize)
- .or_insert_with(Vec::new)
- .insert(0, (capture_key, closure));
+ .or_insert_with(CapturedClosureCache::new)
+ .insert(capture_fingerprint(&capture_key), capture_key, closure);
});
}
@@ -275,7 +467,13 @@ pub(crate) fn test_captured_singleton_closure_cache_entries(
SINGLETON_CAPTURED_CLOSURES.with(|s| {
s.borrow()
.get(&(func_ptr as usize))
- .cloned()
+ .map(|cache| {
+ cache
+ .entries
+ .iter()
+ .map(|entry| (entry.captures.clone(), entry.closure))
+ .collect()
+ })
.unwrap_or_default()
})
}
@@ -331,6 +529,7 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton(
} else {
unsafe { std::slice::from_raw_parts(captures_ptr, n) }
};
+ let fingerprint = capture_fingerprint(captures_slice);
// Adaptive bypass: if this func_ptr has missed the cache N times in
// a row, skip the cache entirely. Async-step closures (`__step` /
@@ -362,25 +561,15 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton(
return allocated;
}
- // Fast path: scan the per-`func_ptr` slot list looking for a
- // matching capture-tuple. We touch only the cached `Vec` (small,
- // bounded by MAX_CAPTURED_CLOSURE_SLOTS). The match check is
- // bit-equality of u64 capture slots — same as a plain primitive
- // value comparison. Move the matched entry to the front to keep
- // recency information for the LRU eviction policy below.
+ // Fast path: use the tuple fingerprint's direct-mapped hint, then exact
+ // bit-equality of every capture slot. Hint collisions fall back to the
+ // bounded entry scan and repair the hint. Entries carry a monotonic
+ // last-use timestamp, so lookups no longer memmove the Vec yet full-cache
+ // eviction preserves the same least-recently-used policy.
if let Some(cached) = SINGLETON_CAPTURED_CLOSURES.with(|s| {
let mut s = s.borrow_mut();
- if let Some(slots) = s.get_mut(&(func_ptr as usize)) {
- for i in 0..slots.len() {
- if slots[i].0.as_slice() == captures_slice {
- let entry = slots.remove(i);
- let ptr = entry.1;
- slots.insert(0, entry);
- return Some(ptr);
- }
- }
- }
- None
+ s.get_mut(&(func_ptr as usize))
+ .and_then(|cache| cache.lookup(fingerprint, captures_slice))
}) {
crate::promise::bump(&CLOSURE_CAP_SINGLETON_HIT);
// Cache hit — reset the streak so a workload that briefly
@@ -419,11 +608,13 @@ pub extern "C" fn js_closure_alloc_with_captures_singleton(
}
SINGLETON_CAPTURED_CLOSURES.with(|s| {
let mut s = s.borrow_mut();
- let slots = s.entry(func_ptr as usize).or_insert_with(Vec::new);
- slots.insert(0, (rewritten_captures, allocated));
- if slots.len() > MAX_CAPTURED_CLOSURE_SLOTS {
- slots.truncate(MAX_CAPTURED_CLOSURE_SLOTS);
- }
+ s.entry(func_ptr as usize)
+ .or_insert_with(CapturedClosureCache::new)
+ .insert(
+ capture_fingerprint(&rewritten_captures),
+ rewritten_captures,
+ allocated,
+ );
});
// Bump the miss-streak counter; flip to disabled sentinel when we
// hit the threshold.
diff --git a/crates/perry/tests/issue_8774_argument_shape_clones.rs b/crates/perry/tests/issue_8774_argument_shape_clones.rs
new file mode 100644
index 0000000000..fc5fa382f9
--- /dev/null
+++ b/crates/perry/tests/issue_8774_argument_shape_clones.rs
@@ -0,0 +1,351 @@
+//! Exact-shape ordinary-argument clone-and-route coverage (#8774).
+//!
+//! The fast fixture pins compiler output and the public checksum. The semantic
+//! fixture drives every guard-failure family against Node, both normally and
+//! while the copying collector relocates live objects.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+use std::sync::Once;
+
+const GC_ENV_OVERRIDES: &[&str] = &[
+ "PERRY_GEN_GC",
+ "PERRY_GC_SCAVENGE",
+ "PERRY_GC_SCAVENGE_NURSERY_MB",
+ "PERRY_GC_MOVING_SAFEPOINT",
+ "PERRY_GC_MOVING_LOOP_POLLS",
+ "PERRY_GC_FORCE_EVACUATE",
+ "PERRY_GC_VERIFY_EVACUATION",
+ "PERRY_GC_DIAG",
+ "PERRY_CONSERVATIVE_STACK_SCAN",
+ "PERRY_WRITE_BARRIERS",
+ "PERRY_GC_INCREMENTAL",
+ "PERRY_GC_HEAP_LIMIT",
+];
+
+fn remove_gc_env_overrides(command: &mut Command) {
+ for key in GC_ENV_OVERRIDES {
+ command.env_remove(key);
+ }
+}
+
+fn perry_bin() -> PathBuf {
+ PathBuf::from(env!("CARGO_BIN_EXE_perry"))
+}
+
+fn workspace_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .canonicalize()
+ .expect("canonicalize workspace root")
+}
+
+fn target_debug_dir() -> PathBuf {
+ if let Some(dir) = std::env::var_os("PERRY_TEST_RUNTIME_DIR") {
+ return PathBuf::from(dir);
+ }
+ let target = std::env::var_os("CARGO_TARGET_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| workspace_root().join("target"));
+ if cfg!(windows) {
+ target.join("x86_64-pc-windows-msvc").join("debug")
+ } else {
+ target.join("debug")
+ }
+}
+
+fn ensure_runtime_archive() {
+ static BUILD_RUNTIME: Once = Once::new();
+ BUILD_RUNTIME.call_once(|| {
+ let runtime_dir = target_debug_dir();
+ let runtime_name = if cfg!(windows) {
+ "perry_runtime.lib"
+ } else {
+ "libperry_runtime.a"
+ };
+ let stdlib_name = if cfg!(windows) {
+ "perry_stdlib.lib"
+ } else {
+ "libperry_stdlib.a"
+ };
+ if runtime_dir.join(runtime_name).is_file() && runtime_dir.join(stdlib_name).is_file() {
+ return;
+ }
+ let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let mut command = Command::new(cargo);
+ command
+ .current_dir(workspace_root())
+ .arg("build")
+ .arg("-p")
+ .arg("perry-runtime-static")
+ .arg("-p")
+ .arg("perry-stdlib-static");
+ if cfg!(windows) {
+ command.arg("--target").arg("x86_64-pc-windows-msvc");
+ }
+ let build = command.output().expect("build static runtime archives");
+ assert_success("static runtime build", &build);
+ });
+}
+
+fn assert_success(label: &str, output: &Output) {
+ assert!(
+ output.status.success(),
+ "{label} failed\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+fn compile(dir: &Path, entry: &str, explain: bool) -> (PathBuf, Output) {
+ ensure_runtime_archive();
+ let output = dir.join(format!("{entry}.bin"));
+ let mut command = Command::new(perry_bin());
+ command
+ .current_dir(dir)
+ .arg("compile")
+ .arg(entry)
+ .arg("-o")
+ .arg(&output)
+ .arg("--no-cache")
+ .arg("--trace")
+ .arg("llvm")
+ .env("PERRY_NO_AUTO_OPTIMIZE", "1")
+ .env("PERRY_RUNTIME_DIR", target_debug_dir())
+ // The clone contract under test is the portable tagged shadow slot;
+ // this also avoids Windows' unsupported RS4GC + funclet-EH pairing in
+ // the exception fixture.
+ .env("PERRY_RS4GC", "0")
+ // Compile-time half of the precise-root moving-loop-poll route.
+ .env("PERRY_GC_MOVING_LOOP_POLLS", "1");
+ if explain {
+ command.arg("--opt-report=json").arg("--explain-lowering");
+ }
+ remove_gc_env_overrides(&mut command);
+ command.env("PERRY_GC_MOVING_LOOP_POLLS", "1");
+ let result = command.output().expect("run perry compile");
+ assert_success("perry compile", &result);
+ (output, result)
+}
+
+fn run(binary: &Path, dir: &Path, moving: bool) -> Output {
+ let mut command = Command::new(binary);
+ command.current_dir(dir);
+ remove_gc_env_overrides(&mut command);
+ if moving {
+ command
+ .env("PERRY_GC_SCAVENGE", "1")
+ .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1")
+ .env("PERRY_GC_MOVING_LOOP_POLLS", "1")
+ .env("PERRY_GC_FORCE_EVACUATE", "1")
+ .env("PERRY_GC_VERIFY_EVACUATION", "1")
+ .env("PERRY_GC_INCREMENTAL", "0")
+ .env("PERRY_CONSERVATIVE_STACK_SCAN", "off")
+ .env("PERRY_GC_DIAG", "1");
+ }
+ let output = command.output().expect("run compiled fixture");
+ assert_success("compiled fixture", &output);
+ output
+}
+
+fn run_node(dir: &Path, entry: &str) -> Output {
+ let output = Command::new("node")
+ .current_dir(dir)
+ .arg(entry)
+ .output()
+ .expect("run Node semantic oracle");
+ assert_success("Node semantic oracle", &output);
+ output
+}
+
+fn copy_minor_relocated_objects(stderr: &str) -> u64 {
+ stderr
+ .lines()
+ .filter_map(|line| line.strip_prefix("[gc-copy-minor] ran "))
+ .map(|fields| {
+ let mut in_place = false;
+ let mut copied = 0;
+ let mut promoted = 0;
+ for field in fields.split_whitespace() {
+ let Some((key, value)) = field.split_once('=') else {
+ continue;
+ };
+ match key {
+ "in_place" => in_place = value == "true",
+ "copied_objects" => copied = value.parse::().unwrap_or(0),
+ "promoted_objects" => promoted = value.parse::().unwrap_or(0),
+ _ => {}
+ }
+ }
+ if in_place {
+ 0
+ } else {
+ copied + promoted
+ }
+ })
+ .sum()
+}
+
+fn function_body<'a>(ir: &'a str, marker: &str) -> &'a str {
+ let start = ir
+ .match_indices("define ")
+ .find(|(index, _)| {
+ let end = ir[*index..]
+ .find('\n')
+ .map(|offset| index + offset)
+ .unwrap_or(ir.len());
+ ir[*index..end].contains(marker)
+ })
+ .map(|(index, _)| index)
+ .unwrap_or_else(|| panic!("missing function {marker}:\n{ir}"));
+ let end = ir[start..]
+ .find("\n}")
+ .map(|offset| start + offset)
+ .expect("function terminator");
+ &ir[start..end]
+}
+
+fn fixture_dir() -> PathBuf {
+ workspace_root().join("test-files/fixtures/issue_8774_argument_shapes")
+}
+
+fn copy_semantic_fixture(dir: &Path) {
+ for file in ["package.json", "foreign.ts", "barrel.ts", "main.ts"] {
+ std::fs::copy(fixture_dir().join(file), dir.join(file))
+ .unwrap_or_else(|error| panic!("copy {file}: {error}"));
+ }
+}
+
+fn read_native_records(dir: &Path) -> Vec {
+ let lowering = dir.join(".perry-trace/lowering");
+ let run_dir = std::fs::read_dir(&lowering)
+ .expect("read lowering directory")
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .find(|path| path.is_dir())
+ .expect("lowering run directory");
+ let mut records = Vec::new();
+ for entry in std::fs::read_dir(run_dir).expect("read lowering run") {
+ let path = entry.expect("lowering entry").path();
+ let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
+ continue;
+ };
+ if !name.starts_with("perry_native_reps_") || !name.ends_with(".json") {
+ continue;
+ }
+ let artifact: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(&path).expect("read native records"))
+ .expect("parse native records");
+ records.extend(
+ artifact["records"]
+ .as_array()
+ .expect("native record array")
+ .iter()
+ .cloned(),
+ );
+ }
+ records
+}
+
+#[test]
+fn stable_argument_clones_are_direct_reported_and_moving_gc_safe() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ std::fs::copy(
+ workspace_root().join("test-files/test_issue_8774_argument_shape_clones.ts"),
+ temp.path().join("valid.ts"),
+ )
+ .expect("copy valid fixture");
+ let (binary, compile_output) = compile(temp.path(), "valid.ts", true);
+
+ assert_eq!(run_node(temp.path(), "valid.ts").stdout, b"20000500000\n");
+ assert_eq!(run(&binary, temp.path(), false).stdout, b"20000500000\n");
+ let moving = run(&binary, temp.path(), true);
+ assert_eq!(moving.stdout, b"20000500000\n");
+ let diagnostics = String::from_utf8_lossy(&moving.stderr);
+ assert!(
+ copy_minor_relocated_objects(&diagnostics) > 0,
+ "forced-moving arm relocated no object:\n{diagnostics}"
+ );
+
+ let compiler_stdout = String::from_utf8_lossy(&compile_output.stdout);
+ assert!(
+ !compiler_stdout.contains("passed as a call argument"),
+ "valid route retained the retired containment denial:\n{compiler_stdout}"
+ );
+
+ let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/valid_ts.ll"))
+ .expect("read valid LLVM IR");
+ for method in ["add", "hash", "clear"] {
+ let public = format!("perry_method_valid_ts__Registry__{method}");
+ let clone = format!("{public}$pshape_args");
+ let clone_body = function_body(&ir, &format!("@{clone}("));
+ assert!(
+ clone_body.contains("getelementptr double") && clone_body.contains("inttoptr i64"),
+ "{clone} must use fixed-offset field access:\n{clone_body}"
+ );
+ assert!(
+ !clone_body.contains("shape_descriptor_by_id")
+ && !clone_body.contains("js_typed_feedback_class_field_get_guard"),
+ "{clone} rebuilt a field IC diamond:\n{clone_body}"
+ );
+ assert!(ir.contains(&format!("call double @{clone}(")));
+ assert!(ir.contains(&format!("call double @{public}(")));
+ }
+ assert!(ir.contains("pshape_arg.fallback"));
+
+ let records = read_native_records(temp.path());
+ for method in ["add", "hash", "clear"] {
+ let suffix = format!("Registry__{method}$pshape_args");
+ assert!(
+ records.iter().any(|record| {
+ record["consumer"] == "proven_shape_argument_method_call"
+ && record["notes"].as_array().is_some_and(|notes| {
+ notes.iter().any(|note| {
+ note.as_str().is_some_and(|note| {
+ note.starts_with("argument_clone=") && note.ends_with(&suffix)
+ })
+ })
+ })
+ }),
+ "missing explain-lowering selection for {method}: {records:#?}"
+ );
+ }
+}
+
+#[test]
+fn guard_failures_match_node_and_unsafe_parameters_stay_generic() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ copy_semantic_fixture(temp.path());
+ let (binary, _) = compile(temp.path(), "main.ts", false);
+ let node = run_node(temp.path(), "main.ts");
+ let ordinary = run(&binary, temp.path(), false);
+ let moving = run(&binary, temp.path(), true);
+ assert_eq!(
+ ordinary.stdout, node.stdout,
+ "ordinary Perry differs from Node"
+ );
+ assert_eq!(
+ moving.stdout, node.stdout,
+ "moving-GC Perry differs from Node"
+ );
+
+ let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/main_ts.ll"))
+ .expect("read semantic LLVM IR");
+ for method in ["read", "throws"] {
+ assert!(
+ ir.contains(&format!("Registry__{method}$pshape_args")),
+ "safe declared-field method should get a clone: {method}\n{ir}"
+ );
+ }
+ for method in ["alias", "reassign"] {
+ assert!(
+ !ir.contains(&format!("Registry__{method}$pshape_args")),
+ "aliased/reassigned parameter must stay generic: {method}\n{ir}"
+ );
+ }
+ assert!(
+ !ir.contains("ForeignReader__read$pshape_args"),
+ "an imported argument class must stay on the generic route:\n{ir}"
+ );
+ assert!(ir.contains("pshape_arg.fallback"));
+}
diff --git a/crates/perry/tests/static_method_object_literal.rs b/crates/perry/tests/static_method_object_literal.rs
new file mode 100644
index 0000000000..453ba9d593
--- /dev/null
+++ b/crates/perry/tests/static_method_object_literal.rs
@@ -0,0 +1,142 @@
+//! Runtime regression for direct lowering of static-key method literals.
+//!
+//! Method-only object literals without a `super` home dependency can use the
+//! ordinary final-shape object path instead of a synthetic builder IIFE. The
+//! controls below cover the source-ordered forms that must retain the IIFE.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+use std::sync::Once;
+
+fn perry_bin() -> PathBuf {
+ PathBuf::from(env!("CARGO_BIN_EXE_perry"))
+}
+
+fn workspace_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .canonicalize()
+ .expect("canonicalize workspace root")
+}
+
+fn runtime_dir() -> PathBuf {
+ static BUILD_RUNTIME: Once = Once::new();
+ BUILD_RUNTIME.call_once(|| {
+ let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let mut command = Command::new(cargo);
+ command.current_dir(workspace_root()).arg("build");
+ if !cfg!(debug_assertions) {
+ command.arg("--release");
+ }
+ let build = command
+ .args(["-p", "perry-runtime-static"])
+ .output()
+ .expect("build static runtime archive");
+ assert!(
+ build.status.success(),
+ "static runtime build failed\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&build.stdout),
+ String::from_utf8_lossy(&build.stderr)
+ );
+ });
+
+ perry_bin()
+ .parent()
+ .expect("Perry binary directory")
+ .to_path_buf()
+}
+
+fn run_fixture(binary: &Path, force_evacuation: bool) -> Output {
+ let mut command = Command::new(binary);
+ if force_evacuation {
+ command
+ .env("PERRY_GC_FORCE_EVACUATE", "1")
+ .env("PERRY_GC_VERIFY_EVACUATION", "1");
+ } else {
+ command
+ .env_remove("PERRY_GC_FORCE_EVACUATE")
+ .env_remove("PERRY_GC_VERIFY_EVACUATION");
+ }
+ command.output().expect("run method-literal fixture")
+}
+
+#[test]
+fn direct_method_literal_preserves_semantics_and_iife_controls() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let entry = dir.path().join("main.ts");
+ let binary = dir.path().join("main_bin");
+ std::fs::write(
+ &entry,
+ r#"
+let order = "";
+function evaluated(label: string, value: number): number {
+ order += label;
+ const churn: any[] = [];
+ for (let i = 0; i < 256; i++) churn.push({ i });
+ return value;
+}
+
+const outer = { offset: 7 };
+const fast: any = {
+ first: evaluated("a", 1),
+ captured(x: number) { return outer.offset + x; },
+ dynamicThis(x: number) { return this.first + x; },
+ last: evaluated("b", 3),
+};
+
+const base: any = { read() { return 10; } };
+const withSuper: any = { read() { return super.read() + 1; } };
+Object.setPrototypeOf(withSuper, base);
+
+const computedKey = "computed";
+const computed: any = { [computedKey]() { return 9; } };
+
+let getterCalls = 0;
+const accessor: any = {
+ get value() { getterCalls++; return 11; },
+};
+
+const spread: any = { ...{ x: 1 }, method() { return 2; } };
+
+console.log(
+ order + ":" + Object.keys(fast).join(",") + ":" + fast.captured(5) + ":" +
+ fast.dynamicThis(5) + ":" + fast.captured.name + ":" + fast.dynamicThis.name +
+ ":" + withSuper.read() + ":" + computed.computed() + ":" + accessor.value +
+ ":" + getterCalls + ":" + (spread.x + spread.method()),
+);
+"#,
+ )
+ .expect("write method-literal fixture");
+
+ let compile = Command::new(perry_bin())
+ .current_dir(dir.path())
+ .arg("compile")
+ .arg(&entry)
+ .arg("-o")
+ .arg(&binary)
+ .arg("--no-cache")
+ .arg("--no-auto-optimize")
+ .env("PERRY_RUNTIME_DIR", runtime_dir())
+ .output()
+ .expect("compile method-literal fixture");
+ assert!(
+ compile.status.success(),
+ "compile failed\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&compile.stdout),
+ String::from_utf8_lossy(&compile.stderr)
+ );
+
+ for force_evacuation in [false, true] {
+ let run = run_fixture(&binary, force_evacuation);
+ assert!(
+ run.status.success(),
+ "fixture failed (force_evacuation={force_evacuation})\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&run.stdout),
+ String::from_utf8_lossy(&run.stderr)
+ );
+ assert_eq!(
+ String::from_utf8_lossy(&run.stdout),
+ "ab:first,captured,dynamicThis,last:12:6:captured:dynamicThis:11:9:11:1:3\n"
+ );
+ }
+}
diff --git a/test-files/fixtures/issue_8774_argument_shapes/barrel.ts b/test-files/fixtures/issue_8774_argument_shapes/barrel.ts
new file mode 100644
index 0000000000..86a527d5b9
--- /dev/null
+++ b/test-files/fixtures/issue_8774_argument_shapes/barrel.ts
@@ -0,0 +1 @@
+export { Foreign, installIdAccessor, makeProxy, reshape } from "./foreign.ts";
diff --git a/test-files/fixtures/issue_8774_argument_shapes/foreign.ts b/test-files/fixtures/issue_8774_argument_shapes/foreign.ts
new file mode 100644
index 0000000000..4bd3b251f5
--- /dev/null
+++ b/test-files/fixtures/issue_8774_argument_shapes/foreign.ts
@@ -0,0 +1,38 @@
+export class Foreign {
+ id: number;
+ components: number[];
+
+ constructor(id: number) {
+ this.id = id;
+ this.components = [1, 2, 3];
+ }
+}
+
+// These mutations deliberately live across an import/re-export boundary.
+// The caller module may still emit an argument clone, but the runtime exact-
+// shape/descriptor guard must send the changed object to its generic body.
+export function reshape(value: any): void {
+ const id = value.id;
+ delete value.id;
+ value.id = id + 1;
+}
+
+export function installIdAccessor(value: any): void {
+ const id = value.id;
+ Object.defineProperty(value, "id", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return id + 2;
+ },
+ });
+}
+
+export function makeProxy(value: any, counter: any): any {
+ return new Proxy(value, {
+ get(target: any, key: any): any {
+ counter.hits++;
+ return target[key];
+ },
+ });
+}
diff --git a/test-files/fixtures/issue_8774_argument_shapes/main.ts b/test-files/fixtures/issue_8774_argument_shapes/main.ts
new file mode 100644
index 0000000000..d1a99538ee
--- /dev/null
+++ b/test-files/fixtures/issue_8774_argument_shapes/main.ts
@@ -0,0 +1,112 @@
+import {
+ Foreign,
+ installIdAccessor,
+ makeProxy,
+ reshape,
+} from "./barrel.ts";
+
+class Entity {
+ id: number;
+ components: number[];
+
+ constructor(id: number) {
+ this.id = id;
+ this.components = [];
+ }
+}
+
+class SubEntity extends Entity {}
+
+let accessorHits = 0;
+class AccessorEntity {
+ get id(): number {
+ accessorHits++;
+ return 7;
+ }
+
+ get components(): number[] {
+ accessorHits++;
+ return [4, 5];
+ }
+}
+
+class Registry {
+ read(entity: Entity): number {
+ return entity.id + entity.components.length;
+ }
+
+ throws(entity: Entity): number {
+ const id = entity.id;
+ throw "boom-" + id;
+ }
+
+ // Bare alias/reassignment uses must keep these methods generic.
+ alias(entity: Entity): number {
+ const alias = entity;
+ return alias.id;
+ }
+
+ reassign(entity: Entity): number {
+ entity = new Entity(99);
+ return entity.id;
+ }
+}
+
+class ForeignReader {
+ // Imported class metadata is intentionally not a local clone capability.
+ read(entity: Foreign): number {
+ return entity.id + entity.components.length;
+ }
+}
+
+const registry = new Registry();
+const results: any[] = [];
+
+const exact = new Entity(2);
+exact.components.push(1, 2);
+results.push(registry.read(exact));
+
+// Wrong class and multiple caller layouts exercise the explicit generic arm.
+results.push(registry.read(({ id: 5, components: [1] } as any) as Entity));
+const subclass = new SubEntity(3);
+subclass.components.push(8);
+results.push(registry.read(subclass));
+
+const changed = new Entity(4);
+changed.components.push(1);
+(changed as any).extra = true;
+results.push(registry.read(changed));
+
+results.push(registry.read((new AccessorEntity() as any) as Entity));
+
+const proxyCounter = { hits: 0 };
+const proxied = makeProxy(new Entity(6), proxyCounter);
+results.push(registry.read((proxied as any) as Entity));
+
+try {
+ registry.throws(new Entity(8));
+} catch (error) {
+ results.push(error);
+}
+
+results.push(registry.alias(new Entity(9)));
+results.push(registry.reassign(new Entity(10)));
+results.push(new ForeignReader().read(new Foreign(11)));
+
+const reshaped = new Entity(12);
+reshape(reshaped);
+results.push(registry.read(reshaped));
+
+const descriptor = new Entity(14);
+installIdAccessor(descriptor);
+results.push(registry.read(descriptor));
+
+// Own-method replacement must stay ahead of every argument clone route.
+(registry as any).read = function (entity: any): number {
+ return entity.id * 10;
+};
+results.push(registry.read(new Entity(16)));
+
+console.log(
+ JSON.stringify({ results, accessorHits, proxyHits: proxyCounter.hits }),
+);
diff --git a/test-files/fixtures/issue_8774_argument_shapes/package.json b/test-files/fixtures/issue_8774_argument_shapes/package.json
new file mode 100644
index 0000000000..3dbc1ca591
--- /dev/null
+++ b/test-files/fixtures/issue_8774_argument_shapes/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/test-files/test_issue_8774_argument_shape_clones.ts b/test-files/test_issue_8774_argument_shape_clones.ts
new file mode 100644
index 0000000000..daf2489bb8
--- /dev/null
+++ b/test-files/test_issue_8774_argument_shape_clones.ts
@@ -0,0 +1,40 @@
+// #8774: an exact-shape object passed in an ordinary argument position must
+// reach Registry's tagged `$pshape_args` clones. The hot bodies may access
+// Entity's declared fields directly; every other runtime shape stays on the
+// generic method entry.
+class Entity {
+ constructor(id) {
+ this.id = id;
+ this.components = [];
+ }
+}
+
+class Registry {
+ add(entity, component) {
+ entity.components.push(component);
+ }
+
+ hash(entity) {
+ let value = entity.id;
+ for (let i = 0; i < entity.components.length; i++) {
+ value += entity.components[i];
+ }
+ return value;
+ }
+
+ clear(entity) {
+ entity.components.length = 0;
+ }
+}
+
+const registry = new Registry();
+let checksum = 0;
+const iterations = 200_000;
+for (let i = 0; i < iterations; i++) {
+ const entity = new Entity(i);
+ registry.add(entity, 1);
+ registry.add(entity, 2);
+ checksum += registry.hash(entity);
+ registry.clear(entity);
+}
+console.log(checksum);