diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs
index 38e4ab165a..71833e7fd1 100644
--- a/crates/perry-codegen/src/lower_call/func_ref.rs
+++ b/crates/perry-codegen/src/lower_call/func_ref.rs
@@ -975,6 +975,22 @@ pub fn try_lower_func_ref_call(
let (values, guard) = super::lower_call_args_rooted(ctx, args)?;
arg_group = guard;
lowered.extend(values);
+ // #8770: pad missing trailing args with TAG_UNDEFINED, exactly like
+ // the cross-module twin (`extern_func.rs`, issue #608 arm). The callee
+ // is compiled with `declared_count` double parameters and its
+ // default-parameter lowering tests each for `undefined`; an
+ // under-applied same-module call site that emits only the provided
+ // args leaves the remaining FP argument registers holding caller-saved
+ // garbage, which the callee then reads as JS values. On the Claude
+ // Code bundle (one giant module, so EVERY direct call resolves here)
+ // `aP([q])` for `function aP(q, K = !1, _)` handed `K`/`_` whatever
+ // d1/d2 held after `js_array_from_values` — the #8770 poison values
+ // (0xffffffffffffffff receivers → shape_is_url_search_params /
+ // js_is_truthy faults, corrupted async iteration → unsettled awaits).
+ let undefined_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
+ while lowered.len() < declared_count {
+ lowered.push(undefined_lit.clone());
+ }
}
let arg_slices: Vec<(crate::types::LlvmType, &str)> =
lowered.iter().map(|s| (DOUBLE, s.as_str())).collect();
diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs
index 31b57ee073..aef67758c6 100644
--- a/crates/perry-codegen/src/lower_call/mod.rs
+++ b/crates/perry-codegen/src/lower_call/mod.rs
@@ -60,6 +60,10 @@ mod field_init;
mod func_ref;
#[cfg(test)]
mod pipeline_call_tests;
+/// #8770: an under-applied same-module direct call must pad the missing
+/// trailing parameters with `TAG_UNDEFINED` (the cross-module arm always did).
+#[cfg(test)]
+mod underapply_pad_tests;
pub(crate) use func_ref::{
guarded_call_return_proof, guarded_discriminant_branch_proofs, guarded_expr_proof,
guarded_path_type,
diff --git a/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs b/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs
new file mode 100644
index 0000000000..f8127da4fe
--- /dev/null
+++ b/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs
@@ -0,0 +1,108 @@
+//! #8770 regression: a SAME-MODULE direct call with fewer arguments than the
+//! callee's declared parameter count must pad the missing trailing parameters
+//! with `TAG_UNDEFINED` — exactly like the cross-module twin
+//! (`extern_func.rs`, issue #608 arm) always has.
+//!
+//! Without the padding, the callee (compiled with `declared_count` double
+//! parameters, its default-parameter lowering testing each for `undefined`)
+//! reads whatever the caller-saved FP argument registers happen to hold. On
+//! the Claude Code bundle — one giant module, so every direct call resolves
+//! through the same-module arm — `aP([q])` for `function aP(q, K = !1, _)`
+//! handed `K`/`_` the leftovers of `js_array_from_values`' internals:
+//! impossible-NaN bit patterns (`0xffffffffffffffff`) that flowed into
+//! truthiness tests and method receivers (`_.get(A)`) and crashed in
+//! `shape_is_url_search_params` / `js_is_truthy`, or silently corrupted the
+//! async iteration ("Detected unsettled top-level await").
+
+use crate::{compile_module, CompileOptions};
+use perry_hir::types::Type;
+use perry_hir::{Expr, Function, Module, Param, Stmt};
+
+fn param(id: u32, name: &str) -> Param {
+ Param {
+ id,
+ name: name.to_string(),
+ ty: Type::Any,
+ 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::Any,
+ body,
+ is_async: false,
+ is_generator: false,
+ is_strict: true,
+ was_plain_async: false,
+ was_unrolled: false,
+ is_exported: false,
+ captures: Vec::new(),
+ decorators: Vec::new(),
+ }
+}
+
+/// `function callee(a, b, c) { return b; }` called as `callee(7)`.
+fn underapplied_call_ir() -> String {
+ let callee = function(
+ 1,
+ "callee",
+ vec![param(10, "a"), param(11, "b"), param(12, "c")],
+ vec![Stmt::Return(Some(Expr::LocalGet(11)))],
+ );
+ let caller = function(
+ 2,
+ "caller",
+ Vec::new(),
+ vec![Stmt::Return(Some(Expr::Call {
+ callee: Box::new(Expr::FuncRef(1)),
+ args: vec![Expr::Number(7.0)],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }))],
+ );
+ let mut module = Module::new("underapply_pad_test.ts");
+ module.functions = vec![callee, caller];
+ let opts = CompileOptions {
+ emit_ir_only: true,
+ ..Default::default()
+ };
+ String::from_utf8(compile_module(&module, opts).expect("call fixture must compile"))
+ .expect("LLVM IR is UTF-8")
+}
+
+#[test]
+fn an_underapplied_direct_call_pads_missing_params_with_undefined() {
+ let ir = underapplied_call_ir();
+ let undefined_lit = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
+ // The direct call must carry all three declared parameters…
+ let call_line = ir
+ .lines()
+ .find(|line| {
+ line.contains("call double @perry_fn_underapply_pad_test_ts__callee(")
+ })
+ .unwrap_or_else(|| panic!("expected a direct call to the callee:\n{ir}"));
+ let args = call_line
+ .split("callee(")
+ .nth(1)
+ .map(|tail| tail.matches("double").count())
+ .unwrap_or(0);
+ assert!(
+ args >= 3,
+ "an under-applied direct call must pass every declared parameter \
+ (got {args} double args): {call_line}\n{ir}"
+ );
+ // …and the missing trailing two must be the TAG_UNDEFINED literal.
+ assert!(
+ call_line.matches(undefined_lit.as_str()).count() >= 2,
+ "the two omitted parameters must be padded with the undefined literal \
+ {undefined_lit}: {call_line}\n{ir}"
+ );
+}