diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e553960ef7..29884ea3b7 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -131,6 +131,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // becomes part of every emitted global so multi-module programs // don't collide on `.str.0.handle`. let mut strings = StringPool::with_prefix(module_prefix.clone()); + // #5247: install per-module source-location context for the dynamic + // call-dispatch throw path, but only under `--debug-symbols` (which sets + // `opts.debug_locations` + `opts.module_source`). Off by default — no + // source clone, no per-call emission. + if opts.debug_locations { + if let Some(src) = opts.module_source.clone() { + strings.set_debug_location_ctx(Some((hir.name.clone(), src))); + } + } // Class lookup table for `Expr::New`. Indexed by class name — // the HIR has unique names per module. diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index c82fed6a9a..569643d804 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -366,6 +366,20 @@ pub struct CompileOptions { /// Without this, side-effect-only dynamic-import targets fail at /// link with `Undefined symbols: ___perry_ns_`. pub is_dynamic_import_target: bool, + + /// #5247: emit source-location tracking for the dynamic call-dispatch + /// throw path (`X is not a function`). Gated by the CLI `--debug-symbols` + /// flag; default `false` so release builds are byte-identical and incur + /// no per-call overhead. When `true` (and `module_source` is present), + /// each dynamic method-call dispatch is preceded by a + /// `js_set_call_location(file, line)` so the thrown TypeError's `.stack` + /// shows `at :`. + pub debug_locations: bool, + /// #5247: this module's original source text, used at codegen to resolve a + /// `Call`'s `byte_offset` to a 1-based line number. Only set when + /// `debug_locations` is on (avoids cloning source for every module in the + /// common build). `None` falls back to the `` frame. + pub module_source: Option, } /// Issue #100: one entry in a module's namespace-population list. diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 88d520c0e1..80299ea839 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -652,6 +652,7 @@ mod tests { callee: Box::new(Expr::FuncRef(7)), args: vec![arg, Expr::Integer(0), Expr::Integer(100)], type_args: vec![], + byte_offset: 0, }; // let src = undefined; src = obj.value; (disqualified seed) // const xx = clamp3(src, 0, 100); (clamp-admitted) diff --git a/crates/perry-codegen/src/collectors/pointer_locals.rs b/crates/perry-codegen/src/collectors/pointer_locals.rs index 245f86fa95..d6df185ab5 100644 --- a/crates/perry-codegen/src/collectors/pointer_locals.rs +++ b/crates/perry-codegen/src/collectors/pointer_locals.rs @@ -775,6 +775,7 @@ mod tests { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::LocalGet(2)], type_args: Vec::new(), + byte_offset: 0, }))], captures: Vec::new(), mutable_captures: Vec::new(), diff --git a/crates/perry-codegen/src/expr/calls.rs b/crates/perry-codegen/src/expr/calls.rs index 05f1131339..4e6b727203 100644 --- a/crates/perry-codegen/src/expr/calls.rs +++ b/crates/perry-codegen/src/expr/calls.rs @@ -47,6 +47,39 @@ use super::{ I18nLowerCtx, }; +/// #5247: under `--debug-symbols`, emit a `js_set_call_location(file, line)` +/// runtime call right before a dynamic method dispatch so the +/// "X is not a function" throw path can render `at :` in the thrown +/// TypeError's `.stack`. Resolves the *pending* call byte offset (recorded by +/// the `Expr::Call` dispatcher) → `(file, line)` via the module's installed +/// debug-location context. No-op (no IR emitted) when the context is absent +/// (default build) or the pending offset is 0 (synthesized call). +/// +/// Called at the dispatch emission site (after the call's arguments are +/// lowered) with the offset the dispatcher captured at entry — before any +/// nested-call argument overwrote the shared pending offset — so the location +/// reflects the OUTER call, not its last-lowered argument. +pub(crate) fn emit_call_location_at(ctx: &mut FnCtx<'_>, byte_offset: u32) { + let Some((file, line)) = ctx + .strings + .call_location_for(byte_offset) + .map(|(f, l)| (f.to_string(), l)) + else { + return; + }; + let file_label = emit_string_literal_global(ctx, &file); + let file_len = file.len(); + let blk = ctx.block(); + blk.call_void( + "js_set_call_location", + &[ + (PTR, &file_label), + (I64, &file_len.to_string()), + (I32, &line.to_string()), + ], + ); +} + /// #2013/#3146: emit a setup-time `validateString` call. `value_box` is the /// original NaN-boxed value; `name` is the static argument name node uses in /// the error (`"algorithm"` for `createHash`, `"hmac"` for `createHmac`'s @@ -2369,7 +2402,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // -------- Calls -------- - Expr::Call { callee, args, .. } => { + Expr::Call { + callee, + args, + byte_offset, + .. + } => { for arg in args { super::downgrade_buffer_aliases_in_expr( ctx, @@ -2377,6 +2415,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { crate::native_value::MaterializationReason::UnknownCallEscape, ); } + // #5247: under `--debug-symbols`, record this call's source byte + // offset so the dynamic method-dispatch emission site can emit a + // `js_set_call_location` immediately before the throwing dispatch + // (after the call's args — which may be nested calls that overwrite + // this — have been lowered). The dynamic dispatch path renders it as + // `at :` in the "X is not a function" TypeError's + // `.stack`. No-op in the default build. + if ctx.strings.debug_locations_enabled() { + ctx.strings.set_pending_call_offset(*byte_offset); + } lower_call(ctx, callee, args) } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 3984656516..4e7dc0ae1d 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1402,7 +1402,7 @@ mod arrays_finds; mod bigint_set; mod binary; mod call_spread; -mod calls; +pub(crate) mod calls; mod child_proc; mod closure; mod compare; diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 124a9642c3..7d62a0eb87 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -261,11 +261,13 @@ fn same_put_value_receiver_expr(target: &Expr, receiver: &Expr) -> bool { callee: a_callee, args: a_args, type_args: a_type_args, + .. }, Expr::Call { callee: b_callee, args: b_args, type_args: b_type_args, + .. }, ) => { a_type_args == b_type_args diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index 5fc49ee118..79e8dd243a 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -638,6 +638,11 @@ pub fn try_lower_native_method_str_dispatch( callee: &Expr, args: &[Expr], ) -> Result> { + // #5247: capture this call's source byte offset before any argument + // (possibly a nested call that overwrites the shared pending offset) is + // lowered, so the dynamic dispatch below can emit `js_set_call_location` + // with the OUTER call's location right before the throw-capable dispatch. + let call_byte_offset = ctx.strings.pending_call_offset(); // -------- PropertyGet method dispatch via js_native_call_method -------- // // For `recv.method(args)` where the static dispatch above didn't fire @@ -788,6 +793,11 @@ pub fn try_lower_native_method_str_dispatch( property, TypedFeedbackContract::method_call(), ); + // #5247: record the source location right before the dynamic + // dispatch so a thrown "X is not a function" TypeError carries + // `at :`. Args are already lowered above, so a nested + // call's location no longer shadows this one. + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); let blk = ctx.block(); return Ok(Some(blk.call( DOUBLE, diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index dc36fdad3c..528281f02d 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -93,6 +93,12 @@ pub fn try_lower_property_get_method_call( let Expr::PropertyGet { object, property } = callee else { return Ok(None); }; + // #5247: capture this call's source byte offset now, before any argument + // (which may be a nested call that overwrites the pending offset) is + // lowered. The dynamic `js_native_call_method` fallback below emits the + // `js_set_call_location` from this captured value, immediately before the + // throwing dispatch. `0` (and the default build) → no emission. + let call_byte_offset = ctx.strings.pending_call_offset(); if let Some(value) = super::web_storage::try_lower_web_storage_method_call(ctx, object, property, args)? { @@ -1753,6 +1759,12 @@ pub fn try_lower_property_get_method_call( )); (ptr_reg, n.to_string()) }; + // #5247: record the source location of this call right before the + // dynamic dispatch, so the runtime "X is not a function" / + // "(kind).method is not a function" TypeError this fallback may + // throw carries `at :`. Args are already lowered, so a + // nested-call argument's location no longer shadows this one. + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); let v_def = ctx.block().call( DOUBLE, "js_native_call_method", diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index b9d072edd8..0bbbb818e6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -406,6 +406,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { // `TypeError [ERR_INVALID_ARG_TYPE]` instead of dereferencing a bogus // pointer (segfault). Used by `crypto.createHash`/`createHmac`/`pbkdf2*`. module.declare_function("js_runtime_validate_string_arg", VOID, &[DOUBLE, PTR, I32]); + // #5247: source-location tracking for the dynamic call-dispatch throw + // path. Emitted before a call's dispatch only under `--debug-symbols`. + module.declare_function("js_set_call_location", VOID, &[PTR, I64, I32]); module.declare_function( "js_runtime_validate_crypto_key_arg", VOID, diff --git a/crates/perry-codegen/src/strings.rs b/crates/perry-codegen/src/strings.rs index d02f70fd6a..df90231e9c 100644 --- a/crates/perry-codegen/src/strings.rs +++ b/crates/perry-codegen/src/strings.rs @@ -76,6 +76,20 @@ pub struct StringPool { /// Ordered list of unique entries; the index in this Vec is the /// interned index referenced by `interned`. entries: Vec, + /// #5247: source-location context for the dynamic call-dispatch throw + /// path. Set once per module after construction (only when the CLI + /// `--debug-symbols` flag is on). `None` in the default build so codegen + /// emits no per-call `js_set_call_location` overhead. When `Some`, + /// carries `(module_file_path, module_source)` so the call-lowering site + /// can resolve a `Call.byte_offset` to a `file:line`. + debug_location_ctx: Option<(String, String)>, + /// #5247: the byte offset of the `Expr::Call` currently being lowered, + /// recorded by the call dispatcher and consumed at the dynamic + /// method-dispatch emission site (after the call's arguments — which may + /// themselves be nested calls that overwrite this — have been lowered) so + /// the `js_set_call_location` is emitted with the *outer* call's offset + /// immediately before the throwing dispatch. `0` = none. + pending_call_offset: std::cell::Cell, } pub struct StringEntry { @@ -107,6 +121,8 @@ impl StringPool { module_prefix, interned: HashMap::new(), entries: Vec::new(), + debug_location_ctx: None, + pending_call_offset: std::cell::Cell::new(0), } } @@ -114,6 +130,53 @@ impl StringPool { &self.module_prefix } + /// #5247: install the per-module source-location context (file path + + /// source text) consulted by the dynamic call-dispatch lowering when the + /// `--debug-symbols` flag is on. No-op otherwise (`ctx` is `None`). + pub fn set_debug_location_ctx(&mut self, ctx: Option<(String, String)>) { + self.debug_location_ctx = ctx; + } + + /// #5247: true iff source-location tracking is active for this module + /// (i.e. `--debug-symbols` installed a debug-location context). Lets the + /// dispatch emission site skip all location work in the default build. + pub fn debug_locations_enabled(&self) -> bool { + self.debug_location_ctx.is_some() + } + + /// #5247: record the byte offset of the call currently being lowered. + pub fn set_pending_call_offset(&self, byte_offset: u32) { + self.pending_call_offset.set(byte_offset); + } + + /// #5247: the byte offset recorded by the most recent + /// [`set_pending_call_offset`]. `0` when none. + pub fn pending_call_offset(&self) -> u32 { + self.pending_call_offset.get() + } + + /// #5247: resolve a `Call`'s source byte offset to `(file_path, line)`, + /// where `line` is 1-based. Returns `None` when no debug-location context + /// is installed (default build), the offset is `0` (synthesized call), or + /// the offset is out of range. SWC's `BytePos` is 1-based, matching the + /// `lower` crate's `current_module_source_slice`, so subtract 1. + pub fn call_location_for(&self, byte_offset: u32) -> Option<(&str, u32)> { + if byte_offset == 0 { + return None; + } + let (file, src) = self.debug_location_ctx.as_ref()?; + let offset = (byte_offset.saturating_sub(1)) as usize; + if offset > src.len() { + return None; + } + // 1-based line = 1 + count of newlines before the offset. + let line = 1 + src.as_bytes()[..offset] + .iter() + .filter(|&&b| b == b'\n') + .count(); + Some((file.as_str(), line as u32)) + } + /// Intern a string literal. Returns the interned index, stable for the /// life of the pool. Identical strings collapse to the same index. pub fn intern(&mut self, value: &str) -> u32 { @@ -276,4 +339,78 @@ mod tests { let e = pool.entry(idx); assert_eq!(e.byte_len, 6); } + + // ───────────────────── #5247 byte→line mapping ───────────────────── + // `call_location_for` takes an SWC `BytePos` (1-based), so byte offset N + // refers to source index N-1. Line is 1-based (1 + newlines before it). + + fn pool_with_src(src: &str) -> StringPool { + let mut p = StringPool::new(); + p.set_debug_location_ctx(Some(("foo.ts".to_string(), src.to_string()))); + p + } + + #[test] + fn call_location_none_without_debug_context() { + // Default build: no context installed → never resolves a location. + let p = StringPool::new(); + assert_eq!(p.call_location_for(5), None); + } + + #[test] + fn call_location_zero_offset_is_none() { + // 0 sentinel (synthesized call) → no location. + let p = pool_with_src("a\nb\nc"); + assert_eq!(p.call_location_for(0), None); + } + + #[test] + fn call_location_first_line() { + // Offsets within the first line (before any '\n') → line 1. + let p = pool_with_src("foo();\nbar();\n"); + // BytePos 1 = source index 0 ('f'); BytePos 6 = index 5 (')'). + assert_eq!(p.call_location_for(1), Some(("foo.ts", 1))); + assert_eq!(p.call_location_for(6), Some(("foo.ts", 1))); + } + + #[test] + fn call_location_line_boundaries() { + // "foo();\nbar();\nbaz();\n" + // index:0..5 '\n'=6 7..12 '\n'=13 14..19 '\n'=20 + let src = "foo();\nbar();\nbaz();\n"; + let p = pool_with_src(src); + // BytePos for the '\n' itself (index 6 → BytePos 7) still counts as + // line 1 (no newline strictly *before* it). + assert_eq!(p.call_location_for(7), Some(("foo.ts", 1))); + // First char of line 2 ('b' at index 7 → BytePos 8): one '\n' before. + assert_eq!(p.call_location_for(8), Some(("foo.ts", 2))); + // First char of line 3 ('b' at index 14 → BytePos 15): two '\n' before. + assert_eq!(p.call_location_for(15), Some(("foo.ts", 3))); + } + + #[test] + fn call_location_last_line_and_out_of_range() { + let src = "x\ny\nz"; // 5 bytes, 3 lines, no trailing newline + let p = pool_with_src(src); + // 'z' at index 4 → BytePos 5: two '\n' before → line 3. + assert_eq!(p.call_location_for(5), Some(("foo.ts", 3))); + // BytePos == len+1 (index == len): clamped, still line 3 (EOF after z). + assert_eq!(p.call_location_for(6), Some(("foo.ts", 3))); + // Far out of range → None. + assert_eq!(p.call_location_for(100), None); + } + + #[test] + fn call_location_utf8_safe() { + // Multi-byte chars before the call must not panic or miscount lines — + // we count raw bytes, and the offsets are byte offsets, so slicing on + // a byte boundary the compiler produced is always valid. + // "café();\nx();" — "café();" is 8 bytes (é = 2), '\n' at index 8. + let src = "café();\nx();"; + let p = pool_with_src(src); + // 'x' is at byte index 9 (after "café();\n" = 9 bytes) → BytePos 10. + assert_eq!(p.call_location_for(10), Some(("foo.ts", 2))); + // A position inside line 1 → line 1. + assert_eq!(p.call_location_for(2), Some(("foo.ts", 1))); + } } diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index 2517f8394e..e62611395c 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -71,6 +71,8 @@ fn entry_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index 4bc8b3528c..56dc066296 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index c032ebae3e..448ef45c7b 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs index c992a38a7b..5ff8acb1fd 100644 --- a/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs +++ b/crates/perry-codegen/tests/macos_bundle_chdir_gate.rs @@ -53,6 +53,8 @@ fn entry_opts(target: Option<&str>) -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index 5de660a415..910495f6d1 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -52,6 +52,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } @@ -467,6 +469,7 @@ fn call(callee: Expr, args: Vec) -> Expr { callee: Box::new(callee), args, type_args: Vec::new(), + byte_offset: 0, } } @@ -590,6 +593,7 @@ fn artifact_records_buffer_read_u32_and_unsigned_materialization() { }), args: vec![int(0)], type_args: Vec::new(), + byte_offset: 0, })), ]; @@ -673,6 +677,7 @@ fn artifact_records_buffer_read_double_as_f64() { }), args: vec![int(0)], type_args: Vec::new(), + byte_offset: 0, })), ]; diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index dadd995289..ea8a1a1553 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -52,6 +52,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } @@ -481,6 +483,7 @@ fn call(callee: Expr, args: Vec) -> Expr { callee: Box::new(callee), args, type_args: Vec::new(), + byte_offset: 0, } } @@ -829,11 +832,13 @@ fn pod_layout_specialization_module() -> Module { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![Type::Named("Tiny".to_string())], + byte_offset: 0, })); module.init.push(Stmt::Expr(Expr::Call { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![Type::Named("Wide".to_string())], + byte_offset: 0, })); module } @@ -873,11 +878,13 @@ fn native_pod_view_specialization_module() -> Module { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::NativeArenaAlloc(Box::new(int(4096)))], type_args: vec![Type::Named("Tiny".to_string())], + byte_offset: 0, })); module.init.push(Stmt::Expr(Expr::Call { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::NativeArenaAlloc(Box::new(int(4096)))], type_args: vec![Type::Named("Wide".to_string())], + byte_offset: 0, })); module } diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index e9d54ddccb..56904988ec 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 0ae20f50b1..6eef851d69 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 93b7e109ac..063a7b7e6a 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -78,6 +78,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } @@ -238,6 +240,7 @@ fn typed_feedback_instruments_property_and_method_boundaries() { }), args: vec![Expr::Number(2.0)], type_args: Vec::new(), + byte_offset: 0, }), Stmt::Return(Some(Expr::PropertyGet { object: Box::new(Expr::LocalGet(1)), @@ -328,6 +331,7 @@ fn typed_feedback_guards_direct_class_method_specialization() { }), args: vec![Expr::Number(5.0)], type_args: Vec::new(), + byte_offset: 0, }))], )); @@ -377,6 +381,7 @@ fn typed_feedback_guards_direct_closure_call_specialization() { callee: Box::new(Expr::LocalGet(2)), args: vec![Expr::Number(9.0)], type_args: Vec::new(), + byte_offset: 0, })), ], )); diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index 90d47b5c55..87d74b49d1 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index 5b273a4d4b..aac9a8d048 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -77,6 +77,8 @@ fn empty_opts() -> CompileOptions { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } @@ -337,6 +339,7 @@ fn c262_array_has_own_property_uses_object_prototype_dispatch() { }), args: vec![Expr::String("0".to_string())], type_args: vec![], + byte_offset: 0, })), ], Vec::new(), diff --git a/crates/perry-hir/src/destructuring/assignment_stmt.rs b/crates/perry-hir/src/destructuring/assignment_stmt.rs index 5c6b5a8565..4fd8c84200 100644 --- a/crates/perry-hir/src/destructuring/assignment_stmt.rs +++ b/crates/perry-hir/src/destructuring/assignment_stmt.rs @@ -38,6 +38,7 @@ fn extern_call(name: &str, args: Vec) -> Expr { }), args, type_args: Vec::new(), + byte_offset: 0, } } diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 1b5b34b089..e98db6c7eb 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -490,6 +490,7 @@ fn resolve_cjs_path_default_join_over_replaced_dirname_local() { Expr::String("node_modules.asar.unpacked".to_string()), ], type_args: vec![], + byte_offset: 0, }), }); @@ -506,6 +507,7 @@ fn resolve_cjs_path_default_join_over_replaced_dirname_local() { Expr::String("worker/conoutSocketWorker.js".to_string()), ], type_args: vec![], + byte_offset: 0, }; let consts = collect_module_const_locals(&module); diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index ba85f4e136..cff4e9af91 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -113,6 +113,13 @@ pub enum Expr { args: Vec, /// Explicit type arguments (e.g., identity(x)) type_args: Vec, + /// #5247: byte offset (`call.span.lo.0`) of this call expression in its + /// module's source, captured at AST→HIR lowering. Used by codegen (under + /// `--debug-symbols`) to attach a `file:line` to the runtime "X is not a + /// function" TypeError thrown by the dynamic method-dispatch path. `0` + /// when unknown (synthesized calls from transforms/intrinsics, etc.) — + /// a 0 sentinel resolves to no location, falling back to ``. + byte_offset: u32, }, /// Function call with spread arguments (e.g., fn(a, ...arr, b)) diff --git a/crates/perry-hir/src/jsx.rs b/crates/perry-hir/src/jsx.rs index d82c389bea..bdd6fe1d41 100644 --- a/crates/perry-hir/src/jsx.rs +++ b/crates/perry-hir/src/jsx.rs @@ -89,6 +89,7 @@ pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement }), args: vec![type_expr, props_expr], type_args: Vec::new(), + byte_offset: 0, }) } @@ -121,6 +122,7 @@ fn react_create_element_call( }), args: vec![type_expr, props_expr.clone()], type_args: Vec::new(), + byte_offset: 0, }) } @@ -189,6 +191,7 @@ pub(crate) fn lower_jsx_fragment( // `type === "__Fragment"` to detect fragment elements. args: vec![Expr::String("__Fragment".to_string()), props_expr], type_args: Vec::new(), + byte_offset: 0, }) } diff --git a/crates/perry-hir/src/lower/array_fold.rs b/crates/perry-hir/src/lower/array_fold.rs index 2b09183430..f2f7f789b1 100644 --- a/crates/perry-hir/src/lower/array_fold.rs +++ b/crates/perry-hir/src/lower/array_fold.rs @@ -32,6 +32,7 @@ pub(crate) fn try_fold_array_method_call(call: Expr) -> Expr { callee: Box::new(other), args, type_args: Vec::new(), + byte_offset: 0, }; } }; @@ -43,6 +44,7 @@ pub(crate) fn try_fold_array_method_call(call: Expr) -> Expr { }), args, type_args: Vec::new(), + byte_offset: 0, }; match property.as_str() { "map" if !args.is_empty() => { diff --git a/crates/perry-hir/src/lower/const_fold_fn.rs b/crates/perry-hir/src/lower/const_fold_fn.rs index df66c7aacb..157162b2ef 100644 --- a/crates/perry-hir/src/lower/const_fold_fn.rs +++ b/crates/perry-hir/src/lower/const_fold_fn.rs @@ -1449,5 +1449,6 @@ fn build_eval_completion_iife( callee: Box::new(closure), args: vec![], type_args: vec![], + byte_offset: 0, })) } diff --git a/crates/perry-hir/src/lower/decorators.rs b/crates/perry-hir/src/lower/decorators.rs index b89c61c5d9..379ba1f52b 100644 --- a/crates/perry-hir/src/lower/decorators.rs +++ b/crates/perry-hir/src/lower/decorators.rs @@ -176,6 +176,7 @@ fn append_method_decorator_init( Expr::String(method.name.clone()), ], type_args: Vec::new(), + byte_offset: 0, }, )]); append_decorator_invocations( @@ -247,6 +248,7 @@ fn append_decorator_invocations_inner( callee: Box::new(base), args: dec.args.clone(), type_args: Vec::new(), + byte_offset: 0, }), }); callees.push((Expr::LocalGet(temp_id), dec.name.clone())); @@ -260,6 +262,7 @@ fn append_decorator_invocations_inner( callee: Box::new(callee), args: invocation_args.clone(), type_args: Vec::new(), + byte_offset: 0, }; match class_name_for_replacement_check { Some(class_name) => { diff --git a/crates/perry-hir/src/lower/eval_super_scan.rs b/crates/perry-hir/src/lower/eval_super_scan.rs index e13cd3e46a..fe8a163f00 100644 --- a/crates/perry-hir/src/lower/eval_super_scan.rs +++ b/crates/perry-hir/src/lower/eval_super_scan.rs @@ -146,6 +146,7 @@ fn throw_eval_syntax_error_expr(msg: &str) -> Expr { }), args: vec![Expr::String(msg.to_string())], type_args: vec![], + byte_offset: 0, } } diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index fb7f1d1a9f..40ddb11537 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -76,6 +76,7 @@ fn throw_type_error_const_assignment(name: &str) -> Expr { }), args: vec![Expr::String(name.to_string())], type_args: vec![], + byte_offset: 0, } } @@ -88,6 +89,7 @@ fn throw_restricted_function_property_assignment() -> Expr { }), args: vec![], type_args: vec![], + byte_offset: 0, } } @@ -100,6 +102,7 @@ fn throw_reference_error_unresolvable_assignment(name: &str) -> Expr { }), args: vec![Expr::String(name.to_string())], type_args: vec![], + byte_offset: 0, } } @@ -547,6 +550,7 @@ fn lower_assignment_target( }), args: vec![*value], type_args: vec![], + byte_offset: 0, }); } } diff --git a/crates/perry-hir/src/lower/expr_call/crypto.rs b/crates/perry-hir/src/lower/expr_call/crypto.rs index df50c9004e..89d269778f 100644 --- a/crates/perry-hir/src/lower/expr_call/crypto.rs +++ b/crates/perry-hir/src/lower/expr_call/crypto.rs @@ -124,6 +124,7 @@ pub(super) fn lower_crypto_passthrough(method: &str, args: Vec) -> Option< }), args, type_args: vec![], + byte_offset: 0, }), // `randomUUIDv7([options])` — options accepted for shape parity but // do not affect the generated value (#2550). @@ -145,6 +146,7 @@ pub(super) fn lower_crypto_passthrough(method: &str, args: Vec) -> Option< }), args, type_args: vec![], + byte_offset: 0, }); } Some(Expr::CryptoRandomBytes(Box::new( @@ -210,6 +212,7 @@ pub(super) fn lower_crypto_passthrough(method: &str, args: Vec) -> Option< }), args, type_args: vec![], + byte_offset: 0, }), // `crypto.hash(alg, data, enc?)` — Node 21+ one-shot helper. // Expand into the `createHash(alg).update(data).digest(enc)` @@ -234,16 +237,19 @@ pub(super) fn lower_crypto_passthrough(method: &str, args: Vec) -> Option< }), args: vec![alg], type_args: vec![], + byte_offset: 0, }), property: "update".to_string(), }), args: vec![data], type_args: vec![], + byte_offset: 0, }), property: "digest".to_string(), }), args: vec![enc], type_args: vec![], + byte_offset: 0, }) } _ => None, diff --git a/crates/perry-hir/src/lower/expr_call/globals.rs b/crates/perry-hir/src/lower/expr_call/globals.rs index d86779654b..47798c1f37 100644 --- a/crates/perry-hir/src/lower/expr_call/globals.rs +++ b/crates/perry-hir/src/lower/expr_call/globals.rs @@ -823,6 +823,7 @@ pub(super) fn try_global_builtins( }), args: new_args, type_args: vec![], + byte_offset: 0, })); } } @@ -844,6 +845,7 @@ pub(super) fn try_global_builtins( }), args: vec![alg_arg, options_arg], type_args: vec![], + byte_offset: 0, })); } } diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics.rs b/crates/perry-hir/src/lower/expr_call/intrinsics.rs index 2cbca78816..45901aad41 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics.rs @@ -225,6 +225,7 @@ pub(super) fn try_strict_eval_arguments_assignment( }), args: Vec::new(), type_args: Vec::new(), + byte_offset: 0, }) } @@ -1317,6 +1318,7 @@ pub(super) fn try_iife_call_rewrite( callee: Box::new(lowered_callee), args: rest_args, type_args: Vec::new(), + byte_offset: 0, })); } } @@ -1441,6 +1443,7 @@ pub(super) fn try_native_module_method_apply_call( }), args: lowered, type_args: Vec::new(), + byte_offset: 0, })); } } diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 97bb2d5cb9..8a4f73ef30 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -633,6 +633,10 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result Re }), args: vec![], type_args: vec![], + byte_offset: 0, }); } _ => {} @@ -579,6 +580,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re }), args: vec![], type_args: vec![], + byte_offset: 0, }); } "on" @@ -2486,6 +2488,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re }), args: vec![*index], type_args: Vec::new(), + byte_offset: 0, }); } Ok(Expr::IndexGet { object, index }) diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index 697c55d9c7..63ece74885 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -160,6 +160,7 @@ pub(super) fn lower_update(ctx: &mut LoweringContext, update: &ast::UpdateExpr) Expr::Bool(update.prefix), ], type_args: vec![], + byte_offset: 0, }); }; let op = match update.op { diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 450c864137..14e66e2756 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -88,6 +88,7 @@ fn nonconstructable_builtin_throw_expr(name: &str, mut args: Vec) -> Expr }), args: Vec::new(), type_args: Vec::new(), + byte_offset: 0, }; if args.is_empty() { @@ -680,6 +681,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R }), args: Vec::new(), type_args: Vec::new(), + byte_offset: 0, }); return Ok(Expr::Sequence(exprs)); } @@ -953,6 +955,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R }), args, type_args: Vec::new(), + byte_offset: 0, }); } diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 06bfbf66b7..d7129e9dc8 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -967,6 +967,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }), args, type_args: Vec::new(), + byte_offset: 0, }; let mut body: Vec = Vec::with_capacity(ops.len() * 4 + 1); let mut inner_local_ids = vec![param_id]; @@ -1103,6 +1104,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R callee: Box::new(closure), args: vec![Expr::Object(Vec::new())], type_args: vec![], + byte_offset: 0, }); } let mut props = Vec::new(); @@ -1246,6 +1248,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }), args, type_args: Vec::new(), + byte_offset: 0, }; for init in computed_post_init { match init { @@ -1308,6 +1311,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }), args: vec![Expr::LocalGet(param_id), key, closure], type_args: Vec::new(), + byte_offset: 0, })); } } @@ -1359,5 +1363,6 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R callee: Box::new(closure), args: vec![static_obj], type_args: vec![], + byte_offset: 0, }) } diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index ee1c68cbc3..d94e8a2338 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -63,6 +63,7 @@ pub(crate) fn throw_reference_error_expr(helper_name: &str) -> Expr { }), args: Vec::new(), type_args: Vec::new(), + byte_offset: 0, } } @@ -196,6 +197,7 @@ pub(crate) fn with_implicit_unset_let(id: LocalId, name: String) -> Stmt { }), args: vec![], type_args: vec![], + byte_offset: 0, }), } } @@ -495,6 +497,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< }), args: vec![Expr::LocalGet(id), Expr::String(n.clone())], type_args: vec![], + byte_offset: 0, }); } Ok(Expr::LocalGet(id)) @@ -598,6 +601,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< }), args: vec![Expr::String(name.clone())], type_args: Vec::new(), + byte_offset: 0, }); } if !known_global { @@ -1051,6 +1055,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< }), args: vec![Expr::String(n.to_string())], type_args: Vec::new(), + byte_offset: 0, }))); } } @@ -1890,6 +1895,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< callee: Box::new(fixed_callee.clone()), args, type_args: Vec::new(), + byte_offset: 0, }; // For `foo?.bar?.(args)` the function value (`bar` on the // un-short-circuited receiver) must itself be null-checked @@ -1952,6 +1958,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< callee: Box::new(callee_expr), args, type_args: Vec::new(), + byte_offset: 0, }) }; @@ -2120,6 +2127,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< callee: Box::new(callee), args: call_args, type_args: vec![], + byte_offset: 0, }) } // Class expression used as a value (not in `new` context) — @@ -2574,5 +2582,6 @@ pub(crate) fn try_desugar_reactive_text( callee: Box::new(outer_closure), args: vec![], type_args: vec![], + byte_offset: 0, })) } diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index a385492999..4738235691 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -243,6 +243,7 @@ fn async_iterator_method_call(iterable: Expr) -> Expr { }), args: vec![], type_args: vec![], + byte_offset: 0, } } @@ -254,6 +255,7 @@ fn iterator_return_call(iter_id: LocalId, needs_await: bool) -> Expr { }), args: vec![], type_args: vec![], + byte_offset: 0, }; if needs_await { Expr::Await(Box::new(call)) @@ -339,6 +341,7 @@ fn iterator_result_validated(call: Expr) -> Expr { }), args: vec![call], type_args: vec![], + byte_offset: 0, } } @@ -351,6 +354,7 @@ pub(crate) fn iterator_next_call(iter_id: LocalId) -> Expr { }), args: vec![], type_args: vec![], + byte_offset: 0, }) } @@ -671,6 +675,7 @@ fn lower_runtime_for_await_iterator( }), args: vec![], type_args: vec![], + byte_offset: 0, }; let next_call = Expr::Await(Box::new(raw_next_call)); module.init.push(Stmt::Let { @@ -865,6 +870,7 @@ pub(crate) fn lower_stmt_for_of( callee: Box::new(Expr::FuncRef(iter_fn_id)), args: vec![iter_expr], type_args: vec![], + byte_offset: 0, } } else if is_filehandle_readlines_for_await || is_fs_dir_for_await { async_iterator_method_call(iter_expr) @@ -876,6 +882,7 @@ pub(crate) fn lower_stmt_for_of( }), args: vec![], type_args: vec![], + byte_offset: 0, } } else if is_readline_interface_for_await { // rl.iterator() -> readline async-iterator object; .next() then @@ -915,6 +922,7 @@ pub(crate) fn lower_stmt_for_of( }), args: vec![], type_args: vec![], + byte_offset: 0, }; let next_call = if needs_await { Expr::Await(Box::new(raw_next_call)) diff --git a/crates/perry-hir/src/lower/widget_decl.rs b/crates/perry-hir/src/lower/widget_decl.rs index 750921f33a..e67b8d107c 100644 --- a/crates/perry-hir/src/lower/widget_decl.rs +++ b/crates/perry-hir/src/lower/widget_decl.rs @@ -276,6 +276,7 @@ pub(super) fn try_desugar_reactive_animate( callee: Box::new(outer_closure), args: vec![], type_args: vec![], + byte_offset: 0, })) } diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 5bf5152c1e..c8afe0a7f9 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -1084,6 +1084,7 @@ pub fn lower_stmts_using_aware( }), args: vec![Expr::Bool(is_async)], type_args: Vec::new(), + byte_offset: 0, }; result.push(Stmt::If { condition: Expr::Logical { @@ -1163,6 +1164,7 @@ pub fn lower_stmts_using_aware( }), args: Vec::new(), type_args: Vec::new(), + byte_offset: 0, }; if is_async { call_expr = Expr::Await(Box::new(call_expr)); diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 398f1b3d1b..5993e484ce 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1025,6 +1025,7 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result Expr }), args: vec![], type_args: vec![], + byte_offset: 0, }; if needs_await { Expr::Await(Box::new(call)) diff --git a/crates/perry-hir/src/lower_decl/body_stmt/for_await.rs b/crates/perry-hir/src/lower_decl/body_stmt/for_await.rs index 5231abc2b3..2ffcc6bae3 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/for_await.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/for_await.rs @@ -29,6 +29,7 @@ pub(super) fn lower_runtime_for_await_iterator_body( }), args: vec![], type_args: vec![], + byte_offset: 0, }; let next_call = Expr::Await(Box::new(raw_next_call)); result.push(Stmt::Let { diff --git a/crates/perry-hir/src/lower_decl/class_computed.rs b/crates/perry-hir/src/lower_decl/class_computed.rs index c40dcdef16..3c1b7ee4ae 100644 --- a/crates/perry-hir/src/lower_decl/class_computed.rs +++ b/crates/perry-hir/src/lower_decl/class_computed.rs @@ -89,6 +89,7 @@ pub(crate) fn push_deduped_class_computed_keys( }), args: vec![lowered], type_args: Vec::new(), + byte_offset: 0, })); } } diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 70ea36c7d0..67fb6ed8ea 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -204,6 +204,7 @@ fn synthesize_symbol_iterator_wrapper( callee: Box::new(Expr::FuncRef(top_fn_id)), args: vec![Expr::This], type_args: Vec::new(), + byte_offset: 0, }))], is_async: false, is_generator: false, diff --git a/crates/perry-hir/src/lower_decl/helpers.rs b/crates/perry-hir/src/lower_decl/helpers.rs index 82f5ec60d9..d7ecc428aa 100644 --- a/crates/perry-hir/src/lower_decl/helpers.rs +++ b/crates/perry-hir/src/lower_decl/helpers.rs @@ -51,6 +51,7 @@ pub(super) fn async_iterator_method_call(iterable: Expr) -> Expr { }), args: vec![], type_args: vec![], + byte_offset: 0, } } diff --git a/crates/perry-hir/src/monomorph/driver.rs b/crates/perry-hir/src/monomorph/driver.rs index 639579b4aa..36e8fda847 100644 --- a/crates/perry-hir/src/monomorph/driver.rs +++ b/crates/perry-hir/src/monomorph/driver.rs @@ -217,6 +217,7 @@ fn collect_instantiations_in_expr( callee, args, type_args, + .. } => { // First collect in the callee and args collect_instantiations_in_expr(callee, ctx, module, idx); diff --git a/crates/perry-hir/src/monomorph/substitute_expr.rs b/crates/perry-hir/src/monomorph/substitute_expr.rs index fb8c87c810..fea3abf25f 100644 --- a/crates/perry-hir/src/monomorph/substitute_expr.rs +++ b/crates/perry-hir/src/monomorph/substitute_expr.rs @@ -55,6 +55,7 @@ pub(crate) fn substitute_expr(expr: &Expr, substitutions: &HashMap callee, args, type_args, + byte_offset, } => Expr::Call { callee: Box::new(substitute_expr(callee, substitutions)), args: args @@ -65,6 +66,7 @@ pub(crate) fn substitute_expr(expr: &Expr, substitutions: &HashMap .iter() .map(|t| substitute_type(t, substitutions)) .collect(), + byte_offset: *byte_offset, }, Expr::PodLayoutSizeOf { ty } => Expr::PodLayoutSizeOf { ty: substitute_type(ty, substitutions), diff --git a/crates/perry-hir/src/monomorph/tests.rs b/crates/perry-hir/src/monomorph/tests.rs index 8fb3d933ce..af7d09c7ef 100644 --- a/crates/perry-hir/src/monomorph/tests.rs +++ b/crates/perry-hir/src/monomorph/tests.rs @@ -83,6 +83,7 @@ fn test_monomorphize_substitutes_pod_layout_type_vars() { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![packet_ty.clone()], + byte_offset: 0, })); monomorphize_module(&mut module); @@ -159,6 +160,7 @@ fn test_monomorphize_substitutes_native_pod_view_type_vars() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::NativeArenaAlloc(Box::new(Expr::Integer(64)))], type_args: vec![packet_ty], + byte_offset: 0, })); monomorphize_module(&mut module); @@ -218,6 +220,7 @@ fn test_monomorphize_generic_function() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::Number(42.0)], type_args: vec![Type::Number], + byte_offset: 0, })); // Run monomorphization @@ -294,6 +297,7 @@ fn test_monomorphize_updates_call_sites() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::String("hello".to_string())], type_args: vec![Type::String], + byte_offset: 0, })); // Run monomorphization @@ -336,11 +340,13 @@ fn test_monomorphize_updates_native_memory_copy_operand_generic_calls() { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![packet_ty.clone()], + byte_offset: 0, }), src: Box::new(Expr::Call { callee: Box::new(Expr::FuncRef(2)), args: vec![], type_args: vec![packet_ty], + byte_offset: 0, }), })); @@ -369,11 +375,13 @@ fn test_monomorphize_updates_native_memory_fill_u32_operand_generic_calls() { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![packet_ty.clone()], + byte_offset: 0, }), value: Box::new(Expr::Call { callee: Box::new(Expr::FuncRef(2)), args: vec![], type_args: vec![packet_ty], + byte_offset: 0, }), })); @@ -569,6 +577,7 @@ fn test_type_inference_from_arguments() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::Number(42.0)], type_args: vec![], // Empty - should be inferred! + byte_offset: 0, })); // Run monomorphization @@ -666,6 +675,7 @@ fn test_type_inference_string() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::String("hello".to_string())], type_args: vec![], // Empty - should be inferred! + byte_offset: 0, })); // Run monomorphization @@ -735,6 +745,7 @@ fn test_type_inference_rest_type_var_binds_tuple() { Expr::String("c".to_string()), ], type_args: vec![], + byte_offset: 0, })); monomorphize_module(&mut module); @@ -789,6 +800,7 @@ fn test_type_inference_rest_array_binds_element_type() { callee: Box::new(Expr::FuncRef(1)), args: vec![Expr::String("a".to_string()), Expr::String("b".to_string())], type_args: vec![], + byte_offset: 0, })); monomorphize_module(&mut module); @@ -892,6 +904,7 @@ fn generic_call(func_id: FuncId, ty: &Type) -> Expr { callee: Box::new(Expr::FuncRef(func_id)), args: vec![], type_args: vec![ty.clone()], + byte_offset: 0, } } diff --git a/crates/perry-hir/src/monomorph/update_call_sites.rs b/crates/perry-hir/src/monomorph/update_call_sites.rs index 5f8400dd44..3f0bb7f9f1 100644 --- a/crates/perry-hir/src/monomorph/update_call_sites.rs +++ b/crates/perry-hir/src/monomorph/update_call_sites.rs @@ -135,6 +135,7 @@ fn update_call_sites_in_expr( callee, args, type_args, + .. } => { // First update the callee and args recursively update_call_sites_in_expr(callee, ctx, lookup); diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index b708b1b108..2cd353a424 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -48,7 +48,11 @@ impl SH for Expr { Expr::Unary { op, operand } => { tag(h, 15); op.hash(h); operand.as_ref().hash(h); } Expr::Compare { op, left, right } => { tag(h, 16); op.hash(h); left.as_ref().hash(h); right.as_ref().hash(h); } Expr::Logical { op, left, right } => { tag(h, 17); op.hash(h); left.as_ref().hash(h); right.as_ref().hash(h); } - Expr::Call { callee, args, type_args, } => { tag(h, 18); callee.as_ref().hash(h); args.hash(h); type_args.hash(h); } + // #5247: `byte_offset` is diagnostic-only (source-location metadata + // for runtime TypeErrors); deliberately excluded from the stable hash + // so source whitespace edits that shift offsets don't bust the object + // cache. + Expr::Call { callee, args, type_args, .. } => { tag(h, 18); callee.as_ref().hash(h); args.hash(h); type_args.hash(h); } Expr::CallSpread { callee, args, type_args, } => { tag(h, 19); callee.as_ref().hash(h); args.hash(h); type_args.hash(h); } Expr::SuperCallSpread(args) => { tag(h, 12240); for a in args { match a { CallArg::Expr(e) | CallArg::Spread(e) => e.hash(h), } } } Expr::PodLayoutSizeOf { ty } => { tag(h, 12001); ty.hash(h); } diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 35ed5ddd27..2ead024874 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -81,13 +81,65 @@ pub struct ErrorHeader { pub errors: *mut crate::array::ArrayHeader, } +thread_local! { + /// #5247: the source location (`file`, 1-based `line`) of the call + /// currently being dispatched, set by codegen-emitted + /// `js_set_call_location` immediately before a dynamic method/call + /// dispatch that can throw "X is not a function" / "is not a + /// constructor". `make_stack` reads it so the thrown TypeError's + /// `.stack` shows `at :` instead of `at `. + /// + /// Only populated when the program was compiled with `--debug-symbols` + /// (the flag that gates the codegen emission). `None` in the default + /// build, so release perf and the `` fallback are unchanged. + static CURRENT_CALL_LOCATION: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// #5247: record the source location of the call about to be dispatched. +/// Codegen emits this call right before a dynamic dispatch under +/// `--debug-symbols`. `file_ptr`/`file_len` are the (UTF-8) source path; +/// `line` is the 1-based line number. A `line` of 0 clears the location. +/// +/// # Safety +/// `file_ptr` must point to `file_len` valid bytes, or be null with +/// `file_len == 0`. +#[no_mangle] +pub unsafe extern "C" fn js_set_call_location(file_ptr: *const u8, file_len: usize, line: u32) { + if line == 0 || file_ptr.is_null() || file_len == 0 { + CURRENT_CALL_LOCATION.with(|c| *c.borrow_mut() = None); + return; + } + let bytes = std::slice::from_raw_parts(file_ptr, file_len); + let file = String::from_utf8_lossy(bytes).into_owned(); + CURRENT_CALL_LOCATION.with(|c| *c.borrow_mut() = Some((file, line))); +} + +// Generated-code-only callee: anchor against the auto-optimize LTO dead-strip +// (see project_auto_optimize_keepalive_3320). +#[used] +static KEEP_JS_SET_CALL_LOCATION: unsafe extern "C" fn(*const u8, usize, u32) = + js_set_call_location; + +/// #5247: render the current call-location frame, or `` when no +/// location was recorded (default builds, or a synthesized/offset-less site). +fn current_stack_frame() -> String { + CURRENT_CALL_LOCATION.with(|c| match &*c.borrow() { + Some((file, line)) => format!(" at {}:{}", file, line), + None => " at ".to_string(), + }) +} + unsafe fn make_stack(name: &str, message: &str) -> *mut StringHeader { - // Build a simple ": \n at " string. - // Real stack traces are not implemented; the test only checks `.includes(message)`. + // Build a simple ": \n at :" string + // (or "" when no #5247 source location is recorded). Real + // multi-frame stack traces are not implemented; the test only checks + // `.includes(message)`. + let frame = current_stack_frame(); let s = if message.is_empty() { - format!("{}\n at ", name) + format!("{}\n{}", name, frame) } else { - format!("{}: {}\n at ", name, message) + format!("{}: {}\n{}", name, message, frame) }; js_string_from_bytes(s.as_ptr(), s.len() as u32) } diff --git a/crates/perry-transform/src/deforest/call_sites.rs b/crates/perry-transform/src/deforest/call_sites.rs index d99c10c784..c4032398fb 100644 --- a/crates/perry-transform/src/deforest/call_sites.rs +++ b/crates/perry-transform/src/deforest/call_sites.rs @@ -211,6 +211,7 @@ fn try_consumer_fuse_pattern( callee, args, type_args, + .. }), .. } => match callee.as_ref() { @@ -253,6 +254,8 @@ fn try_consumer_fuse_pattern( callee: Box::new(Expr::FuncRef(callee_id)), args: new_args, type_args, + // #5247: deforestation-fused call; no single source offset. + byte_offset: 0, }; Some((2, vec![Stmt::Expr(new_call)])) } @@ -387,6 +390,7 @@ fn try_rewrite_single_stmt( callee, args, type_args, + .. }), } => match callee.as_ref() { Expr::FuncRef(fid) if producers.contains_key(fid) => { @@ -397,6 +401,7 @@ fn try_rewrite_single_stmt( callee: callee.clone(), args: new_args, type_args: type_args.clone(), + byte_offset: 0, }); let let_stmt = Stmt::Let { id: *id, diff --git a/crates/perry-transform/src/deforest/tests.rs b/crates/perry-transform/src/deforest/tests.rs index 1ca83d2d43..f7bd55ba67 100644 --- a/crates/perry-transform/src/deforest/tests.rs +++ b/crates/perry-transform/src/deforest/tests.rs @@ -58,6 +58,7 @@ fn rejects_producer_with_out_passed_to_call() { callee: Box::new(Expr::FuncRef(99)), args: vec![Expr::LocalGet(10)], type_args: vec![], + byte_offset: 0, }); assert!(analyze_producer(&func).is_none()); } @@ -159,6 +160,7 @@ fn rejects_producer_called_inside_closure() { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![], + byte_offset: 0, }), }, // return v.length; @@ -247,6 +249,7 @@ fn still_deforests_when_caller_is_not_a_closure() { callee: Box::new(Expr::FuncRef(1)), args: vec![], type_args: vec![], + byte_offset: 0, }), }], is_async: false, diff --git a/crates/perry-transform/src/generator/helpers.rs b/crates/perry-transform/src/generator/helpers.rs index 299061ce8f..10c81b1024 100644 --- a/crates/perry-transform/src/generator/helpers.rs +++ b/crates/perry-transform/src/generator/helpers.rs @@ -107,6 +107,7 @@ pub fn wrap_in_promise_resolve(value: Expr) -> Expr { }), args: vec![value], type_args: vec![], + byte_offset: 0, } } diff --git a/crates/perry-transform/src/generator/linearize.rs b/crates/perry-transform/src/generator/linearize.rs index c4f30e9666..2a5d565d69 100644 --- a/crates/perry-transform/src/generator/linearize.rs +++ b/crates/perry-transform/src/generator/linearize.rs @@ -84,6 +84,7 @@ fn delegate_next_call(del_next_id: LocalId, del_iter_id: LocalId, arg: Expr) -> }), args: call_args, type_args: vec![], + byte_offset: 0, }), else_expr: Box::new(Expr::Call { callee: Box::new(Expr::PropertyGet { @@ -92,6 +93,7 @@ fn delegate_next_call(del_next_id: LocalId, del_iter_id: LocalId, arg: Expr) -> }), args: method_args, type_args: vec![], + byte_offset: 0, }), } } diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 8553e220a1..647f04986c 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -1075,6 +1075,7 @@ fn promise_reject(value: Expr) -> Expr { }), args: vec![value], type_args: vec![], + byte_offset: 0, } } @@ -1889,6 +1890,7 @@ pub fn build_async_step_driver_direct( }), args: vec![arg], type_args: vec![], + byte_offset: 0, }; let promise_reject = |arg: Expr| Expr::Call { callee: Box::new(Expr::PropertyGet { @@ -1897,6 +1899,7 @@ pub fn build_async_step_driver_direct( }), args: vec![arg], type_args: vec![], + byte_offset: 0, }; // Rewrite every Return inside next_body to LabeledBreak(__step_done) @@ -1957,6 +1960,7 @@ pub fn build_async_step_driver_direct( callee: Box::new(Expr::LocalGet(tid)), args: vec![Expr::LocalGet(value_param_id)], type_args: vec![], + byte_offset: 0, })] } else { // No __async_throw closure was constructed (callee passed None). @@ -2033,6 +2037,7 @@ pub fn build_async_step_driver_direct( callee: Box::new(Expr::LocalGet(step_self_id)), args: vec![Expr::LocalGet(catch_e_id), Expr::Bool(true)], type_args: vec![], + byte_offset: 0, })), ], }), diff --git a/crates/perry-transform/src/state_desugar.rs b/crates/perry-transform/src/state_desugar.rs index 93328368b0..0b883868d3 100644 --- a/crates/perry-transform/src/state_desugar.rs +++ b/crates/perry-transform/src/state_desugar.rs @@ -907,6 +907,7 @@ fn try_rewrite_foreach( callee: Box::new(closure), args: vec![], type_args: vec![], + byte_offset: 0, }) } @@ -1103,6 +1104,7 @@ fn try_rewrite_navstack( callee: Box::new(closure), args: vec![], type_args: vec![], + byte_offset: 0, }) } diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index ac7e6d4b07..a7a12b49c2 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -4031,6 +4031,17 @@ pub fn run_with_parse_cache( // alone is insufficient because it's empty when the // target has no `export` statements. is_dynamic_import_target: dyn_target_paths.contains(path), + // #5247: source-location tracking for the dynamic call-dispatch + // throw path. Gated by `--debug-symbols` so the default build is + // unchanged (no source read, no per-call emission). When on, read + // the module's original source so codegen can map a Call's byte + // offset to a 1-based line. + debug_locations: args.debug_symbols, + module_source: if args.debug_symbols { + std::fs::read_to_string(path).ok() + } else { + None + }, }; // V2.2 + #686 object cache lookup. The key hashes every // codegen-affecting field of `opts` together with this diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 7b4ef5d1f1..b10ba65c63 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -165,6 +165,11 @@ fn compute_object_cache_key_with_env( // doesn't usually move between rebuilds. h.field("build_id", &format!("{:016x}", perry_build_id())); h.field("ir_only", if opts.emit_ir_only { "1" } else { "0" }); + // #5247: `--debug-symbols` flips per-call `js_set_call_location` emission, + // which changes the emitted IR (and `.o` bytes). Without this in the key, + // toggling the flag would serve the previously-cached object and the + // source locations would silently not appear. + h.field("dbgloc", if opts.debug_locations { "1" } else { "0" }); h.field( "verify_native_regions", if opts.verify_native_regions { "1" } else { "0" }, @@ -948,6 +953,8 @@ mod object_cache_tests { deferred_module_prefixes: std::collections::HashSet::new(), module_init_deps: Vec::new(), is_dynamic_import_target: false, + debug_locations: false, + module_source: None, } } @@ -1017,6 +1024,20 @@ mod object_cache_tests { ); } + #[test] + fn key_changes_with_debug_locations_flag() { + // #5247: toggling --debug-symbols (debug_locations) flips per-call + // location emission, so cached objects must not be shared across it. + let mut a = empty_opts(); + let mut b = empty_opts(); + a.debug_locations = false; + b.debug_locations = true; + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); + } + #[test] fn key_changes_with_fast_math_flag() { // Without this guard, `perry --fast-math foo.ts` after a default diff --git a/crates/perry/tests/issue_5247_runtime_error_source_location.rs b/crates/perry/tests/issue_5247_runtime_error_source_location.rs new file mode 100644 index 0000000000..bebb089b9f --- /dev/null +++ b/crates/perry/tests/issue_5247_runtime_error_source_location.rs @@ -0,0 +1,158 @@ +//! Regression test for #5247 (first increment): runtime TypeErrors from the +//! dynamic call-dispatch path ("X is not a function") carry a real source +//! `file:line` in `.stack` / `.message`'s frame when the program is compiled +//! with `--debug-symbols`. +//! +//! Behavior: +//! • WITH `--debug-symbols`: the thrown TypeError's `.stack` contains +//! `at :` pointing at the offending call's line. +//! • WITHOUT the flag (default build): unchanged — `at `. +//! +//! The fixture exercises the highest-value path the increment targets: a +//! method call on an `any`-typed value that is actually a primitive at runtime +//! (`const f: any = 5; f.test();`), which dispatches dynamically and throws +//! `TypeError: (number).test is not a function`. + +use std::path::PathBuf; +use std::process::Command; +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 target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_runtime.a` once so the compiled binaries can link. +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime") + .output() + .expect("run cargo build -p perry-runtime"); + assert!( + build.status.success(), + "cargo build -p perry-runtime failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// `f.test()` lives on line 4 of this fixture (1 = blank from the raw-string +/// leading newline, 2 = `const`, 3 = `try {`, 4 = `f.test();`). +const FIXTURE: &str = r#" +const f: any = 5; +try { + f.test(); +} catch (e: any) { + console.log("MSG:" + e.message); + console.log("STACK:" + e.stack); +} +"#; + +fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output { + let entry = root.join("main.ts"); + let output = root.join("main_bin"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + for a in extra_args { + cmd.arg(a); + } + cmd.env("PERRY_NO_AUTO_OPTIMIZE", "1"); + cmd.env("PERRY_RUNTIME_DIR", runtime_dir()); + cmd.output().expect("run perry compile") +} + +#[test] +fn debug_symbols_attaches_file_line_to_not_a_function_throw() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("main.ts"), FIXTURE).expect("write entry"); + + let out = compile(root, &["--debug-symbols"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "compile --debug-symbols must succeed; stderr:\n{stderr}" + ); + + let bin = root.join("main_bin"); + let run = Command::new(&bin).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + + // The dynamic dispatch threw the expected TypeError. + assert!( + stdout.contains("MSG:") && stdout.contains("is not a function"), + "expected a 'is not a function' TypeError; got:\n{stdout}" + ); + // The stack frame names the source file and the line of `f.test()` (4), + // not ``. + assert!( + stdout.contains("at main.ts:4"), + "expected 'at main.ts:4' frame with --debug-symbols; got:\n{stdout}" + ); + assert!( + !stdout.contains(""), + "the location must replace the frame; got:\n{stdout}" + ); +} + +#[test] +fn default_build_keeps_anonymous_frame() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("main.ts"), FIXTURE).expect("write entry"); + + let out = compile(root, &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "default compile must succeed; stderr:\n{stderr}" + ); + + let bin = root.join("main_bin"); + let run = Command::new(&bin).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + + assert!( + stdout.contains("is not a function"), + "expected a 'is not a function' TypeError; got:\n{stdout}" + ); + // Default build is unchanged: the coarse frame, no file:line. + assert!( + stdout.contains("at "), + "default build must keep the frame; got:\n{stdout}" + ); + assert!( + !stdout.contains("at main.ts:"), + "default build must NOT emit a source location; got:\n{stdout}" + ); +}