diff --git a/changelog.d/8094-guarded-parameter-specialization.md b/changelog.d/8094-guarded-parameter-specialization.md
new file mode 100644
index 0000000000..7079b71ded
--- /dev/null
+++ b/changelog.d/8094-guarded-parameter-specialization.md
@@ -0,0 +1,22 @@
+### Performance
+
+- Recover ordinary typed-parameter optimization in runtime-guarded function clones while preserving the conservative generic fallback for erased or lying TypeScript annotations (#8079, #8094).
+
+ A guard-eligible module function now has three symbols: a `noinline` routing
+ trampoline keeping the public name and the JSValue ABI, the unchanged
+ `$generic` body, and a `$spec_*` clone that receives parameter proofs only
+ after `js_param_type_guard` (or a raw scalar guard) accepted the live
+ argument at that entry. Reference-typed parameters lose their proof across
+ any call in the body, because unknown code can reach the same object through
+ an alias the caller arranged before entry.
+
+### Fixed
+
+- `has_any_mutation` was gating the SPEC-ABI `demoted` mask, which also drives
+ raw representation selection, so writing through a parameter (`values[i] = v`)
+ demoted its raw slot and deleted specializations that predate this work — a
+ `Float64Array` fill lost its `$spec_ta…_i32` entry entirely. Content mutation
+ invalidates a descriptor PROOF, not a calling convention, so it moved to the
+ guard-only `guard_blocked` mask. The emitted guard set is unchanged by
+ construction (the two masks are OR-ed in the same predicate); only the
+ representation is restored (#8094).
diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs
index 318557a82f..bf48cdbe8a 100644
--- a/crates/perry-codegen/src/codegen/closure.rs
+++ b/crates/perry-codegen/src/codegen/closure.rs
@@ -905,6 +905,7 @@ pub(super) fn compile_closure(
locals,
local_types,
proven_local_types: std::collections::HashMap::new(),
+ guarded_discriminant_aliases: std::collections::HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals,
const_string_locals: std::collections::HashMap::new(),
@@ -1026,6 +1027,7 @@ pub(super) fn compile_closure(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
diff --git a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs
index 77306836ed..9c38915e64 100644
--- a/crates/perry-codegen/src/codegen/declared_string_add_tests.rs
+++ b/crates/perry-codegen/src/codegen/declared_string_add_tests.rs
@@ -98,7 +98,32 @@ fn module_with(function: Function) -> Module {
fn ir(params: Vec, body: Expr) -> String {
let module = module_with(probe_fn(params, body));
- String::from_utf8(compile_module(&module, ir_opts()).unwrap()).expect("LLVM IR is UTF-8")
+ let ir =
+ String::from_utf8(compile_module(&module, ir_opts()).unwrap()).expect("LLVM IR is UTF-8");
+ // An ordinary typed parameter may now produce a public guard wrapper plus
+ // proof-bearing and generic clones (#8079). This suite's subject remains
+ // the annotation-distrusting body, so inspect the generic clone when one
+ // exists instead of letting the validated clone satisfy a negative check.
+ let generic = "perry_fn_declared_string_add_ts__probe$generic";
+ let public = "perry_fn_declared_string_add_ts__probe";
+ let symbol = if ir.contains(&format!("@{generic}(")) {
+ generic
+ } else {
+ public
+ };
+ let marker = format!("@{symbol}(");
+ let start = ir
+ .match_indices("define ")
+ .find_map(|(index, _)| {
+ let line_end = ir[index..].find('\n').map(|offset| index + offset)?;
+ ir[index..line_end].contains(&marker).then_some(index)
+ })
+ .expect("probe body");
+ let end = ir[start..]
+ .find("\n}\n")
+ .map(|offset| start + offset + 3)
+ .unwrap_or(ir.len());
+ ir[start..end].to_string()
}
fn add(left: Expr, right: Expr) -> Expr {
diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs
index aaca4a9eb7..540380c0bf 100644
--- a/crates/perry-codegen/src/codegen/entry.rs
+++ b/crates/perry-codegen/src/codegen/entry.rs
@@ -730,6 +730,7 @@ pub(super) fn compile_module_entry(
locals: HashMap::new(),
local_types: init_local_types,
proven_local_types: HashMap::new(),
+ guarded_discriminant_aliases: HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals: crate::collectors::reassigned_locals(&hir.init),
const_string_locals: HashMap::new(),
@@ -849,6 +850,7 @@ pub(super) fn compile_module_entry(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
@@ -1414,6 +1416,7 @@ pub(super) fn compile_module_entry(
locals: HashMap::new(),
local_types: HashMap::new(),
proven_local_types: HashMap::new(),
+ guarded_discriminant_aliases: HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals: crate::collectors::reassigned_locals(&hir.init),
const_string_locals: HashMap::new(),
@@ -1533,6 +1536,7 @@ pub(super) fn compile_module_entry(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs
index 4caba52ddb..3bfe32b51b 100644
--- a/crates/perry-codegen/src/codegen/function.rs
+++ b/crates/perry-codegen/src/codegen/function.rs
@@ -323,6 +323,139 @@ fn emit_public_typed_function_trampoline(
.ret(DOUBLE, &fallback_value);
}
+/// Public JSValue entry for a declaration-guarded full-body clone. Unknown
+/// and indirect callers always land here; the unchanged generic body is a
+/// separate internal fallback. Proven same-module call sites bypass this
+/// wrapper and call the specialized symbol directly.
+fn emit_public_spec_function_trampoline(
+ llmod: &mut LlModule,
+ f: &Function,
+ public_name: &str,
+ generic_body_name: &str,
+ plan: &SpecFnPlan,
+) {
+ let params: Vec<(LlvmType, String)> = f
+ .params
+ .iter()
+ .map(|p| (DOUBLE, format!("%arg{}", p.id)))
+ .collect();
+ let arg_names: Vec = f.params.iter().map(|p| format!("%arg{}", p.id)).collect();
+ let spec_name = spec_function_name(public_name, &plan.reps);
+ let wf = llmod.define_function(public_name, DOUBLE, params);
+ // This is deliberately an optimization boundary. Inlining a routing
+ // diamond into generic callers duplicates both the specialized and
+ // fallback call graphs and recreates the issue's performance cliff.
+ wf.no_inline = true;
+ let _ = wf.create_block("entry");
+
+ let mut guard: Option = None;
+ {
+ let blk = wf.block_mut(0).unwrap();
+ for ((arg, rep), descriptor) in arg_names
+ .iter()
+ .zip(plan.reps.iter())
+ .zip(plan.guards.iter())
+ {
+ let rep_guard = match rep {
+ crate::collectors::SpecParamRep::I32 => {
+ Some(emit_typed_arg_guard(blk, TypedParamRep::I32, arg))
+ }
+ crate::collectors::SpecParamRep::F64 => {
+ Some(emit_typed_arg_guard(blk, TypedParamRep::F64, arg))
+ }
+ crate::collectors::SpecParamRep::Boxed => None,
+ // Guarded plans never carry TaPtr: its raw pointer contract is
+ // admitted only by construction at a direct call site.
+ crate::collectors::SpecParamRep::TaPtr { .. } => Some("false".to_string()),
+ };
+ if let Some(ok) = rep_guard {
+ guard = Some(match guard {
+ Some(prev) => blk.and(I1, &prev, &ok),
+ None => ok,
+ });
+ }
+ if let Some(descriptor) = descriptor {
+ let raw = blk.call(
+ I32,
+ "js_param_type_guard",
+ &[
+ (DOUBLE, arg.as_str()),
+ (PTR, &format!("@{}", descriptor.descriptor_name)),
+ (I32, &descriptor.descriptor.len().to_string()),
+ ],
+ );
+ let ok = blk.icmp_ne(I32, &raw, "0");
+ guard = Some(match guard {
+ Some(prev) => blk.and(I1, &prev, &ok),
+ None => ok,
+ });
+ }
+ }
+ }
+
+ let Some(guard) = guard else {
+ // Plan construction requires either a raw scalar guard or an ordinary
+ // descriptor. Stay conservative if that invariant is ever weakened.
+ let call_args: Vec<(LlvmType, &str)> =
+ arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect();
+ let value = wf
+ .block_mut(0)
+ .unwrap()
+ .call(DOUBLE, generic_body_name, &call_args);
+ wf.block_mut(0).unwrap().ret(DOUBLE, &value);
+ return;
+ };
+
+ let fast_idx = wf.num_blocks();
+ let fast_label = wf.create_block("spec_public.fast").label.clone();
+ let fallback_idx = wf.num_blocks();
+ let fallback_label = wf.create_block("spec_public.fallback").label.clone();
+ wf.block_mut(0)
+ .unwrap()
+ .cond_br(&guard, &fast_label, &fallback_label);
+
+ let mut raw_args: Vec<(LlvmType, String)> = Vec::with_capacity(arg_names.len());
+ {
+ let blk = wf.block_mut(fast_idx).unwrap();
+ for (arg, rep) in arg_names.iter().zip(plan.reps.iter()) {
+ match rep {
+ crate::collectors::SpecParamRep::Boxed => {
+ raw_args.push((DOUBLE, arg.clone()));
+ }
+ crate::collectors::SpecParamRep::I32 => {
+ raw_args.push((I32, emit_typed_arg_to_raw(blk, TypedParamRep::I32, arg)))
+ }
+ crate::collectors::SpecParamRep::F64 => {
+ raw_args.push((DOUBLE, emit_typed_arg_to_raw(blk, TypedParamRep::F64, arg)))
+ }
+ crate::collectors::SpecParamRep::TaPtr { .. } => {
+ let bits = blk.bitcast_double_to_i64(arg);
+ raw_args.push((I64, blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64)));
+ }
+ }
+ }
+ }
+ let fast_args: Vec<(LlvmType, &str)> = raw_args
+ .iter()
+ .map(|(ty, arg)| (*ty, arg.as_str()))
+ .collect();
+ let fast_value = wf
+ .block_mut(fast_idx)
+ .unwrap()
+ .call(DOUBLE, &spec_name, &fast_args);
+ wf.block_mut(fast_idx).unwrap().ret(DOUBLE, &fast_value);
+
+ let fallback_args: Vec<(LlvmType, &str)> =
+ arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect();
+ let fallback_value =
+ wf.block_mut(fallback_idx)
+ .unwrap()
+ .call(DOUBLE, generic_body_name, &fallback_args);
+ wf.block_mut(fallback_idx)
+ .unwrap()
+ .ret(DOUBLE, &fallback_value);
+}
+
/// Compile a single user function into the module.
pub(super) fn compile_function(
llmod: &mut LlModule,
@@ -349,6 +482,15 @@ pub(super) fn compile_function(
.get(&f.id)
.cloned()
.ok_or_else(|| anyhow!("function name not resolved for {}", f.name))?;
+ let guarded_public_plan = if typed_public_trampoline.is_none() && spec_entry.is_none() {
+ cross_module
+ .spec_abi_functions
+ .get(&f.id)
+ .filter(|plan| matches!(plan.dispatch, crate::codegen::SpecDispatch::Guarded))
+ .cloned()
+ } else {
+ None
+ };
let llvm_name = if let Some(plan) = spec_entry {
// Spec entries are an ADDITIONAL internal symbol next to the ordinary
// public body — never combined with the typed_abi trampoline scheme
@@ -356,7 +498,7 @@ pub(super) fn compile_function(
debug_assert!(typed_public_trampoline.is_none());
debug_assert_eq!(plan.reps.len(), f.params.len());
spec_function_name(&public_llvm_name, &plan.reps)
- } else if typed_public_trampoline.is_some() {
+ } else if typed_public_trampoline.is_some() || guarded_public_plan.is_some() {
generic_function_body_name(&public_llvm_name)
} else {
public_llvm_name.clone()
@@ -384,7 +526,7 @@ pub(super) fn compile_function(
let ic_base = llmod.ic_counter;
let buffer_alias_base = llmod.buffer_alias_counter;
let lf = llmod.define_function(&llvm_name, DOUBLE, params);
- if typed_public_trampoline.is_some() || spec_entry.is_some() {
+ if typed_public_trampoline.is_some() || guarded_public_plan.is_some() || spec_entry.is_some() {
lf.linkage = "internal".to_string();
}
@@ -416,7 +558,13 @@ pub(super) fn compile_function(
// async-to-generator pre-pass (was_plain_async=true). Inlining the
// rewritten wrapper into its caller breaks GC-root coverage of the
// step closure's iter capture, hanging async chains (issue #447).
- if f.body.len() <= 8 && !f.is_async && !f.is_generator && !f.was_plain_async {
+ let specialized_entry = spec_entry.is_some();
+ if !specialized_entry
+ && f.body.len() <= 8
+ && !f.is_async
+ && !f.is_generator
+ && !f.was_plain_async
+ {
lf.force_inline = true;
}
// Inline-hot-small (PERRY_INLINE_HOT_SMALL, default ON): bias — do not
@@ -438,7 +586,8 @@ pub(super) fn compile_function(
// as `hot_loop_callee` (before the entry block exists and before any
// expression is lowered), for the same reason.
lf.alloc_hot = cross_module.alloc_hot_functions.contains(&f.id);
- if !lf.force_inline
+ if !specialized_entry
+ && !lf.force_inline
&& inline_hot_small_enabled()
&& (INLINE_HOT_SMALL_MIN..=inline_hot_small_size_cap()).contains(&f.body.len())
&& !f.is_async
@@ -615,6 +764,38 @@ pub(super) fn compile_function(
.collect()
})
.unwrap_or_default();
+ // A specialized body may consume parameter type evidence only when its
+ // entry contract established it. Raw reps are proven by construction;
+ // ordinary boxed params are proven by `js_param_type_guard` on the sole
+ // direct route to this clone. The public body still starts with an empty
+ // map and therefore remains the conservative fallback for annotation lies.
+ let spec_param_proofs: HashMap = spec_entry
+ .map(|plan| {
+ f.params
+ .iter()
+ .zip(plan.reps.iter())
+ .zip(plan.guards.iter())
+ .filter_map(|((param, rep), guard)| {
+ let proof = match (guard, rep) {
+ (Some(guard), _) => guard.proof.clone(),
+ (None, crate::collectors::SpecParamRep::I32) => {
+ perry_hir::types::Type::Int32
+ }
+ (None, crate::collectors::SpecParamRep::F64) => {
+ perry_hir::types::Type::Number
+ }
+ (None, crate::collectors::SpecParamRep::TaPtr { kind, .. }) => {
+ perry_hir::types::Type::Named(
+ spec_ta_kind_class_name(*kind)?.to_string(),
+ )
+ }
+ (None, crate::collectors::SpecParamRep::Boxed) => return None,
+ };
+ Some((param.id, proof))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
// `--opt-report` (#6952): attribute every representation decision the
// collectors below make to this function. No-op when the report is off.
//
@@ -713,7 +894,8 @@ pub(super) fn compile_function(
native_facts: &native_facts,
locals,
local_types,
- proven_local_types: std::collections::HashMap::new(),
+ proven_local_types: spec_param_proofs,
+ guarded_discriminant_aliases: HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals: crate::collectors::reassigned_locals(&f.body),
const_string_locals: std::collections::HashMap::new(),
@@ -831,6 +1013,7 @@ pub(super) fn compile_function(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
@@ -1106,6 +1289,8 @@ pub(super) fn compile_function(
}
if let Some(kind) = typed_public_trampoline {
emit_public_typed_function_trampoline(llmod, f, &public_llvm_name, &llvm_name, kind);
+ } else if let Some(plan) = guarded_public_plan.as_ref() {
+ emit_public_spec_function_trampoline(llmod, f, &public_llvm_name, &llvm_name, plan);
}
Ok(())
}
diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs
index 24f0ded25a..8532de0d92 100644
--- a/crates/perry-codegen/src/codegen/method.rs
+++ b/crates/perry-codegen/src/codegen/method.rs
@@ -428,6 +428,7 @@ pub(super) fn compile_method(
locals,
local_types,
proven_local_types: std::collections::HashMap::new(),
+ guarded_discriminant_aliases: std::collections::HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals: crate::collectors::reassigned_locals(&method.body),
const_string_locals: std::collections::HashMap::new(),
@@ -542,6 +543,7 @@ pub(super) fn compile_method(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
@@ -1491,6 +1493,7 @@ pub(super) fn compile_static_method(
locals,
local_types,
proven_local_types: std::collections::HashMap::new(),
+ guarded_discriminant_aliases: std::collections::HashMap::new(),
module_global_proven_types: &cross_module.module_global_proven_types,
reassigned_locals: crate::collectors::reassigned_locals(&f.body),
const_string_locals: std::collections::HashMap::new(),
@@ -1609,6 +1612,7 @@ pub(super) fn compile_static_method(
repsel_context_allows_canonical_str: repsel_str_allows,
repsel_str_ineligible_locals: repsel_str_ineligible,
spec_abi_functions: &cross_module.spec_abi_functions,
+ spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs
index 607f15fe0f..6e8ed74077 100644
--- a/crates/perry-codegen/src/codegen/mod.rs
+++ b/crates/perry-codegen/src/codegen/mod.rs
@@ -197,7 +197,11 @@ mod module_globals_emit;
#[cfg(test)]
mod number_exactness_tests;
mod opts;
+#[cfg(test)]
+mod ordinary_param_guard_tests;
+mod param_guard;
mod spec_abi;
+mod spec_return_proof;
mod string_pool;
#[cfg(test)]
mod testing_feature_gate_tests;
@@ -1906,6 +1910,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
// Phase 2 spec-ABI plans are selected AFTER the i64-specialization
// pass (mutual exclusion), below; start empty here.
spec_abi_functions: std::collections::HashMap::new(),
+ spec_return_proofs: std::collections::HashMap::new(),
spec_ta_bindings: std::collections::HashMap::new(),
typed_f64_functions,
typed_i32_functions,
@@ -2385,14 +2390,16 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
// BEFORE any rejection was constructed, so `--opt-report` said
// nothing at all about the function: indistinguishable from "not
// analysed" and from "analysed and denied". Say "moot" instead.
- let Some(sites) = spec_facts.call_sites.get(&f.id) else {
- reject(
- typed_abi::TypedCloneRejectionReason::SpecNoCallSites,
- &mut typed_clone_rejection_records,
- );
- continue;
- };
- if f.is_async || f.is_generator || f.was_plain_async {
+ let sites = spec_facts.call_sites.get(&f.id);
+ // A guard executed when a generator object is CREATED cannot
+ // prove the argument when its body later runs. Likewise, an
+ // async body may let external code mutate a reachable argument
+ // across `await`. Async functions with no suspension execute
+ // their entire body synchronously and are safe to clone.
+ if f.is_generator
+ || f.was_plain_async
+ || (f.is_async && param_guard::body_contains_await(&f.body))
+ {
reject(
typed_abi::TypedCloneRejectionReason::AsyncOrGenerator,
&mut typed_clone_rejection_records,
@@ -2457,23 +2464,115 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
.iter()
.map(|p| reassigned.contains(&p.id) || closure_refs.contains(&p.id))
.collect();
- let plan = match spec_abi::select_dominant_tuple(sites, f.params.len(), &demoted) {
- Some((reps, _matching_sites)) => SpecFnPlan {
- reps,
- dispatch: SpecDispatch::Static,
- },
+ // (#8094) A descriptor proof describes a heap object and is
+ // established once, at entry. Any call in this body can run code
+ // that reaches that same object — not only through an argument we
+ // hand over, but through any alias the caller arranged before we
+ // were entered (a global, a closure, a field of another live
+ // object). So a REFERENCE-typed parameter cannot keep its proof
+ // across a call. Primitive parameters are immune: a callee has no
+ // route to the caller's copy of a number, string or boolean.
+ //
+ // A write THROUGH the parameter in this body invalidates the same
+ // proof without any call being involved (`node.left = x`,
+ // `values[i] = v`), so it belongs here too — and ONLY here. It is
+ // a claim about the object's CONTENTS; a raw slot (I32 / F64 /
+ // TaPtr) makes no such claim, so putting this on `demoted` deletes
+ // representation choices that predate this PR. Measured: with it
+ // on `demoted`, `fill(values: Float64Array, nodes: number)` loses
+ // `fill$spec_ta7x10000_i32` entirely, because writing an element
+ // reads as "mutation" of the typed-array parameter.
+ let body_calls = crate::collectors::body_contains_call(&f.body);
+ let guard_blocked: Vec = f
+ .params
+ .iter()
+ .map(|p| {
+ crate::collectors::has_any_mutation(&f.body, p.id)
+ || (body_calls
+ && spec_return_proof::is_reference_like(
+ &cross_module.type_aliases,
+ &p.ty,
+ 0,
+ ))
+ })
+ .collect();
+ let declaration_guards = param_guard::declaration_guards(
+ f.id,
+ &module_prefix,
+ &f.params,
+ &demoted,
+ &guard_blocked,
+ &cross_module.type_aliases,
+ &cross_module.interfaces,
+ &class_table,
+ &class_ids,
+ );
+ let plan = match sites
+ .and_then(|sites| spec_abi::select_dominant_tuple(sites, f.params.len(), &demoted))
+ {
+ Some((reps, _matching_sites)) => {
+ // Raw slots already carry a by-construction proof. Boxed
+ // slots may additionally recover an ordinary declared
+ // parameter fact, but only through their runtime
+ // descriptor. TaPtr remains a construction-only ABI: an
+ // unknown/public caller has no equivalent cheap guard for
+ // the raw pointer + length contract, so mixed TaPtr plans
+ // keep their existing static-only route.
+ let guards: Vec<_> = if reps
+ .iter()
+ .any(|rep| matches!(rep, crate::collectors::SpecParamRep::TaPtr { .. }))
+ {
+ vec![None; reps.len()]
+ } else {
+ declaration_guards
+ .into_iter()
+ .zip(reps.iter())
+ .map(|(guard, rep)| {
+ matches!(rep, crate::collectors::SpecParamRep::Boxed)
+ .then_some(guard)
+ .flatten()
+ })
+ .collect()
+ };
+ let dispatch = if guards.iter().any(Option::is_some) {
+ SpecDispatch::Guarded
+ } else {
+ SpecDispatch::Static
+ };
+ SpecFnPlan {
+ reps,
+ dispatch,
+ guards,
+ }
+ }
None => {
- // Tier B: declaration-derived tuple, runtime-guarded
- // dispatch. Only viable with ≥1 declared-Int32 param.
+ // Tier B: declaration-derived tuple plus descriptors for
+ // boxed ordinary parameters. The raw tuple can be all
+ // Boxed here: the clone's win is its post-guard parameter
+ // facts rather than a calling-convention change.
let reps = spec_abi::declaration_tuple(&f.params, &demoted);
- if spec_abi::spec_tuple_is_viable(&reps) {
+ let guards: Vec<_> = declaration_guards
+ .into_iter()
+ .zip(reps.iter())
+ .map(|(guard, rep)| {
+ matches!(rep, crate::collectors::SpecParamRep::Boxed)
+ .then_some(guard)
+ .flatten()
+ })
+ .collect();
+ if spec_abi::spec_tuple_is_viable(&reps) || guards.iter().any(Option::is_some) {
SpecFnPlan {
reps,
dispatch: SpecDispatch::Guarded,
+ guards,
}
} else {
reject(
- typed_abi::TypedCloneRejectionReason::SpecTupleUnproven,
+ if sites.is_none() {
+ typed_abi::TypedCloneRejectionReason::SpecNoCallSites
+ } else {
+ typed_abi::TypedCloneRejectionReason::SpecTupleUnproven
+ },
&mut typed_clone_rejection_records,
);
continue;
@@ -2491,6 +2590,26 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result>
cross_module.spec_abi_functions.insert(f.id, plan);
}
cross_module.spec_ta_bindings = spec_facts.ta_bindings;
+ cross_module.spec_return_proofs = spec_return_proof::collect_proven_returns(
+ hir,
+ &cross_module.spec_abi_functions,
+ &cross_module.type_aliases,
+ );
+
+ // The descriptor is immutable rodata, not a GC object. Emit in HIR
+ // order (never HashMap order) so cache/object bytes stay deterministic.
+ for f in &hir.functions {
+ let Some(plan) = cross_module.spec_abi_functions.get(&f.id) else {
+ continue;
+ };
+ for guard in plan.guards.iter().flatten() {
+ llmod.add_named_string_constant(
+ &guard.descriptor_name,
+ guard.descriptor.len() + 1,
+ ¶m_guard::descriptor_llvm_literal(&guard.descriptor),
+ );
+ }
+ }
}
progress.checkpoint("locals, closures, and module globals analysis");
diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs
index 1151c285ba..9e232ca9c0 100644
--- a/crates/perry-codegen/src/codegen/opts.rs
+++ b/crates/perry-codegen/src/codegen/opts.rs
@@ -786,6 +786,11 @@ pub(crate) struct CrossModuleCtx {
/// entry (internal linkage, named by `spec_function_name`). Mutually
/// exclusive with the typed_abi clone families.
pub spec_abi_functions: std::collections::HashMap,
+ /// Declared return types independently verified from the specialized
+ /// body's guarded inputs and runtime-derived constructions. A direct call
+ /// result may become a local proof only when its arguments also route to
+ /// the matching specialized entry.
+ pub spec_return_proofs: std::collections::HashMap,
/// Phase 2 pre-pass: LocalIds proven to permanently hold one specific
/// non-view typed array (see `collectors/spec_abi_sites.rs`).
pub spec_ta_bindings: std::collections::HashMap,
diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs
new file mode 100644
index 0000000000..f3cb6844aa
--- /dev/null
+++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs
@@ -0,0 +1,567 @@
+//! #8079 — erased ordinary-parameter annotations may optimize only behind the
+//! public runtime guard. Pin the three-symbol contract and the recovered
+//! string lowering so a later routing refactor cannot silently seed the
+//! generic body or discard the proof-bearing clone.
+
+use crate::{compile_module, CompileOptions};
+use perry_hir::types::{ObjectType, PropertyInfo, Type};
+use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, Param, Stmt, TypeAlias};
+use std::collections::HashMap;
+
+fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str {
+ let start = ir
+ .match_indices("define ")
+ .find(|(index, _)| {
+ let line_end = ir[*index..]
+ .find('\n')
+ .map(|offset| index + offset)
+ .unwrap_or(ir.len());
+ ir[*index..line_end].contains(marker)
+ })
+ .map(|(index, _)| index)
+ .unwrap_or_else(|| panic!("missing function containing {marker}:\n{ir}"));
+ let end = ir[start..]
+ .find("\n}")
+ .map(|offset| start + offset)
+ .expect("function terminator");
+ &ir[start..end]
+}
+
+#[test]
+fn public_guard_routes_to_proof_clone_and_conservative_fallback() {
+ let payload = Type::Object(ObjectType {
+ name: Some("Payload".to_string()),
+ properties: HashMap::from([(
+ "label".to_string(),
+ PropertyInfo {
+ ty: Type::String,
+ optional: false,
+ readonly: false,
+ },
+ )]),
+ property_order: Some(vec!["label".to_string()]),
+ index_signature: None,
+ });
+ let render = Function {
+ id: 1,
+ name: "render".to_string(),
+ type_params: Vec::new(),
+ params: vec![Param {
+ id: 10,
+ name: "payload".to_string(),
+ ty: Type::Named("Payload".to_string()),
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }],
+ return_type: Type::String,
+ body: vec![Stmt::Return(Some(Expr::Binary {
+ op: BinaryOp::Add,
+ left: Box::new(Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(10)),
+ property: "label".to_string(),
+ byte_offset: 0,
+ }),
+ right: Box::new(Expr::String("!".to_string())),
+ }))],
+ 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,
+ };
+ let mut module = Module::new("ordinary_param_guard.ts");
+ module.type_aliases.push(TypeAlias {
+ id: 1,
+ name: "Payload".to_string(),
+ type_params: Vec::new(),
+ ty: payload.clone(),
+ is_exported: false,
+ });
+ module.functions.push(render);
+ // An unknown live value nominates the declaration-guarded plan but cannot
+ // provide a call-site proof. It must target the public wrapper.
+ module.init.push(Stmt::Expr(Expr::Call {
+ callee: Box::new(Expr::FuncRef(1)),
+ args: vec![Expr::Undefined],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }));
+
+ // The driver aggregates aliases into CompileOptions before codegen. Mirror
+ // that production boundary: Module::type_aliases is retained for HIR
+ // metadata, while CrossModuleCtx resolves Named types from this map.
+ let mut opts = CompileOptions {
+ emit_ir_only: true,
+ output_type: "executable".to_string(),
+ ..Default::default()
+ };
+ opts.type_aliases.insert("Payload".to_string(), payload);
+ let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+
+ let public = function_ir(&ir, "@perry_fn_ordinary_param_guard_ts__render(");
+ let specialized = function_ir(&ir, "$spec_b(");
+ let generic = function_ir(&ir, "$generic(");
+
+ assert!(public.lines().next().unwrap().contains(" noinline "));
+ assert!(public.contains("call i32 @js_param_type_guard("));
+ assert!(public.contains("$spec_b("));
+ assert!(public.contains("$generic("));
+ assert!(!generic.contains("js_param_type_guard"));
+ assert!(!specialized.contains("js_param_type_guard"));
+ assert!(
+ specialized.contains("call double @js_string_concat_box(")
+ || specialized.contains("call i64 @js_value_concat_string(")
+ || specialized.contains("call i64 @js_string_concat_value("),
+ "the successful clone must consume the guarded string field proof:\n{specialized}"
+ );
+ assert!(!specialized.contains("js_dynamic_string_or_number_add"));
+ // Keep #8033 intact: declaration annotations may still improve ordinary
+ // generic lowering. The safety boundary pinned here is that only the
+ // successful clone receives entry proofs, while the fallback contains no
+ // guard-derived facts or recursive guard call.
+}
+
+#[test]
+fn nonsuspending_async_function_needs_no_direct_call_site_for_its_guarded_clone() {
+ // An async body with no `await` runs to completion synchronously, so the
+ // entry guard still describes the live arguments when the body reads them.
+ // No direct call site is required: the public wrapper is the route.
+ //
+ // The parameters are PRIMITIVES. `guard_blocked` (see `compile_module`)
+ // refuses a descriptor proof for a reference-typed parameter in a body that
+ // can reach unknown code, and `lookup.has(...)` is such a reach — the third
+ // function below pins exactly that, so this fixture stays a test of the
+ // async rule instead of silently becoming a test of the generic path.
+ let payload = Type::Object(ObjectType {
+ name: Some("Payload".to_string()),
+ properties: HashMap::from([(
+ "label".to_string(),
+ PropertyInfo {
+ ty: Type::String,
+ optional: false,
+ readonly: false,
+ },
+ )]),
+ property_order: Some(vec!["label".to_string()]),
+ index_signature: None,
+ });
+ let map_type = Type::Generic {
+ base: "Map".to_string(),
+ type_args: vec![Type::String, Type::Number],
+ };
+ let render = Function {
+ id: 21,
+ name: "renderAsync".to_string(),
+ type_params: Vec::new(),
+ params: vec![
+ Param {
+ id: 210,
+ name: "label".to_string(),
+ ty: Type::String,
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ },
+ Param {
+ id: 211,
+ name: "weight".to_string(),
+ ty: Type::Number,
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ },
+ ],
+ return_type: Type::Boolean,
+ body: vec![
+ Stmt::Let {
+ id: 212,
+ name: "lookup".to_string(),
+ ty: map_type,
+ mutable: false,
+ init: Some(Expr::MapNew),
+ },
+ Stmt::Expr(Expr::MapSet {
+ map: Box::new(Expr::LocalGet(212)),
+ key: Box::new(Expr::LocalGet(210)),
+ value: Box::new(Expr::LocalGet(211)),
+ }),
+ Stmt::Return(Some(Expr::MapHas {
+ map: Box::new(Expr::LocalGet(212)),
+ key: Box::new(Expr::LocalGet(210)),
+ })),
+ ],
+ is_async: true,
+ is_generator: false,
+ is_strict: true,
+ is_exported: false,
+ captures: Vec::new(),
+ decorators: Vec::new(),
+ was_plain_async: false,
+ was_unrolled: false,
+ };
+ let mut module = Module::new("ordinary_param_guard_async.ts");
+ module.type_aliases.push(TypeAlias {
+ id: 1,
+ name: "Payload".to_string(),
+ type_params: Vec::new(),
+ ty: payload.clone(),
+ is_exported: false,
+ });
+ module.functions.push(render);
+ module.functions.push(Function {
+ id: 22,
+ name: "renderAfterAwait".to_string(),
+ type_params: Vec::new(),
+ params: vec![Param {
+ id: 220,
+ name: "payload".to_string(),
+ ty: Type::Named("Payload".to_string()),
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }],
+ return_type: Type::String,
+ body: vec![Stmt::Return(Some(Expr::Await(Box::new(
+ Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(220)),
+ property: "label".to_string(),
+ byte_offset: 0,
+ },
+ ))))],
+ is_async: true,
+ is_generator: false,
+ is_strict: true,
+ is_exported: false,
+ captures: Vec::new(),
+ decorators: Vec::new(),
+ was_plain_async: false,
+ was_unrolled: false,
+ });
+ // The discriminating negative for the primitive-parameter choice above: the
+ // SAME body shape with a reference-typed parameter gets no clone at all,
+ // because a call can reach that object through an alias the caller arranged
+ // before entry. If that rule is ever weakened, this row goes red rather
+ // than the fixture above silently starting to measure something else.
+ module.functions.push(Function {
+ id: 23,
+ name: "renderReferenceParam".to_string(),
+ type_params: Vec::new(),
+ params: vec![Param {
+ id: 230,
+ name: "payload".to_string(),
+ ty: Type::Named("Payload".to_string()),
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }],
+ return_type: Type::Boolean,
+ body: vec![
+ Stmt::Let {
+ id: 231,
+ name: "lookup".to_string(),
+ ty: Type::Generic {
+ base: "Map".to_string(),
+ type_args: vec![Type::String, Type::Number],
+ },
+ mutable: false,
+ init: Some(Expr::MapNew),
+ },
+ Stmt::Return(Some(Expr::MapHas {
+ map: Box::new(Expr::LocalGet(231)),
+ key: Box::new(Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(230)),
+ property: "label".to_string(),
+ byte_offset: 0,
+ }),
+ })),
+ ],
+ 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,
+ });
+
+ let mut opts = CompileOptions {
+ emit_ir_only: true,
+ output_type: "executable".to_string(),
+ ..Default::default()
+ };
+ opts.type_aliases.insert("Payload".to_string(), payload);
+ let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ let public = function_ir(&ir, "@perry_fn_ordinary_param_guard_async_ts__renderAsync(");
+ assert!(public.contains("call i32 @js_param_type_guard("));
+ assert_eq!(public.matches("call i32 @js_param_type_guard(").count(), 2);
+ assert!(public.contains("$spec_b_b("));
+ assert!(public.contains("$generic("));
+ let specialized = function_ir(&ir, "renderAsync$spec_b_b(");
+ let generic = function_ir(&ir, "renderAsync$generic(");
+ assert!(specialized.contains("@js_map_has_string_key("));
+ assert!(!specialized.contains("@js_map_has("));
+ assert!(generic.contains("@js_map_has("));
+ assert!(!generic.contains("@js_map_has_string_key("));
+
+ let suspended = function_ir(
+ &ir,
+ "@perry_fn_ordinary_param_guard_async_ts__renderAfterAwait(",
+ );
+ assert!(!suspended.contains("js_param_type_guard"));
+ assert!(!suspended.contains("$spec_"));
+
+ let reference_param = function_ir(
+ &ir,
+ "@perry_fn_ordinary_param_guard_async_ts__renderReferenceParam(",
+ );
+ assert!(
+ !ir.contains("renderReferenceParam$spec_") && !ir.contains("renderReferenceParam$generic"),
+ "a reference parameter in a body that can reach unknown code must not be guarded:\n{ir}"
+ );
+ assert!(
+ !reference_param.contains("js_param_type_guard")
+ && reference_param.contains("@js_map_has("),
+ "the unguarded body must keep the generic key lowering:\n{reference_param}"
+ );
+}
+
+#[test]
+fn guarded_discriminant_branch_narrows_a_union_parameter_inside_the_clone() {
+ // Renamed from `guarded_discriminant_branch_routes_recursive_field_to_clone`.
+ // The routing half of that name described a RECURSIVE union walk, which
+ // `guard_blocked` no longer admits: a call in the body can reach the
+ // guarded object through an alias the caller arranged before entry, so a
+ // reference-typed parameter cannot keep a descriptor proof across it. The
+ // narrowing machinery it was really exercising survives on a call-free
+ // body, and the recursive shape is kept below as the negative that pins
+ // the rule.
+ let node = Type::Union(vec![
+ Type::Object(ObjectType {
+ name: None,
+ properties: HashMap::from([
+ (
+ "kind".to_string(),
+ PropertyInfo {
+ ty: Type::StringLiteral("num".to_string()),
+ optional: false,
+ readonly: false,
+ },
+ ),
+ (
+ "num".to_string(),
+ PropertyInfo {
+ ty: Type::Number,
+ optional: false,
+ readonly: false,
+ },
+ ),
+ ]),
+ property_order: Some(vec!["kind".to_string(), "num".to_string()]),
+ index_signature: None,
+ }),
+ Type::Object(ObjectType {
+ name: None,
+ properties: HashMap::from([
+ (
+ "kind".to_string(),
+ PropertyInfo {
+ ty: Type::StringLiteral("bin".to_string()),
+ optional: false,
+ readonly: false,
+ },
+ ),
+ (
+ "left".to_string(),
+ PropertyInfo {
+ ty: Type::Named("Node".to_string()),
+ optional: false,
+ readonly: false,
+ },
+ ),
+ ]),
+ property_order: Some(vec!["kind".to_string(), "left".to_string()]),
+ index_signature: None,
+ }),
+ ]);
+ fn discriminant_let(id: u32, owner: u32) -> Stmt {
+ Stmt::Let {
+ id,
+ name: "kind".to_string(),
+ ty: Type::Any,
+ mutable: false,
+ init: Some(Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(owner)),
+ property: "kind".to_string(),
+ byte_offset: 0,
+ }),
+ }
+ }
+ let eval = Function {
+ id: 31,
+ name: "evalNode".to_string(),
+ type_params: Vec::new(),
+ params: vec![Param {
+ id: 310,
+ name: "node".to_string(),
+ ty: Type::Named("Node".to_string()),
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }],
+ return_type: Type::Number,
+ body: vec![
+ discriminant_let(311, 310),
+ // The arm returns, so its complement dominates the statement that
+ // follows. This pins the control-flow merge that interpreter-style
+ // chains of discriminator checks rely on.
+ Stmt::If {
+ condition: Expr::Compare {
+ op: CompareOp::Eq,
+ left: Box::new(Expr::LocalGet(311)),
+ right: Box::new(Expr::String("num".to_string())),
+ },
+ then_branch: vec![Stmt::Return(Some(Expr::Binary {
+ op: BinaryOp::Add,
+ left: Box::new(Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(310)),
+ property: "num".to_string(),
+ byte_offset: 0,
+ }),
+ right: Box::new(Expr::Number(1.0)),
+ }))],
+ else_branch: None,
+ },
+ Stmt::Return(Some(Expr::Integer(0))),
+ ],
+ 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,
+ };
+ // Same union, same discriminant chain, but the "bin" arm recurses. The
+ // call is what removes the clone.
+ let eval_recursive = Function {
+ id: 32,
+ name: "evalRecursive".to_string(),
+ type_params: Vec::new(),
+ params: vec![Param {
+ id: 320,
+ name: "node".to_string(),
+ ty: Type::Named("Node".to_string()),
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }],
+ return_type: Type::Number,
+ body: vec![
+ discriminant_let(321, 320),
+ Stmt::If {
+ condition: Expr::Compare {
+ op: CompareOp::Eq,
+ left: Box::new(Expr::LocalGet(321)),
+ right: Box::new(Expr::String("bin".to_string())),
+ },
+ then_branch: vec![Stmt::Return(Some(Expr::Call {
+ callee: Box::new(Expr::FuncRef(32)),
+ args: vec![Expr::PropertyGet {
+ object: Box::new(Expr::LocalGet(320)),
+ property: "left".to_string(),
+ byte_offset: 0,
+ }],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }))],
+ else_branch: None,
+ },
+ Stmt::Return(Some(Expr::Integer(0))),
+ ],
+ 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,
+ };
+ let mut module = Module::new("recursive_guard_narrowing.ts");
+ module.type_aliases.push(TypeAlias {
+ id: 31,
+ name: "Node".to_string(),
+ type_params: Vec::new(),
+ ty: node.clone(),
+ is_exported: false,
+ });
+ module.functions.push(eval);
+ module.functions.push(eval_recursive);
+ module.init.push(Stmt::Expr(Expr::Call {
+ callee: Box::new(Expr::FuncRef(31)),
+ args: vec![Expr::Undefined],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }));
+
+ let mut opts = CompileOptions {
+ emit_ir_only: true,
+ output_type: "executable".to_string(),
+ ..Default::default()
+ };
+ opts.type_aliases.insert("Node".to_string(), node);
+ let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles"))
+ .expect("LLVM IR is UTF-8");
+ let public = function_ir(&ir, "@perry_fn_recursive_guard_narrowing_ts__evalNode(");
+ assert!(public.contains("call i32 @js_param_type_guard("));
+ assert!(public.contains("evalNode$spec_b("));
+ assert!(public.contains("evalNode$generic("));
+
+ let specialized = function_ir(&ir, "evalNode$spec_b(");
+ let generic = function_ir(&ir, "evalNode$generic(");
+ // Inside the clone the entry guard proved `Node`, so `kind === "num"`
+ // narrows the union to its first arm and `node.num` is a proven number:
+ // the add lowers to a raw `fadd`. The generic body has no such proof and
+ // must keep the dynamic add — that pair is the whole subject.
+ assert!(
+ specialized.contains("fadd double")
+ && !specialized.contains("js_dynamic_string_or_number_add"),
+ "the guarded clone should narrow the discriminated union and add raw:\n{specialized}"
+ );
+ assert!(
+ generic.contains("call double @js_dynamic_string_or_number_add(")
+ && !generic.contains("fadd double"),
+ "the unproven body must keep the dynamic add:\n{generic}"
+ );
+
+ // The negative that pins the rule: same union, same discriminant chain,
+ // one recursive call — and the clone is gone. Without this, weakening
+ // `guard_blocked` would go unnoticed here.
+ assert!(
+ !ir.contains("evalRecursive$spec_") && !ir.contains("evalRecursive$generic"),
+ "a reference parameter must not keep a descriptor proof across a call:\n{ir}"
+ );
+ let recursive = function_ir(
+ &ir,
+ "@perry_fn_recursive_guard_narrowing_ts__evalRecursive(",
+ );
+ assert!(
+ !recursive.contains("js_param_type_guard"),
+ "the recursive walker must stay on the unguarded body:\n{recursive}"
+ );
+}
diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs
new file mode 100644
index 0000000000..94ad48efd0
--- /dev/null
+++ b/crates/perry-codegen/src/codegen/param_guard.rs
@@ -0,0 +1,572 @@
+//! Runtime type descriptors for guarded ordinary-parameter specialization.
+//!
+//! TypeScript annotations are candidates, never proofs. This module turns the
+//! guardable subset into a compact, immutable graph consumed by
+//! `js_param_type_guard`. Graph edges (rather than recursively nested bytes)
+//! let recursive aliases such as `Node` and `Env` terminate, and deterministic
+//! field ordering keeps object-cache inputs stable.
+
+use std::collections::{HashMap, HashSet};
+
+use perry_hir::types::Type;
+
+#[derive(Debug, Clone)]
+pub(crate) struct SpecParamGuard {
+ /// The exact HIR fact made available only inside the successful clone.
+ pub proof: Type,
+ /// Module-unique rodata symbol containing `descriptor`.
+ pub descriptor_name: String,
+ pub descriptor: Vec,
+}
+
+#[derive(Debug, Clone)]
+struct GuardField {
+ name: String,
+ optional: bool,
+ ty: u32,
+}
+
+#[derive(Debug, Clone)]
+enum GuardNode {
+ Any,
+ Number,
+ Int32,
+ Boolean,
+ String,
+ StringLiteral(String),
+ Null,
+ Undefined,
+ BigInt,
+ Symbol,
+ Array(u32),
+ Tuple(Vec),
+ Object {
+ class_id: Option,
+ fields: Vec,
+ },
+ Union(Vec),
+ RecursiveRef(u32),
+ Map {
+ key: u32,
+ value: u32,
+ },
+ Set(u32),
+}
+
+struct GuardGraphBuilder<'a> {
+ nodes: Vec,
+ named: HashMap,
+ building_named: HashSet,
+ type_aliases: &'a HashMap,
+ interfaces: &'a HashMap,
+ classes: &'a HashMap,
+ class_ids: &'a HashMap,
+}
+
+impl<'a> GuardGraphBuilder<'a> {
+ fn reserve(&mut self) -> u32 {
+ let id = self.nodes.len() as u32;
+ self.nodes.push(GuardNode::Any);
+ id
+ }
+
+ fn push(&mut self, node: GuardNode) -> u32 {
+ let id = self.nodes.len() as u32;
+ self.nodes.push(node);
+ id
+ }
+
+ fn build_fields(
+ &mut self,
+ fields: impl IntoIterator- ,
+ ) -> Option> {
+ fields
+ .into_iter()
+ .map(|(name, ty, optional)| {
+ // The proof consumers currently expose a property's declared
+ // type, not `T | undefined`. Do not admit optional fields
+ // until that fact propagation models absence explicitly.
+ if optional {
+ return None;
+ }
+ Some(GuardField {
+ name,
+ optional,
+ ty: self.build_type(&ty, true)?,
+ })
+ })
+ .collect()
+ }
+
+ fn build_named(&mut self, name: &str) -> Option {
+ if let Some(id) = self.named.get(name) {
+ return if self.building_named.contains(name) {
+ Some(self.push(GuardNode::RecursiveRef(*id)))
+ } else {
+ Some(*id)
+ };
+ }
+ let id = self.reserve();
+ self.named.insert(name.to_string(), id);
+ self.building_named.insert(name.to_string());
+
+ let node = if let Some(alias) = self.type_aliases.get(name) {
+ let alias_id = self.build_type(alias, true)?;
+ self.nodes.get(alias_id as usize)?.clone()
+ } else if let Some(interface) = self.interfaces.get(name) {
+ // Extended/generic interfaces need substitution + inherited-field
+ // flattening. Stay generic until the descriptor can prove both.
+ if !interface.extends.is_empty()
+ || !interface.type_params.is_empty()
+ || !interface.methods.is_empty()
+ {
+ return None;
+ }
+ let fields = self.build_fields(
+ interface
+ .properties
+ .iter()
+ .map(|p| (p.name.clone(), p.ty.clone(), p.optional)),
+ )?;
+ GuardNode::Object {
+ class_id: None,
+ fields,
+ }
+ } else if self.classes.contains_key(name) && self.class_ids.contains_key(name) {
+ // Class identity alone cannot prove mutable field values, while
+ // compact instances do not expose the ordinary `keys_array`
+ // needed for read-only field validation. Keep class parameters on
+ // the generic path until a layout-aware field guard exists.
+ return None;
+ } else {
+ self.building_named.remove(name);
+ self.named.remove(name);
+ self.nodes.pop();
+ return None;
+ };
+ self.building_named.remove(name);
+ self.nodes[id as usize] = node;
+ Some(id)
+ }
+
+ fn build_type(&mut self, ty: &Type, nested: bool) -> Option {
+ Some(match ty {
+ Type::Any | Type::Unknown | Type::TypeVar(_) if nested => self.push(GuardNode::Any),
+ Type::Any | Type::Unknown | Type::TypeVar(_) | Type::Never => return None,
+ Type::Void => self.push(GuardNode::Undefined),
+ Type::Null => self.push(GuardNode::Null),
+ Type::Boolean => self.push(GuardNode::Boolean),
+ Type::Number => self.push(GuardNode::Number),
+ Type::Int32 => self.push(GuardNode::Int32),
+ Type::BigInt => self.push(GuardNode::BigInt),
+ Type::String => self.push(GuardNode::String),
+ Type::StringLiteral(value) => self.push(GuardNode::StringLiteral(value.clone())),
+ Type::Symbol => self.push(GuardNode::Symbol),
+ Type::Array(elem) => {
+ let elem = self.build_type(elem, true)?;
+ self.push(GuardNode::Array(elem))
+ }
+ Type::Tuple(elems) => {
+ let elems = elems
+ .iter()
+ .map(|elem| self.build_type(elem, true))
+ .collect::