From 8a0057dfe9584e5b0e29b07116ffcedf86343246 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 04:33:54 +0000 Subject: [PATCH 01/20] Make coverage, stack frames, call sites and source maps agree with the source, with one test over every place a position is reported --- scripts/build/deps/webkit.ts | 2 +- src/bun_core/string/immutable.rs | 7 + .../linker_context/convertStmtsForChunk.rs | 4 +- src/js/node/inspector.ts | 16 +- src/js/node/util.ts | 5 +- src/js_parser/p.rs | 6 +- src/js_parser/parse/parse_jsx.rs | 2 +- src/js_parser/visit/visit_expr.rs | 3 +- src/js_printer/lib.rs | 19 +- src/jsc/bindings/CallSitePrototype.cpp | 5 +- src/jsc/bindings/FormatStackTraceForJS.cpp | 9 +- src/sourcemap/InternalSourceMap.rs | 106 +-- src/sourcemap/LineOffsetTable.rs | 39 +- src/sourcemap/ParsedSourceMap.rs | 20 +- src/sourcemap/lib.rs | 2 + src/sourcemap_jsc/CodeCoverage.rs | 659 ++++++++---------- src/sourcemap_jsc/JSSourceMap.rs | 6 +- .../test/__snapshots__/coverage.test.ts.snap | 26 +- test/cli/test/coverage.test.ts | 22 +- .../bun/sourcemap/source-positions-corpus.ts | 578 +++++++++++++++ .../js/bun/sourcemap/source-positions.test.ts | 487 +++++++++++++ test/js/bun/util/inspect-error.test.js | 4 +- .../node/inspector/inspector-profiler.test.ts | 43 ++ 23 files changed, 1503 insertions(+), 567 deletions(-) create mode 100644 test/js/bun/sourcemap/source-positions-corpus.ts create mode 100644 test/js/bun/sourcemap/source-positions.test.ts diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 00f04ad65913..4d4868f2b7f1 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "87ee10da087b63e011bd1ada64d1814a850a4395"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-dd699577"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index 57decef055d6..943ee9a86342 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -1900,6 +1900,13 @@ pub(crate) fn index_of_line_ranges( let _ = new_ranges.extend_from_slice(&ranges.as_slice()[1..]); // OOM/capacity: fire-and-forget ranges = new_ranges; } + if current_line <= target_line { + let _ = ranges.push(LineRange { + start: prev_end, + // Wrapping cast. + end: text.len() as u32, + }); // OOM/capacity: fire-and-forget + } ranges } diff --git a/src/bundler/linker_context/convertStmtsForChunk.rs b/src/bundler/linker_context/convertStmtsForChunk.rs index 8d0fa4992e59..f9e72a5ae5ec 100644 --- a/src/bundler/linker_context/convertStmtsForChunk.rs +++ b/src/bundler/linker_context/convertStmtsForChunk.rs @@ -577,7 +577,7 @@ pub(crate) fn convert_stmts_for_chunk( core::ptr::read(&raw const s2.func) }, }, - stmt.loc, + stmt2.loc, ); stmt.data.s_function_mut().unwrap().func.name = Some(s.default_name); @@ -596,7 +596,7 @@ pub(crate) fn convert_stmts_for_chunk( }, is_export: false, }, - stmt.loc, + stmt2.loc, ); stmt.data.s_class_mut().unwrap().class.class_name = Some(s.default_name); diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index 12f4d84a037c..e473cf484d10 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -307,13 +307,19 @@ function buildScriptCoverageList( url = pathToFileURL(url).href; } - // Outer functions before nested ones, so a stack-based sweep below sees - // enclosing ranges first. Zero-width entries are dropped: V8 never emits + // JSC's ranges include their last character; V8's end after it. What is empty or inverted + // after that is text JSC has between two adjacent functions: V8 never emits // startOffset === endOffset, and @bcoe/v8-coverage recurses forever on one. + // The program is one of JSC's function ranges, and has the whole-script entry below. + // Outer functions before nested ones, so a stack-based sweep below sees enclosing ranges first. const functions = script.functions - .filter(([start, end]) => start >= 0 && end > start) + .map(([start, end, executed]): [number, number, boolean] => [start, end + 1, executed]) + .filter(([start, end]) => start >= 0 && end > start && !(start === 0 && end === sourceLength)) .sort((a, b) => a[0] - b[0] || b[1] - a[1]); - const blocks = script.blocks.filter(([start, end]) => start >= 0 && end > start).sort((a, b) => a[0] - b[0]); + const blocks = script.blocks + .map(([start, end, count]): [number, number, number] => [start, end + 1, count]) + .filter(([start, end]) => start >= 0 && end > start) + .sort((a, b) => a[0] - b[0]); // Assign each basic block to the innermost function range containing it. const blocksPerFunction: Array> = functions.map(() => []); @@ -327,7 +333,7 @@ function buildScriptCoverageList( } // Functions that ended before this block started can no longer contain // this block or any later one (blocks are sorted by start). - while (stack.length > 0 && functions[stack[stack.length - 1]][1] < block[0]) { + while (stack.length > 0 && functions[stack[stack.length - 1]][1] <= block[0]) { stack.pop(); } // The stack is a nesting chain (siblings get popped above), so ends diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 0d1abba7777e..5f1492c0617b 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -552,9 +552,8 @@ function prepareCallSites(_err, callSites) { const result: CallSiteObject[] = []; for (let i = 0; i < callSites.length; i++) { const callSite = callSites[i]; - // CallSite#getColumnNumber() is 0-based here but 1-based in V8, and node - // exposes the column under both names. - const columnNumber = (callSite.getColumnNumber() ?? 0) + 1; + // node exposes the column under both names. + const columnNumber = callSite.getColumnNumber() ?? 0; result.push({ functionName: callSite.getFunctionName() ?? "", scriptId: `${callSite.getScriptId()}`, diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index b5795f0a802d..562fe78ae6c3 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1538,7 +1538,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } - let _ = self.check_dynamic_specifier(arg, state.loc, "import()"); + let _ = self.check_dynamic_specifier(arg, arg.loc, "import()"); self.new_expr( E::Import { @@ -1728,7 +1728,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O import_record_index, unwrapped_id, }, - arg.loc, + state.loc, ); } @@ -1751,7 +1751,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O import_record_index, ..Default::default() }, - arg.loc, + state.loc, ) } _ => { diff --git a/src/js_parser/parse/parse_jsx.rs b/src/js_parser/parse/parse_jsx.rs index dbeebdcfd831..9a6c1866a790 100644 --- a/src/js_parser/parse/parse_jsx.rs +++ b/src/js_parser/parse/parse_jsx.rs @@ -363,7 +363,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Ok(p.new_expr( E::JSXElement { - tag: end_tag.data.as_expr(), + tag: tag.data.as_expr(), children: ExprNodeList::move_from_list(children), properties, key_prop_index: key_prop_i, diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 23c08903f202..b8219644717c 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1856,7 +1856,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Data::EImport(nt) if core::ptr::eq(&raw const *e_, &raw const *nt) ), import_options: e_.options, - loc: e_.expr.loc, + loc: e.loc, import_loader: e_.import_record_loader(), ..Default::default() }; @@ -2171,6 +2171,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let state = TransposeState { is_require_immediately_assigned_to_decl: in_.is_immediately_assigned_to_decl && matches!(first.data, Data::EString(..)), + loc: expr.loc, ..Default::default() }; match &first.data { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 6163a50c0eca..af6f8a5f3b85 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2978,8 +2978,6 @@ pub(crate) mod __gated_printer { } // External import() - self.add_source_mapping(record.range.loc); - self.print_space_before_identifier(); let preload = record.flags.contains(ImportRecordFlags::IMPORTS_CHUNK) @@ -3576,6 +3574,7 @@ pub(crate) mod __gated_printer { } } ExprData::ERequireString(e) => { + self.add_source_mapping(expr.loc); self.print_require_or_import_expr( e.import_record_index, e.unwrapped_id.is_some(), @@ -3644,6 +3643,7 @@ pub(crate) mod __gated_printer { self.print(b")"); } } else { + self.add_source_mapping(expr.loc); self.print_require_or_import_expr( e.import_record_index, false, @@ -5392,7 +5392,6 @@ pub(crate) mod __gated_printer { StmtData::SFunction(s) => { self.print_indent(); self.print_space_before_identifier(); - self.add_source_mapping(stmt.loc); let name = s.func.name.as_ref().unwrap_or_else(|| { Output::panic(format_args!( "Internal error: expected func to have a name ref" @@ -5400,9 +5399,11 @@ pub(crate) mod __gated_printer { }); let name_ref = name.ref_; + // `stmt.loc` is what follows `export`. if s.func.flags.contains(G::FnFlags::IsExport) { self.print(b"export "); } + self.add_source_mapping(stmt.loc); if s.func.flags.contains(G::FnFlags::IsAsync) { self.print(b"async "); } @@ -5436,12 +5437,12 @@ pub(crate) mod __gated_printer { self.print_indent(); self.print_space_before_identifier(); - self.add_source_mapping(stmt.loc); let name_ref = s.class.class_name.as_ref().unwrap().ref_; if s.is_export { self.print(b"export "); } + self.add_source_mapping(stmt.loc); self.print(b"class "); self.add_source_mapping(s.class.class_name.as_ref().unwrap().loc); let name_str = self.name_for_symbol(name_ref); @@ -5470,7 +5471,9 @@ pub(crate) mod __gated_printer { StmtData::SExportDefault(s) => { self.print_indent(); self.print_space_before_identifier(); - self.add_source_mapping(stmt.loc); + if let js_ast::StmtOrExpr::Expr(_) = s.value { + self.add_source_mapping(stmt.loc); + } self.print(b"export default "); match s.value { @@ -5493,6 +5496,7 @@ pub(crate) mod __gated_printer { return Ok(()); } js_ast::StmtOrExpr::Stmt(s2) => { + self.add_source_mapping(s2.loc); match &s2.data { StmtData::SFunction(func) => { self.print_space_before_identifier(); @@ -6663,8 +6667,7 @@ pub(crate) mod __gated_printer { self.print_semicolon_if_needed(); self.print_indent(); self.print_space_before_identifier(); - self.add_source_mapping(loc); - self.print_decl_stmt(is_export, kind, decls); + self.print_decl_stmt(loc, is_export, kind, decls); } /// The initializer of such a declarator, as a statement. Its value is @@ -6786,6 +6789,7 @@ pub(crate) mod __gated_printer { pub(crate) fn print_decl_stmt( &mut self, + loc: bun_ast::Loc, is_export: bool, kind: S::Kind, decls: &[G::Decl], @@ -6793,6 +6797,7 @@ pub(crate) mod __gated_printer { if is_export { self.print(b"export "); } + self.add_source_mapping(loc); let tlm: TopLevelAndIsExport = if Self::MAY_HAVE_MODULE_INFO { TopLevelAndIsExport { is_export } } else { diff --git a/src/jsc/bindings/CallSitePrototype.cpp b/src/jsc/bindings/CallSitePrototype.cpp index 70a9e594d1f2..066d733329ec 100644 --- a/src/jsc/bindings/CallSitePrototype.cpp +++ b/src/jsc/bindings/CallSitePrototype.cpp @@ -142,8 +142,7 @@ JSC_DEFINE_HOST_FUNCTION(callSiteProtoFuncGetLineNumber, (JSGlobalObject * globa JSC_DEFINE_HOST_FUNCTION(callSiteProtoFuncGetColumnNumber, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { ENTER_PROTO_FUNC(); - // https://github.com/mozilla/source-map/blob/60adcb064bf033702d954d6d3f9bc3635dcb744b/lib/source-map-consumer.js#L488-L489 - return JSC::JSValue::encode(jsNumber(std::max(callSite->columnNumber().zeroBasedInt(), 0))); + return JSC::JSValue::encode(jsNumber(std::max(callSite->columnNumber().oneBasedInt(), 1))); } JSC_DEFINE_HOST_FUNCTION(callSiteProtoFuncGetScriptId, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) @@ -250,7 +249,7 @@ JSC_DEFINE_HOST_FUNCTION(callSiteProtoFuncToJSON, (JSGlobalObject * globalObject JSObject* obj = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 4); Bun::putDirectNamed(vm, obj, "sourceURL"_s, callSite->sourceURL()); Bun::putDirectNamed(vm, obj, "lineNumber"_s, jsNumber(callSite->lineNumber().oneBasedInt())); - Bun::putDirectNamed(vm, obj, "columnNumber"_s, jsNumber(callSite->columnNumber().zeroBasedInt())); + Bun::putDirectNamed(vm, obj, "columnNumber"_s, jsNumber(callSite->columnNumber().oneBasedInt())); Bun::putDirectNamed(vm, obj, "functionName"_s, callSite->functionName()); return JSC::JSValue::encode(obj); } diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index cebb7c8f74cd..2608c4d02b5e 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -434,14 +434,11 @@ WTF::String formatStackTrace( if (!sourceURLForFrame.isEmpty()) { sb.append(sourceURLForFrame); - if (displayLine.zeroBasedInt() > 0 || displayColumn.zeroBasedInt() > 0) { + if (frame.hasLineAndColumnInfo()) { sb.append(':'); sb.append(displayLine.oneBasedInt()); - - if (displayColumn.zeroBasedInt() > 0) { - sb.append(':'); - sb.append(displayColumn.oneBasedInt()); - } + sb.append(':'); + sb.append(displayColumn.oneBasedInt()); } } diff --git a/src/sourcemap/InternalSourceMap.rs b/src/sourcemap/InternalSourceMap.rs index fbf1e5b1c522..0daa7bc507ed 100644 --- a/src/sourcemap/InternalSourceMap.rs +++ b/src/sourcemap/InternalSourceMap.rs @@ -398,8 +398,8 @@ mod win_hdr { /// streams are consumed in order, so a reader is forward-only. // Invariant: `bytes`/`base`/`src_idx_mask` point into the blob and are only // valid while the blob is live. They are raw pointers (not lifetimes) because -// readers are stored in lifetime-less caches (`FindCacheSlot`, `Cursor`) that -// follow `InternalSourceMap`'s Copy-view design. +// readers are stored in a lifetime-less cache (`FindCacheSlot`) that +// follows `InternalSourceMap`'s Copy-view design. #[derive(Copy, Clone)] struct WindowReader { bytes: *const [u8], @@ -637,99 +637,19 @@ impl InternalSourceMap { } } -/// Stateful forward cursor. `move_to` is cheap when successive targets are -/// monotonically non-decreasing in generated position; otherwise it reseeks via -/// the sync index. -/// -/// Invariant: when `has_state`, `reader` is positioned such that calling -/// `advance_one()` produces the mapping immediately after `peek orelse state`. -pub struct Cursor { - map: InternalSourceMap, - state: State, - peek: Option, - reader: WindowReader, - sync_idx: u32, - has_state: bool, -} - -impl Cursor { - pub(crate) fn init(map: InternalSourceMap) -> Cursor { - Cursor { - map, - state: State::default(), - peek: None, - reader: WindowReader::DANGLING, - sync_idx: 0, - has_state: false, - } - } - - pub fn move_to(&mut self, line: Ordinal, column: Ordinal) -> Option { - let target_line = line.zero_based(); - let target_col = column.zero_based(); - - if !self.has_state || !self.state.less_or_equal(target_line, target_col) { - if !self.reseek(target_line, target_col) { - return None; - } - } - - loop { - if let Some(p) = self.peek { - if !p.less_or_equal(target_line, target_col) { - break; - } - self.state = p; - self.peek = None; - } - let Some(nxt) = self.advance_one() else { break }; - if nxt.less_or_equal(target_line, target_col) { - self.state = nxt; - } else { - self.peek = Some(nxt); - break; - } - } - - if self.state.generated_line != target_line { - return None; - } - Some(self.state.to_mapping()) - } - - fn advance_one(&mut self) -> Option { - if self.reader.done() { - let n_sync = self.map.sync_count(); - if self.sync_idx + 1 >= n_sync { - return None; +impl InternalSourceMap { + /// Every mapping, in generated order. + pub(crate) fn for_each(self, mut each: impl FnMut(Mapping)) { + for idx in 0..self.sync_count() { + let mut state = State::default(); + let mut reader = WindowReader::DANGLING; + self.seed_window(idx, &mut state, &mut reader); + each(state.to_mapping()); + while !reader.done() { + reader.next(&mut state); + each(state.to_mapping()); } - self.sync_idx += 1; - let mut seed = State::default(); - self.map - .seed_window(self.sync_idx, &mut seed, &mut self.reader); - return Some(seed); } - let mut nxt = self.peek.unwrap_or(self.state); - self.reader.next(&mut nxt); - Some(nxt) - } - - fn reseek(&mut self, target_line: i32, target_col: i32) -> bool { - let Some(idx) = self.map.locate_window(target_line, target_col) else { - self.has_state = false; - return false; - }; - self.sync_idx = idx; - self.map.seed_window(idx, &mut self.state, &mut self.reader); - self.peek = None; - self.has_state = true; - true - } -} - -impl InternalSourceMap { - pub(crate) fn cursor(self) -> Cursor { - Cursor::init(self) } /// Re-encode the full mapping stream as a standard VLQ "mappings" string. Only diff --git a/src/sourcemap/LineOffsetTable.rs b/src/sourcemap/LineOffsetTable.rs index de4051acf294..154448a38654 100644 --- a/src/sourcemap/LineOffsetTable.rs +++ b/src/sourcemap/LineOffsetTable.rs @@ -21,9 +21,8 @@ use smallvec::SmallVec; /// Generic over `A` so the bundler can put both the SoA slab and every /// `columns_for_non_ascii` payload into the per-worker AST heap (`AstAlloc`) /// and bulk-free them on `mi_heap_destroy` instead of walking -/// `LinkerGraph.files` in `deinit_without_freeing_arena`. Non-bundler callers -/// (`CodeCoverage`, the runtime printer's lazy table) keep the `Global` -/// default and free normally. +/// `LinkerGraph.files` in `deinit_without_freeing_arena`. The runtime printer's +/// lazy table keeps the `Global` default and frees normally. pub struct LineOffsetTable { pub columns_for_non_ascii: Box<[i32], A>, /// Byte offset of the first non-ASCII byte on this line, or `i32::MAX as u32` @@ -118,44 +117,12 @@ impl LineOffsetTable { Self::find_line(offsets, loc) } - pub fn find_index(byte_offsets_to_start_of_line: &[u32], loc: Loc) -> Option { - debug_assert!(loc.start > -1); // checked by caller - let mut original_line: usize = 0; - let loc_start = usize::try_from(loc.start).expect("int cast"); - - let mut count = byte_offsets_to_start_of_line.len(); - while count > 0 { - let step = count / 2; - let i = original_line + step; - let byte_offset = byte_offsets_to_start_of_line[i] as usize; - if byte_offset == loc_start { - return Some(i); - } - if i + 1 < byte_offsets_to_start_of_line.len() { - let next_byte_offset = byte_offsets_to_start_of_line[i + 1] as usize; - if byte_offset < loc_start && loc_start < next_byte_offset { - return Some(i); - } - } - - if byte_offset < loc_start { - original_line = i + 1; - count = count - step - 1; - } else { - count = step; - } - } - - None - } - /// `Global`-allocator convenience wrapper around [`generate_in`]. pub fn generate(contents: &[u8], approximate_line_count: i32) -> Result { Self::generate_in::(contents, approximate_line_count) } - // Callers pass mixed allocators (printer/bundler arenas vs VM default - // allocator in code coverage): the bundler routes `A = AstAlloc` so the + // Callers pass mixed allocators: the bundler routes `A = AstAlloc` so the // table bulk-frees with the per-worker AST heap, everyone else uses // [`generate`] (`A = Global`). pub fn generate_in( diff --git a/src/sourcemap/ParsedSourceMap.rs b/src/sourcemap/ParsedSourceMap.rs index 372acb606616..3cc9be8dfae1 100644 --- a/src/sourcemap/ParsedSourceMap.rs +++ b/src/sourcemap/ParsedSourceMap.rs @@ -247,8 +247,24 @@ impl ParsedSourceMap { self.mappings.find(line, column) } - pub fn internal_cursor(&self) -> Option { - self.internal.as_ref().map(|ism| ism.cursor()) + /// Every mapping, in generated order. + pub fn for_each_mapping(&self, mut each: impl FnMut(Mapping)) { + if let Some(ism) = &self.internal { + return ism.for_each(each); + } + let (generated, original, source_index) = ( + self.mappings.generated(), + self.mappings.original(), + self.mappings.source_index(), + ); + for i in 0..generated.len() { + each(Mapping { + generated: generated[i], + original: original[i], + source_index: source_index[i], + name_index: -1, + }); + } } pub(crate) fn standalone_module_graph_data(&self) -> *mut crate::SerializedSourceMap::Loaded { diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index de4d0c42ba61..ed0ae364c0d6 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -167,6 +167,8 @@ impl LineColumnOffset { debug_assert!(i >= offset); debug_assert!((i as usize) < input.len()); + this.columns = this.columns.add_scalar((i - offset) as i32); + let iter = strings::CodepointIterator::init(input); let mut cursor = strings::Cursor { i, diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 72308d3e3f87..0dda5838f0dd 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -3,32 +3,19 @@ use core::ffi::{c_int, c_void}; use core::ptr::NonNull; use std::borrow::Cow; -use bun_ast::Loc; use bun_collections::VecExt; use bun_collections::bit_set::DynamicBitSet; use bun_core::{self, Utf8Bytes}; use bun_jsc::{JSGlobalObject, JSValue, VM, bun_string_jsc}; -use bun_sourcemap::{ - LineOffsetTable, LineOffsetTableColumns as _, Ordinal, ParsedSourceMap, internal_source_map, - line_offset_table, -}; +use bun_sourcemap::{Ordinal, ParsedSourceMap}; type LinesHits = Vec; type Bitset = DynamicBitSet; -/// Our code coverage currently only deals with lines of code, not statements or branches. -/// JSC doesn't expose function names in their coverage data, so we don't include that either :(. -/// Since we only need to store line numbers, our job gets simpler +/// Line and function coverage of one source file. /// -/// We can use two bitsets to store code coverage data for a given file -/// 1. executable_lines -/// 2. lines_which_have_executed -/// -/// Not all lines of code are executable. Comments, whitespace, empty lines, etc. are not executable. -/// It's not a problem for anyone if comments, whitespace, empty lines etc are not executed, so those should always be omitted from coverage reports -/// -/// We use two bitsets since the typical size will be decently small, -/// bitsets are simple and bitsets are relatively fast to construct and query +/// A line is executable if the source map has code of it in the text JSC compiled, so comments, +/// blank lines and types are not. What ran does not change which lines are executable. pub struct Report<'a> { pub source_url: Cow<'a, [u8]>, pub(crate) executable_lines: Bitset, @@ -260,19 +247,12 @@ pub mod wire { /// Folds several processes' `Report`s for one source file into one, for /// `bun test --parallel` where each worker that loaded the file reports it. /// -/// Hits and executed lines/functions/blocks union across reports. Executable -/// lines do not: a process that never ran a function marks the function's -/// whole line span (blank lines included) executable, while one that ran it -/// knows the real lines. So a line counts as executable only if it executed -/// somewhere or every report agrees it is executable; otherwise the coarse -/// span from an import-only worker would show a fully executed function as -/// partially covered (#39930). +/// Hits add up; everything else is a union. #[derive(Default)] pub struct MergedReport { source_url: Vec, - reports: u32, - executable_in_all: Bitset, - executed_in_any: Bitset, + executable_lines: Bitset, + lines_which_have_executed: Bitset, line_hits: LinesHits, /// Every report's ranges with their executed bit; deduplicated in `finish`. functions: Vec<(ByteRange, bool)>, @@ -281,32 +261,22 @@ pub struct MergedReport { impl MergedReport { pub fn add(&mut self, report: &Report<'_>) -> Result<(), bun_alloc::AllocError> { - let n = report.line_hits.len(); - self.reports += 1; - if self.reports == 1 { + if self.source_url.is_empty() { self.source_url = report.source_url.to_vec(); - self.executable_in_all = report.executable_lines.clone()?; - self.executed_in_any = report.lines_which_have_executed.clone()?; - self.line_hits.clone_from(&report.line_hits); - } else { - if n != self.line_hits.len() { - // Same path, different contents between workers. Keep the - // longer view; lines past the shorter one's end count only - // if executed. - let len = n.max(self.line_hits.len()); - self.executable_in_all.resize(len, false)?; - self.executed_in_any.resize(len, false)?; - self.line_hits.resize(len, 0); - } - let mut executable = report.executable_lines.clone()?; - let mut executed = report.lines_which_have_executed.clone()?; - executable.resize(self.line_hits.len(), false)?; - executed.resize(self.line_hits.len(), false)?; - self.executable_in_all.set_intersection(&executable); - self.executed_in_any.set_union(&executed); - for (sum, &hits) in self.line_hits.iter_mut().zip(&report.line_hits) { - *sum = sum.saturating_add(hits); - } + } + // Workers can see different contents at one path. + let len = report.line_hits.len().max(self.line_hits.len()); + self.executable_lines.resize(len, false)?; + self.lines_which_have_executed.resize(len, false)?; + self.line_hits.resize(len, 0); + let mut executable = report.executable_lines.clone()?; + let mut executed = report.lines_which_have_executed.clone()?; + executable.resize(len, false)?; + executed.resize(len, false)?; + self.executable_lines.set_union(&executable); + self.lines_which_have_executed.set_union(&executed); + for (sum, &hits) in self.line_hits.iter_mut().zip(&report.line_hits) { + *sum = sum.saturating_add(hits); } for (i, &r) in report.functions.iter().enumerate() { self.functions @@ -341,14 +311,12 @@ impl MergedReport { } pub fn finish(self) -> Result, bun_alloc::AllocError> { - let mut executable_lines = self.executable_in_all; - executable_lines.set_union(&self.executed_in_any); let (functions, functions_which_have_executed) = Self::dedupe(self.functions)?; let (stmts, stmts_which_have_executed) = Self::dedupe(self.stmts)?; Ok(Report { source_url: Cow::Owned(self.source_url), - executable_lines, - lines_which_have_executed: self.executed_in_any, + executable_lines: self.executable_lines, + lines_which_have_executed: self.lines_which_have_executed, line_hits: self.line_hits, functions, functions_which_have_executed, @@ -604,10 +572,7 @@ impl Generator { // provided by JSC for the duration of this synchronous callback. let all = unsafe { core::slice::from_raw_parts(blocks_ptr, blocks_len) }; let blocks: &[BasicBlockRange] = &all[0..function_start_offset]; - let mut function_blocks: &[BasicBlockRange] = &all[function_start_offset..blocks_len]; - if function_blocks.len() > 1 { - function_blocks = &function_blocks[1..]; - } + let function_blocks: &[BasicBlockRange] = &all[function_start_offset..blocks_len]; if blocks.is_empty() { return; @@ -641,10 +606,14 @@ pub struct BasicBlockRange { } pub struct ByteRangeMapping { - pub(crate) line_offset_table: line_offset_table::List, + /// The text JSC compiled. JSC's offsets and the source map's generated columns both count + /// its code units. + source: bun_core::String, + /// Code unit offset of the first unit of each line of `source`. + line_starts: Vec, /// JSC records coverage per `SourceProvider`, and each load of the file makes one. pub(crate) source_ids: Vec, - /// Of the text `line_offset_table` was built from. + /// Of `source`. source_hash: u64, pub source_url: Utf8Bytes<'static>, } @@ -710,336 +679,151 @@ impl ByteRangeMapping { ignore_sourcemap: bool, ) -> Result, bun_alloc::AllocError> { let source_url = self.source_url.slice(); - let line_starts = self.line_offset_table.items_byte_offset_to_start_of_line(); - - let mut executable_lines: Bitset; - let mut lines_which_have_executed: Bitset; - // `SavedSourceMap::get` returns an `Option>`, so the - // +1 ref is released automatically when `parsed_mappings_` drops at scope - // exit — no explicit guard is required. - let parsed_mappings_: Option> = - // SAFETY: `VirtualMachine::get()` returns the live singleton `*mut VirtualMachine` - // with full write provenance; dereference to call the `&mut self` accessor. - bun_jsc::VirtualMachine::VirtualMachine::get().as_mut() + let source_map: Option> = if ignore_sourcemap { + None + } else { + bun_jsc::VirtualMachine::VirtualMachine::get() + .as_mut() .source_mappings() - .get(source_url); - let mut line_hits: LinesHits; - - let mut functions: Vec = Vec::new(); - functions.reserve_exact(function_blocks.len()); - let mut functions_which_have_executed: Bitset = Bitset::init_empty(function_blocks.len())?; - let mut stmts_which_have_executed: Bitset = Bitset::init_empty(blocks.len())?; - - let mut stmts: Vec = Vec::new(); - stmts.reserve_exact(blocks.len()); - - let line_count: u32; - - if ignore_sourcemap || parsed_mappings_.is_none() { - line_count = line_starts.len() as u32; - executable_lines = Bitset::init_empty(line_count as usize)?; - lines_which_have_executed = Bitset::init_empty(line_count as usize)?; - line_hits = vec![0u32; line_count as usize]; - let line_hits_slice = line_hits.as_mut_slice(); - - for block in blocks { - if block.end_offset < 0 || block.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = - usize::try_from(block.start_offset.min(block.end_offset)).expect("int cast"); - let max: usize = - usize::try_from(block.start_offset.max(block.end_offset)).expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - let has_executed = block.has_executed || block.execution_count > 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let line: u32 = u32::try_from(new_line_index).expect("int cast"); - min_line = min_line.min(line); - max_line = max_line.max(line); - - executable_lines.set(line as usize); - if has_executed { - lines_which_have_executed.set(line as usize); - line_hits_slice[line as usize] += 1; - } - } - - if min_line != u32::MAX { - if has_executed { - stmts_which_have_executed.set(stmts.len()); - } - - stmts.push(ByteRange::of(min, max)); - } - } - - for function in function_blocks { - if function.end_offset < 0 || function.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = usize::try_from(function.start_offset.min(function.end_offset)) - .expect("int cast"); - let max: usize = usize::try_from(function.start_offset.max(function.end_offset)) - .expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let line: u32 = u32::try_from(new_line_index).expect("int cast"); - min_line = min_line.min(line); - max_line = max_line.max(line); - } - - let did_fn_execute = function.execution_count > 0 || function.has_executed; - - // only mark the lines as executable if the function has not executed - // functions that have executed have non-executable lines in them and thats fine. - if !did_fn_execute { - let end = max_line.min(line_count); - line_hits_slice[min_line as usize..end as usize].fill(0); - for line in min_line..end { - executable_lines.set(line as usize); - lines_which_have_executed.unset(line as usize); - } - } - - if did_fn_execute { - functions_which_have_executed.set(functions.len()); - } - functions.push(ByteRange::of(min, max)); - } - } else if let Some(parsed_mapping) = parsed_mappings_.as_deref() { - line_count = (parsed_mapping.input_line_count as u32) + 1; - executable_lines = Bitset::init_empty(line_count as usize)?; - lines_which_have_executed = Bitset::init_empty(line_count as usize)?; - line_hits = vec![0u32; line_count as usize]; - let line_hits_slice = line_hits.as_mut_slice(); - - let mut cur_: Option = parsed_mapping.internal_cursor(); - - for block in blocks { - if block.end_offset < 0 || block.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = - usize::try_from(block.start_offset.min(block.end_offset)).expect("int cast"); - let max: usize = - usize::try_from(block.start_offset.max(block.end_offset)).expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - let has_executed = block.has_executed || block.execution_count > 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - let column_position = - byte_offset.saturating_sub(line_start_byte_offset as usize); - - let found: Option = if let Some(c) = cur_.as_mut() { - c.move_to( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - } else { - parsed_mapping.find_mapping( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - }; - if let Some(point) = found.as_ref() { - if point.original.lines.zero_based() < 0 { - continue; - } - - let line: u32 = - u32::try_from(point.original.lines.zero_based()).expect("int cast"); - if line >= line_count { - continue; - } - - executable_lines.set(line as usize); - if has_executed { - lines_which_have_executed.set(line as usize); - line_hits_slice[line as usize] += 1; - } - - min_line = min_line.min(line); - max_line = max_line.max(line); - } - } - - if min_line != u32::MAX { - if has_executed { - stmts_which_have_executed.set(stmts.len()); - } - stmts.push(ByteRange::of(min, max)); + .get(source_url) + }; + + let mut blocks: Vec = + blocks.iter().filter_map(CountedRange::from_jsc).collect(); + blocks.sort_unstable_by_key(|block| block.range); + + let source_len = u32::try_from(self.source.length()).expect("int cast"); + let mut functions: Vec = Vec::with_capacity(function_blocks.len()); + for function in function_blocks { + let Some(mut function) = CountedRange::from_jsc(function) else { + continue; + }; + // JSC does not count calls. The function's first block runs once per call. + if function.count > 0 { + let first = blocks.partition_point(|b| b.range.start < function.range.start); + if let Some(entry) = blocks.get(first) + && entry.range.start < function.range.end + { + function.count = entry.count; } } - - for function in function_blocks { - if function.end_offset < 0 || function.start_offset < 0 { - continue; // does not map to anything - } - - let min: usize = usize::try_from(function.start_offset.min(function.end_offset)) - .expect("int cast"); - let max: usize = usize::try_from(function.start_offset.max(function.end_offset)) - .expect("int cast"); - let mut min_line: u32 = u32::MAX; - let mut max_line: u32 = 0; - - for byte_offset in min..max { - let Some(new_line_index) = LineOffsetTable::find_index( - line_starts, - Loc { - start: i32::try_from(byte_offset).expect("int cast"), - }, - ) else { - continue; - }; - let line_start_byte_offset = line_starts[new_line_index]; - if (line_start_byte_offset as usize) >= byte_offset { - continue; - } - - let column_position = - byte_offset.saturating_sub(line_start_byte_offset as usize); - - let found: Option = if let Some(c) = cur_.as_mut() { - c.move_to( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - } else { - parsed_mapping.find_mapping( - Ordinal::from_zero_based( - i32::try_from(new_line_index).expect("int cast"), - ), - Ordinal::from_zero_based( - i32::try_from(column_position).expect("int cast"), - ), - ) - }; - if let Some(point) = found { - if point.original.lines.zero_based() < 0 { - continue; - } - - let line: u32 = - u32::try_from(point.original.lines.zero_based()).expect("int cast"); - if line >= line_count { - continue; - } - min_line = min_line.min(line); - max_line = max_line.max(line); - } - } - - // no sourcemaps? ignore it - if min_line == u32::MAX && max_line == 0 { - continue; - } - - let did_fn_execute = function.execution_count > 0 || function.has_executed; - - // only mark the lines as executable if the function has not executed - // functions that have executed have non-executable lines in them and thats fine. - if !did_fn_execute { - let end = max_line.min(line_count); - for line in min_line..end { - executable_lines.set(line as usize); - lines_which_have_executed.unset(line as usize); - line_hits_slice[line as usize] = 0; - } + functions.push(function); + } + functions.sort_unstable_by_key(|function| function.range); + + let counts = ExecutionCounts::new(&blocks, &functions); + let mut lines = LineCounts::default(); + + if let Some(source_map) = source_map.as_deref() { + let mut previous_original = None; + source_map.for_each_mapping(|mapping| { + // The printer repeats a mapping at column 0 of a line that starts without one. + let original = ( + mapping.original.lines.zero_based(), + mapping.original.columns.zero_based(), + ); + if previous_original.replace(original) == Some(original) { + return; } - - if did_fn_execute { - functions_which_have_executed.set(functions.len()); + let (Ok(line), Some(offset)) = ( + usize::try_from(mapping.original.lines.zero_based()), + self.offset_of(mapping.generated), + ) else { + return; + }; + self.count_line(&counts, offset, line, &mut lines); + }); + } else { + for (line, &start) in self.line_starts.iter().enumerate() { + let end = self + .line_starts + .get(line + 1) + .copied() + .unwrap_or(source_len); + for offset in start..end { + self.count_line(&counts, offset, line, &mut lines); } - functions.push(ByteRange::of(min, max)); } - } else { - unreachable!(); } - functions_which_have_executed.resize(functions.len(), false)?; - stmts_which_have_executed.resize(stmts.len(), false)?; + // The program is a range of JSC's like any function, and so is a function the source + // does not have, such as the CommonJS wrapper. + functions.retain(|function| { + function.range != ByteRange::of(0, source_len) + && source_map.as_deref().is_none_or(|source_map| { + let (line, column) = self.line_and_column_of(function.range.start); + source_map.find_mapping(line, column).is_some() + }) + }); + + let (executable_lines, lines_which_have_executed) = lines.bitsets()?; + let (functions, functions_which_have_executed) = split_executed(&functions)?; + let (stmts, stmts_which_have_executed) = split_executed(&blocks)?; Ok(Report { source_url: Cow::Borrowed(source_url), functions, executable_lines, lines_which_have_executed, - line_hits, + line_hits: lines.hits, stmts, functions_which_have_executed, stmts_which_have_executed, }) } + /// `line` ran as often as the code at `offset` did, if that is code. A brace is not: the `{` + /// of a loop body shares a line with a header that runs once, and after a `return` JSC has + /// the `}` in a block of its own that never runs. + fn count_line( + &self, + counts: &ExecutionCounts, + offset: u32, + line: usize, + lines: &mut LineCounts, + ) { + if matches!( + self.source.char_at(offset as usize), + 0x09 | 0x0A | 0x0D | 0x20 | 0x7B | 0x7D + ) { + return; + } + if let Some(count) = counts.at(offset) { + lines.add(line, count); + } + } + + fn offset_of(&self, generated: bun_sourcemap::LineColumnOffset) -> Option { + let line = usize::try_from(generated.lines.zero_based()).ok()?; + let column = u32::try_from(generated.columns.zero_based()).ok()?; + let offset = self.line_starts.get(line)?.checked_add(column)?; + ((offset as usize) < self.source.length()).then_some(offset) + } + + fn line_and_column_of(&self, offset: u32) -> (Ordinal, Ordinal) { + let line = self.line_starts.partition_point(|&start| start <= offset) - 1; + ( + Ordinal::from_zero_based(i32::try_from(line).expect("int cast")), + Ordinal::from_zero_based( + i32::try_from(offset - self.line_starts[line]).expect("int cast"), + ), + ) + } + pub(crate) fn compute( - source_contents: &[u8], + source: bun_core::String, source_hash: u64, source_id: i32, source_url: Utf8Bytes<'static>, ) -> ByteRangeMapping { + let line_starts = if source.is_utf16() { + line_starts(source.utf16().iter().copied()) + } else { + line_starts(source.latin1().iter().map(|&unit| unit as u16)) + }; ByteRangeMapping { - line_offset_table: LineOffsetTable::generate(source_contents, 0) - .unwrap_or_else(|_| bun_alloc::out_of_memory()), + source, + line_starts, source_ids: vec![source_id], source_hash, source_url, @@ -1047,6 +831,129 @@ impl ByteRangeMapping { } } +/// Lines end where `bun_sourcemap::Chunk`'s builder ends a generated line. +fn line_starts(units: impl Iterator) -> Vec { + let mut starts = vec![0u32]; + let mut units = units.enumerate().peekable(); + while let Some((i, unit)) = units.next() { + match unit { + 0x0D if units.peek().is_some_and(|&(_, next)| next == 0x0A) => {} + 0x0A | 0x0D | 0x2028 | 0x2029 => starts.push(u32::try_from(i + 1).expect("int cast")), + _ => {} + } + } + starts +} + +#[derive(Clone, Copy)] +struct CountedRange { + range: ByteRange, + count: u32, +} + +impl CountedRange { + /// JSC's ranges include their end, and the text between two adjacent functions is a range + /// that ends before it starts. + fn from_jsc(range: &BasicBlockRange) -> Option { + let start = u32::try_from(range.start_offset).ok()?; + let end = u32::try_from(range.end_offset).ok()?.checked_add(1)?; + (start < end).then(|| CountedRange { + range: ByteRange { start, end }, + count: u32::try_from(range.execution_count).unwrap_or(u32::MAX), + }) + } +} + +fn split_executed( + ranges: &[CountedRange], +) -> Result<(Vec, Bitset), bun_alloc::AllocError> { + let mut executed = Bitset::init_empty(ranges.len())?; + for (i, range) in ranges.iter().enumerate() { + if range.count > 0 { + executed.set(i); + } + } + Ok((ranges.iter().map(|range| range.range).collect(), executed)) +} + +/// How often each code unit of a text ran: as often as the smallest of JSC's ranges that holds +/// it, which is how `ControlFlowProfiler::findBasicBlockAtTextOffset` reads them. +struct ExecutionCounts { + /// `(offset, count)`: the count from `offset` up to the next step's. `None`: in no range. + steps: Vec<(u32, Option)>, +} + +impl ExecutionCounts { + /// A block and a function of one extent are the same code, and the block has the count. + fn new(blocks: &[CountedRange], functions: &[CountedRange]) -> ExecutionCounts { + let ranges: Vec = blocks.iter().chain(functions).copied().collect(); + let mut by_start: Vec = (0..ranges.len()).collect(); + let mut by_end = by_start.clone(); + by_start.sort_unstable_by_key(|&i| ranges[i].range.start); + by_end.sort_unstable_by_key(|&i| ranges[i].range.end); + + let key = |i: usize| (ranges[i].range.end - ranges[i].range.start, i); + let mut open = std::collections::BTreeSet::new(); + let mut steps: Vec<(u32, Option)> = Vec::new(); + let (mut starting, mut ending) = (by_start.iter().peekable(), by_end.iter().peekable()); + while let Some(&&next_end) = ending.peek() { + let mut offset = ranges[next_end].range.end; + if let Some(&&next_start) = starting.peek() { + offset = offset.min(ranges[next_start].range.start); + } + while let Some(&i) = ending.next_if(|&&i| ranges[i].range.end == offset) { + open.remove(&key(i)); + } + while let Some(&i) = starting.next_if(|&&i| ranges[i].range.start == offset) { + open.insert(key(i)); + } + let count = open.first().map(|&(_, i)| ranges[i].count); + if steps.last().is_none_or(|&(_, last)| last != count) { + steps.push((offset, count)); + } + } + ExecutionCounts { steps } + } + + fn at(&self, offset: u32) -> Option { + let after = self.steps.partition_point(|&(start, _)| start <= offset); + self.steps.get(after.checked_sub(1)?)?.1 + } +} + +/// A line is executable once anything on it is, and ran as often as what ran most on it. +#[derive(Default)] +struct LineCounts { + executable: Vec, + hits: LinesHits, +} + +impl LineCounts { + fn add(&mut self, line: usize, count: u32) { + if line >= self.hits.len() { + self.hits.resize(line + 1, 0); + self.executable.resize(line + 1, false); + } + self.executable[line] = true; + self.hits[line] = self.hits[line].max(count); + } + + /// Executable lines, and those of them that ran. + fn bitsets(&self) -> Result<(Bitset, Bitset), bun_alloc::AllocError> { + let mut executable = Bitset::init_empty(self.hits.len())?; + let mut executed = Bitset::init_empty(self.hits.len())?; + for (line, &hits) in self.hits.iter().enumerate() { + if self.executable[line] { + executable.set(line); + } + if hits > 0 { + executed.set(line); + } + } + Ok((executable, executed)) + } +} + #[unsafe(no_mangle)] extern "C" fn ByteRangeMapping__generate( str_: &bun_core::String, @@ -1071,8 +978,12 @@ extern "C" fn ByteRangeMapping__generate( return; } - let new_value = - ByteRangeMapping::compute(source_contents.slice(), source_hash, source_id, source_url); + let new_value = ByteRangeMapping::compute( + source_contents_str.clone(), + source_hash, + source_id, + source_url, + ); map.insert(hash, new_value); } @@ -1153,9 +1064,8 @@ extern "C" fn ByteRangeMapping__findExecutedLines( // writers and the test runner share one definition. pub use bun_options_types::code_coverage_options::Fraction; -/// A basic block or function body as `[start, end)` byte offsets into the -/// generated source. Offsets are what JSC reports, so they identify the same -/// block across processes that loaded the same file. +/// A basic block or function as `[start, end)` code unit offsets into the text JSC compiled, so +/// it identifies the same block across processes that loaded the same file. #[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] pub struct ByteRange { pub start: u32, @@ -1163,10 +1073,7 @@ pub struct ByteRange { } impl ByteRange { - fn of(min: usize, max: usize) -> ByteRange { - ByteRange { - start: u32::try_from(min).expect("int cast"), - end: u32::try_from(max).expect("int cast"), - } + fn of(start: u32, end: u32) -> ByteRange { + ByteRange { start, end } } } diff --git a/src/sourcemap_jsc/JSSourceMap.rs b/src/sourcemap_jsc/JSSourceMap.rs index 207c94fbcd93..dc44ed2d983c 100644 --- a/src/sourcemap_jsc/JSSourceMap.rs +++ b/src/sourcemap_jsc/JSSourceMap.rs @@ -113,12 +113,16 @@ impl JSSourceMap { // Parse the payload to create a proper sourcemap // Extract mappings string from payload - let Some(mappings_value) = payload_arg.get_stringish(global, b"mappings")? else { + let Some(mappings_value) = payload_arg + .get(global, b"mappings")? + .filter(|mappings| !mappings.is_null() && *mappings != JSValue::FALSE) + else { return Err( global.throw_invalid_arguments(format_args!("payload 'mappings' must be a string")) ); }; + let mappings_value = mappings_value.to_bun_string(global)?; let mappings_str = mappings_value.to_utf8(); let mut names: Vec = Vec::new(); diff --git a/test/cli/test/__snapshots__/coverage.test.ts.snap b/test/cli/test/__snapshots__/coverage.test.ts.snap index 23af59233aa9..635154608364 100644 --- a/test/cli/test/__snapshots__/coverage.test.ts.snap +++ b/test/cli/test/__snapshots__/coverage.test.ts.snap @@ -3,26 +3,24 @@ exports[`lcov coverage reporter: lcov-coverage-reporter-output 1`] = ` "TN: SF:demo1.ts -FNF:1 +FNF:0 FNH:0 -DA:2,19 -DA:3,16 -DA:4,1 -LF:3 -LH:3 +DA:2,1 +DA:3,1 +LF:2 +LH:2 end_of_record TN: SF:demo2.ts FNF:2 FNH:1 -DA:2,28 -DA:4,11 -DA:6,9 +DA:2,1 +DA:4,1 +DA:6,1 DA:9,0 -DA:10,0 -DA:11,1 -DA:14,9 -LF:7 -LH:5 +DA:11,0 +DA:14,1 +LF:6 +LH:4 end_of_record" `; diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index f1caa6bfa312..6a0e690fa26f 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -245,8 +245,8 @@ test("should call only some functions", () => { ---------------|---------|---------|------------------- File | % Funcs | % Lines | Uncovered Line #s ---------------|---------|---------|------------------- -All files | 75.00 | 83.33 | - include-me.ts | 50.00 | 66.67 | 6 +All files | 75.00 | 75.00 | + include-me.ts | 50.00 | 50.00 | 6-7 test.test.ts | 100.00 | 100.00 | ---------------|---------|---------|------------------- @@ -438,8 +438,8 @@ test("should call both functions", () => { SF:include-me.ts FNF:1 FNH:1 -DA:2,11 -DA:3,17 +DA:2,1 +DA:3,1 LF:2 LH:2 end_of_record @@ -447,13 +447,13 @@ TN: SF:test.test.ts FNF:1 FNH:1 -DA:2,40 -DA:3,41 -DA:4,39 -DA:6,42 -DA:7,39 -DA:8,36 -DA:9,2 +DA:2,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 LF:7 LH:7 end_of_record" diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts new file mode 100644 index 000000000000..f784fca35f38 --- /dev/null +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -0,0 +1,578 @@ +// Programs whose positions are known, for source-positions.test.ts. +// +// Before each line is how often `bun test --coverage` says it ran, or `-` if there is nothing on it to run. +// `at()` reports where the runtime says it was called from and counts its calls. `caught(e, "text")` reports where the +// runtime says `e` was thrown, which has to be the first "text" of the file. Neither position is written down anywhere: +// the test looks for the call in the text. A call in tail position has no caller to report, so `at()` is never returned. +// `ran()` only counts its calls, for the initializer of a field: JSC keeps the function it is in out of stack traces. + +export type Case = { + /** Functions that ran / functions in the file. */ + functions: string; + lines: string; +}; + +export const cases: Record = { + // A function that never ran has the lines its code is on, up to the last. + "uncalled.ts": { + functions: "1/2", + lines: ` + 1|export function called() { + 1| return 1; + -|} + -| + -|// a comment + 0|export function uncalled(a: number) { + -| // a comment + -| + 0| const b = a + 1; + -| /* another + -| comment */ + 0| return b; + -|} + 1|called(); +`, + }, + // How often, not how many characters. + "counts.ts": { + functions: "1/1", + lines: ` + 1|let total = 0; + 4|function add(n: number) { + 4| total += n; + -|} + 1|for (let i = 0; i < 3; i++) { + 3| add(i); + -|} + 1|add(1); + 1|export { total }; +`, + }, + "branches.ts": { + functions: "3/3", + lines: ` + 2|export function f(x: boolean) { + 2| if (x) { + 2| return 1; + 0| } else { + 0| return 2; + -| } + -|} + 1|export function g(x: number) { + 1| switch (x) { + 0| case 1: + 0| return "one"; + 1| case 2: + 1| return "two"; + -| default: + 0| return "many"; + -| } + -|} + 1|export function h() { + 1| try { + 1| throw new Error("x"); + 1| } catch (e) { + 1| return 1; + -| } finally { + 1| f(true); + -| } + -|} + 1|f(true); + 1|g(2); + 1|h(); +`, + }, + "else.ts": { + functions: "2/2", + lines: ` + 6|function a() {} + 2|function f(x: boolean) { + 2| if (x) { + 0| a(); + 2| } else { + 2| a(); + -| } + 2| if (x) { + 0| a(); + 2| } else if (!x) { + 2| a(); + -| } + 2| if (x) a(); + 2| else a(); + -|} + 1|f(false); + 1|f(false); +`, + }, + // Nothing is left of a type to run. + "types.ts": { + functions: "0/1", + lines: ` + -|import type { A } from "./nothing"; + -|interface B { + -| a: number; + -|} + -|type C = { + -| b: string; + -|}; + -|declare const d: number; + 1|export const e: B = { + 1| a: 1, + -|}; + 0|export function f(x: C): A | undefined { + 0| return undefined; + -|} +`, + }, + "multiline.ts": { + functions: "2/3", + lines: ` + 1|function id(x: T): T { + 1| return x; + -|} + 1|export const a = id( + 1| 1 + + -| 2, + 1|); + 1|export const b = \`one + 1|two \${a} + -|three\`; + 1|export const c = [ + 1| 1, + 1| 2, + 2|].map(x => + 2| x * 2, + 1|); + 1|export const never = () => [ + 0| 1, + 0| 2, + 0|]; +`, + }, + // Where a constant is inlined, its value has the position of its declaration. + "inlined.ts": { + functions: "1/2", + lines: ` + 1|const A = 1; + 1|const B = 2; + 1|const C = 3; + -| + 0|export function unused(x: number): number { + 0| return x + B; + -|} + 1|export function used(x: number): number { + 1| return x + C; + -|} + 1|used(A); +`, + }, + // JSC's offsets count code units of a text that is Latin-1 here and UTF-16 below, never UTF-8 bytes. + "latin1.ts": { + functions: "1/2", + lines: ` + 1|export function café() { + 1| return "éééééééé"; + -|} + 0|export function uncalled() { + 0| return "é"; + -|} + 1|café(); +`, + }, + "utf16.ts": { + functions: "1/2", + lines: ` + 1|export function a() { + 1| return "漢字🎉漢字🎉"; + -|} + 0|export function uncalled() { + 0| return "🎉"; + -|} + 1|a(); +`, + }, + // The function Bun wraps CommonJS in is not one of the file's. + "wrapper.cjs": { + functions: "1/1", + lines: ` + 1|function a() { + 1| return 1; + -|} + 1|module.exports = a(); +`, + }, + // An implicit constructor, the initializers of the fields and a static block are not functions of the file. + "classes.ts": { + functions: "1/2", + lines: ` + 4|function v() { + 4| return 1; + -|} + 1|export class Implicit {} + 1|export class Fields { + 2| a = v(); + 1| static b = v(); + 1| c: number; + -| static { + 1| v(); + -| } + -|} + 1|export class NeverConstructed { + 0| a = v(); + 0| m() { + 0| return 1; + -| } + -|} + 1|new Fields(); + 1|new Fields(); +`, + }, + "loops.ts": { + functions: "1/1", + lines: ` + 3|function f() {} + 1|for (let i = 0; i < 3; i++) { + 3| if (i > 5) + 0| break; + 3| if (i === 1) + 1| continue; + 2| f(); + -|} + 1|let n = 0; + 1|do { + 3| n++; + 3|} while (n < 3); + 1|out: { + 1| if (n) + 1| break out; + 0| f(); + -|} + 1|f(); +`, + }, + "generators.ts": { + functions: "4/4", + lines: ` + 1|function* once() { + 1| yield 1; + -|} + 1|function* never() { + 0| yield 1; + -|} + 1|async function stuck() { + 1| await new Promise(() => {}); + -|} + 1|once().next(); + 1|never(); + 1|stuck(); +`, + }, + "logical.ts": { + functions: "1/2", + lines: ` + 3|const t = () => true; + 1|const never = () => 1; + 1|export const a = + 1| t() || + 0| never(); + 1|export const b = + 1| !t() && + 0| never(); + 1|export const c = + 1| 1 ?? + 0| never(); + 1|export const d = + 1| t() + 1| ? 1 + 0| : never(); +`, + }, + "calls.ts": { + functions: "2/2", + lines: ` + 1|function outer() { + 1| at(); + 1| const list = [at(), at()]; + 1| return inner(at(), list); + -|} + 1|function inner(...args: unknown[]) { + 1| at(); + -|} + 1|outer(); +`, + }, + // What is erased is before the call, on its line or above. + "erased.ts": { + functions: "2/2", + lines: ` + -|interface Erased { + -| a: number; + -|} + 1|const typed = (value: T): T => (at(), value); + 1|typed(1) as number; at(); + 1|const cast = (at() as unknown) satisfies unknown; at(); + 1|function generic(this: void, a?: A, b?: B): void { at(); } + 1|generic<{}>(); at(); + -|declare const nothing: number; + 1|at(); +`, + }, + // An enum and a namespace are compiled to a function each. + "lowered.ts": { + functions: "3/3", + lines: ` + 1|enum Color { + 1| Red = 1, + 1| Green = (at(), 2), + -|} + 1|namespace Space { + 1| export const value = at(); + -|} + 1|class Point { + 1| constructor(public x = at(), private y = at()) { + 1| at(); + -| } + -|} + 1|new Point(); at(); + 1|void [Color.Green, Space.value]; +`, + }, + "members.ts": { + functions: "6/6", + lines: ` + 1|class Members { + 2| field = ran(); + 1| static fixed = ran(); + 2| #hidden = ran(); + 1| get value() { at(); return 1; } + 1| set value(v) { at(); } + 1| static { at(); } + 1| method() { at(); return this.#hidden; } + 1| async later() { await null; at(); } + 1| *steps() { at(); yield 1; at(); } + 1| ["computed" + at()]() { at(); } + -|} + 1|const members = new Members(); + 1|new Members(); + 1|members.value; members.value = 1; members.method(); members.later(); [...members.steps()]; + 1|(members as any).computedundefined(); +`, + }, + // A token that is on several lines is before the call. + "spans.ts": { + functions: "0/0", + lines: ` + 1|const text = \`one + 1|two \${at()} three + 1|four\`; at(); + -|/* a comment + 1| on two lines */ at(); + 1|const pattern = /[/]\\//g; at(); + 1|const continued = "a\\ + 1|b"; at(); + 1|void [text, pattern, continued]; +`, + }, + "operators.ts": { + functions: "2/2", + lines: ` + 1|at?.(); + 1|new (at(), Object)(); + 1|const tag = (strings: TemplateStringsArray, ...values: unknown[]) => [at()]; + 1|tag\`a\${at()}b\`; + 1|const { a = at(), ...rest } = {} as any; + 1|const [b = at()] = []; + 1|const spread = [...[at()]]; + 1|const chosen = at() ? at() : at(); + 1|void (at() ?? at()); + 1|const object = { key: at(), [\`k\${at()}\`]: at(), method() { at(); } }; + 1|object.method(); + 1|label: for (const x of [at()]) { at(); break label; } + 1|switch (at()) { case at(): at(); default: at(); } + 1|try { at(); throw at(); } catch { at(); } finally { at(); } + 1|void [a, rest, b, spread, chosen]; +`, + }, + "elements.tsx": { + functions: "2/2", + lines: ` + -|/** @jsxRuntime classic */ + -|/** @jsx h */ + -|/** @jsxFrag Fragment */ + 1|const Fragment = "fragment"; + 3|function h(tag: any, props: any, ...children: any[]) { + 3| return typeof tag === "function" ? tag({ ...props, children }) : tag; + -|} + 1|function Component(props: any) { + 1| at(); + -|} + 1|const tree = ( + 1|
+ 1| text {at()} + 1| + 1| {at()} + 1| + 1| <> + 1| {at()} + 1| + 1|
+ -|); + 1|try { + -| ( + 1| + -| text + 1| + -| ); + 1|} catch (e) { + 1| caught(e, "Missing>"); + -|} + 1|void tree; +`, + }, + // Errors that the engine throws. + "thrown.ts": { + functions: "0/0", + lines: ` + 1|const empty: any = {}; + 1|try { notDefined; } catch (e) { caught(e, "notDefined"); } + 1|try { empty.first.second; } catch (e) { caught(e, "first.second"); } + 1|try { empty.notFunction(); } catch (e) { caught(e, "notFunction"); } + 1|try { new empty.notConstructor(); } catch (e) { caught(e, "new empty"); } + 1|try { tooEarly; let tooEarly = 1; } catch (e) { caught(e, "tooEarly"); } + 1|try { throw new RangeError("thrown"); } catch (e) { caught(e, "RangeError"); } + 1|try { null!.ofNull; } catch (e) { caught(e, "null!"); } +`, + }, + "common.cjs": { + functions: "1/1", + lines: ` + 1|exports.value = at(); + 1|function common() { + 1| at(); + -|} + 1|common(); +`, + }, + "module.mjs": { + functions: "1/2", + lines: ` + 1|export const value = at(); + 0|export default function never() { + 0| at(); + -|} + 1|export function called() { + 1| at(); + -|} + 1|called(); +`, + }, + // What a bundle keeps of a default export is a declaration without `export default`. + "function.ts": { + functions: "1/1", + lines: ` + 1|export default function imported() { + 1| at(); + -|} +`, + }, + "class.ts": { + functions: "1/1", + lines: ` + 1|export default class Imported { + 1| method() { + 1| at(); + -| } + -|} +`, + }, + "importer.ts": { + functions: "0/0", + lines: ` + 1|import Imported from "./class.ts"; + 1|import imported from "./function.ts"; + 1|imported(); + 1|new Imported().method(); +`, + }, + // The error is from the file that is loaded. Below it is who loads it. + "requires.cjs": { + functions: "0/0", + lines: ` + 1|try { + 1| const list = [1, require("../throws.cjs")]; + 1|} catch (e) { + 1| caught(e, "require("); + -|} +`, + }, + // The path of a chunk is put in once its length is known, which moves what follows it on the line. + "lazy.mjs": { + functions: "0/0", + lines: ` + 1|export const lazy = at(); +`, + }, + "chunks.ts": { + functions: "0/0", + lines: ` + 1|const text = "é汉🎉"; at(); import("./lazy.mjs"); at(); + 1|export { text }; +`, + }, + // Columns far from the start of the line. + "wide.js": { + functions: "0/0", + lines: ` + 1|${Array(8) + .fill(`${JSON.stringify(Buffer.alloc(500, "x").toString())}; at();`) + .join(" ")} +`, + }, +}; + +// Programs that do not build. `«` is before what the message is about. +export const broken: Record = { + "syntax.ts": ` +const a = 1; +const b = a +«* 2; +`, + "redeclared.ts": ` +let a = 1; + let «a = 2; +`, + "unresolved.ts": ` +const a = 1; +import «"./missing"; +`, + "required.cjs": ` +const a = 1; +const b = 2, c = require(«"./missing"); +`, + "element.tsx": ` +const a = 1; +const b =
text; +`, + "imported.css": ` +a { + color: red; +} +@import« "./missing.css"; +`, + "data.jsonc": ` +{ + "a": 1, + "b": «] +} +`, +}; + +// A test file. `bun test` writes what is between `«` and `»`. +export const snapshots = ` +import { expect, test } from "bun:test"; +type Erased = number; +test("snapshots", () => { + expect(1 as Erased).toMatchInlineSnapshot(«\`1\`»); expect("two").toMatchInlineSnapshot(«\`"two"\`»); + expect(3) + .toMatchInlineSnapshot(«\`3\`»); +}); +`; diff --git a/test/js/bun/sourcemap/source-positions.test.ts b/test/js/bun/sourcemap/source-positions.test.ts new file mode 100644 index 000000000000..61413f52e50c --- /dev/null +++ b/test/js/bun/sourcemap/source-positions.test.ts @@ -0,0 +1,487 @@ +// Every way Bun reports a position in a source file, checked on one set of programs (source-positions-corpus.ts). +// +// The programs are written out once per variant, which moves their text without changing what it does. Then: +// - `bun test --coverage` runs them: lines, functions, both reporters, the lines in the JUnit report, inline snapshots +// - `bun` runs them, then runs each bundle `Bun.build` made of them: stack traces, call sites, error previews +// - the source maps of the bundles are read directly: every word they map has to be the same word in the source +// - a compiled executable runs them +// - `Bun.build` says where the programs that do not build are wrong +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { SourceMap } from "node:module"; +import { join } from "node:path"; +import { broken, cases, snapshots } from "./source-positions-corpus"; + +type Variant = { + /** Goes before the code of each line that has some. */ + before?: string; + /** Lines of code that go before the program. */ + above?: number; + eol?: string; + bom?: boolean; + finalNewline?: boolean; +}; +const variants: Record = { + plain: {}, + crlf: { eol: "\r\n" }, + bom: { bom: true, finalNewline: false }, + latin1: { before: "/*é*/ " }, + utf16: { before: "/*汉🎉*/ " }, + // More mappings than one window of the runtime's source maps has. + below: { above: 100 }, +}; + +type Probe = { id: number; file: string; line: number; column: number; text: string }; +type Program = { file: string; source: string; functions: string; counts: string[]; texts: string[] }; +const probes: Probe[] = []; +const programs: Program[] = []; + +for (const [variantName, { before = "", above = 0, eol = "\n", bom = false, finalNewline = true }] of Object.entries( + variants, +)) { + for (const [name, { functions, lines }] of Object.entries(cases)) { + const file = `${variantName}/${name}`; + const counts: string[] = Array(above).fill(" 1"); + const texts: string[] = Array(above).fill("globalThis.above = 0;"); + for (const line of lines.slice(1, -1).split("\n")) { + const count = line.slice(0, 2); + const text = line.slice(3); + counts.push(count); + // A line that starts inside a comment cannot have one put before it. + texts.push(count === " -" || /^[^/]*\*\//.test(text) ? text : before + text); + } + + const wanted: { id: number; text: string }[] = []; + const source = (texts.join(eol) + (finalNewline ? eol : "")).replace( + /\b(at|ran)(\?\.)?\(\)|\bcaught\(e, "([^"]+)"\)/g, + (_, name, optional = "", thrownAt) => { + const id = probes.length + wanted.length; + wanted.push({ id, text: thrownAt ?? `${name}${optional}(${id})` }); + return thrownAt ? `caught(${id}, e)` : `${name}${optional}(${id})`; + }, + ); + for (const { id, text } of wanted) { + const index = source.indexOf(text); + const lineStart = source.lastIndexOf("\n", index) + 1; + probes.push({ id, file, text, line: source.slice(0, index).split("\n").length, column: index - lineStart + 1 }); + } + programs.push({ file, source: (bom ? "\uFEFF" : "") + source, functions, counts, texts: source.split(eol) }); + } +} + +/** A text that is not one of the programs, in a variant. A comment can go before any of its lines. */ +function moved(variantName: string, text: string) { + const { before = "", above = 0, eol = "\n", bom = false, finalNewline = true } = variants[variantName]; + const lines = text.slice(1, -1).split("\n"); + return ( + (bom ? "\uFEFF" : "") + + [...Array(above).fill("/* above */"), ...lines.map(line => before + line)].join(eol) + + (finalNewline ? eol : "") + ); +} +const inEachVariant = (name: string, value: (variantName: string) => T) => + Object.keys(variants).map(variantName => [`${variantName}/${name}`, value(variantName)] as const); + +// Runs inside the programs. What it writes down is only what the runtime said. +const probeRuntime = ` +const { getCallSites } = require("node:util"); +const lanes = {}; +const records = (lanes[process.env.SOURCE_POSITIONS_LANE] = { failed: {} }); +function record(id, error) { + const previous = records[id]; + if (previous) return void previous.count++; + records[id] = { count: 1, stack: error.stack, preview: Bun.inspect(error, { colors: false }) }; + return records[id]; +} +globalThis.at = function at(id) { + const error = new Error("probe"); + Error.captureStackTrace(error, at); + const first = record(id, error); + if (!first) return; + const prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_, [, site]) => site.getFileName() + ":" + site.getLineNumber() + ":" + site.getColumnNumber(); + first.callSite = new Error().stack; + Error.prepareStackTrace = prepareStackTrace; + const site = getCallSites(2)[1]; + first.getCallSites = site.scriptName + ":" + site.lineNumber + ":" + (site.columnNumber ?? site.column); +}; +globalThis.ran = id => void (records[id] ??= { count: 0 }).count++; +globalThis.caught = (id, error) => void record(id, error); +globalThis.load = specifiers => + specifiers.reduce( + (previous, specifier) => previous.then(() => import(specifier)).catch(e => void (records.failed[specifier] = String(e))), + Promise.resolve(), + ); +globalThis.save = () => require("node:fs").writeFileSync(process.env.SOURCE_POSITIONS_OUT, JSON.stringify(lanes)); +process.on("exit", save); +`; + +const builds: Record> = { + linked: { sourcemap: "linked" }, + inline: { sourcemap: "inline", format: "cjs" }, + minified: { sourcemap: "linked", minify: true }, + split: { + sourcemap: "linked", + splitting: true, + minify: { whitespace: true }, + naming: { chunk: "[dir]/[name]-[hash].[ext]" }, + }, + // Text that is not ASCII is only left as it is for a browser. + browser: { + target: "browser", + sourcemap: "linked", + splitting: true, + minify: { whitespace: true }, + naming: { chunk: "[dir]/[name]-[hash].[ext]" }, + }, +}; +// Bun reads the source map of what was built for it. +const bundles = Object.keys(builds).filter(name => name !== "browser"); + +type Record_ = { count: number; stack?: string; preview: string; callSite?: string; getCallSites?: string }; +type Lane = { failed: Record } & Record; + +const lanes: Record = {}; +const outputs: Record = {}; + +// The tests of the JUnit report. What is before `test` moves it the way the variants move the programs. +const testFile = [ + `import { afterAll, describe, test } from "bun:test";`, + `type Erased = {`, + ` a: number;`, + `};`, + `await load(${JSON.stringify(programs.map(({ file }) => `./${file}`))});`, + `afterAll(save);`, + `/*汉🎉*/ test("first", () => {});`, + `describe("group", () => {`, + ` const text = \`a`, + ` b\`; test("second", () => {});`, + `});`, + ``, +].join("\n"); + +const dir = tempDir("source-positions", { + ...Object.fromEntries(programs.map(({ file, source }) => [file, source])), + ...Object.fromEntries( + Object.entries(broken).flatMap(([name, text]) => inEachVariant(name, v => moved(v, text).replace("«", ""))), + ), + ...Object.fromEntries(inEachVariant("snapshots.test.ts", v => moved(v, snapshots).replace(/«.*?»/g, ""))), + "probe.cjs": probeRuntime, + "throws.cjs": `throw new Error("thrown while it loads");\n`, + "all.test.ts": testFile, + "entry.ts": programs.map(({ file }) => `import "./${file}";\n`).join(""), + "compiled.ts": `import "./probe.cjs";\nawait import("./entry.ts");\n`, + "run.ts": `await load(${JSON.stringify(programs.map(({ file }) => `./${file}`))});\n`, +}); +afterAll(() => dir[Symbol.dispose]()); +const root = String(dir); + +async function run(lane: string, cmd: string[]) { + const out = lane + ".json"; + await using proc = Bun.spawn({ + cmd, + cwd: root, + env: { ...bunEnv, SOURCE_POSITIONS_OUT: join(root, out), SOURCE_POSITIONS_LANE: lane }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`${cmd.join(" ")} exited with ${exitCode}\n${stdout}\n${stderr}`); + Object.assign(lanes, JSON.parse(readFileSync(join(root, out), "utf8"))); + return stderr; +} + +async function bundle(name: string) { + const { outputs: artifacts } = await Bun.build({ + entrypoints: [join(root, "entry.ts")], + outdir: join(root, "out", name), + target: "bun", + ...builds[name], + }); + outputs[name] = []; + for (const artifact of artifacts) { + if (artifact.kind === "sourcemap") continue; + const code = await artifact.text(); + const inline = code.match(/\/\/# sourceMappingURL=data:application\/json;base64,(\S+)/)?.[1]; + outputs[name].push({ + path: artifact.path, + code, + map: JSON.parse(inline ? Buffer.from(inline, "base64").toString() : readFileSync(artifact.path + ".map", "utf8")), + }); + } + if (bundles.includes(name)) await run(name, [bunExe(), "--preload=./probe.cjs", `./out/${name}/entry.js`]); +} + +// Everything runs once, here and not in a hook: a hook has the time limit of one test. +const compiled = join(root, isWindows ? "compiled.exe" : "compiled"); +const [table, { logs: diagnostics }] = await Promise.all([ + run("test", [ + bunExe(), + "test", + "--preload=./probe.cjs", + "--coverage", + "--coverage-reporter=lcov", + "--coverage-reporter=text", + "--reporter=junit", + "--reporter-outfile=junit.xml", + "--update-snapshots", + "./all.test.ts", + ...inEachVariant("snapshots.test.ts", () => {}).map(([file]) => `./${file}`), + ]), + Bun.build({ + entrypoints: Object.keys(broken).flatMap(name => inEachVariant(name, () => {}).map(([file]) => join(root, file))), + throw: false, + }), + run("run", [bunExe(), "--preload=./probe.cjs", "run.ts"]), + ...Object.keys(builds).map(bundle), + Bun.build({ entrypoints: [join(root, "compiled.ts")], compile: { outfile: compiled }, sourcemap: "inline" }).then( + () => run("compiled", [compiled]), + ), +]); +const lcov = readFileSync(join(root, "coverage", "lcov.info"), "utf8"); +const junit = readFileSync(join(root, "junit.xml"), "utf8"); + +/** `path:line:column` at the end of what the runtime said, with the path relative to the programs. */ +function position(said: string | undefined) { + const [, path, line, column] = said?.match(/([^\s()]+):(\d+):(\d+)\)?$/) ?? []; + if (!path) return String(said); + return `${path.replaceAll("\\", "/").split("/").slice(-2).join("/")}:${line}:${column}`; +} + +/** The text that the `^` of an error preview is under. A line of more than 1024 bytes is cut, and has no `^`. */ +function underCaret(preview: string, text: string) { + const lines = preview.split("\n"); + const caret = lines.findIndex(line => /^\s*\^$/.test(line)); + if (caret < 1) return lines.some(line => Buffer.byteLength(line) > 1024) ? text : preview; + return lines[caret - 1].slice(lines[caret].indexOf("^")).slice(0, text.length); +} + +describe.each(["test", "run", ...bundles, "compiled"])("%s", laneName => { + test("every program loads", () => { + expect(lanes[laneName].failed).toEqual({}); + }); + + test.each(Object.keys(variants))("%s: where a call or an error is", variantName => { + const wrong: Record = {}; + let checked = 0; + for (const { id, file, line, column, text } of probes) { + if (!file.startsWith(variantName + "/")) continue; + const said = lanes[laneName][id]; + if (!said?.stack) continue; + checked++; + const expected = `${file}:${line}:${column}`; + const frames = said.stack.split("\n").slice(1).map(position); + const frame = frames.findIndex(frame => frame.startsWith(file + ":")); + const got = { + stack: frames[frame], + ...(said.callSite && { callSite: position(said.callSite), getCallSites: position(said.getCallSites) }), + // The preview is of the first frame. + preview: frame === 0 ? underCaret(said.preview, text) : text, + }; + const want = { + stack: expected, + ...(said.callSite && { callSite: expected, getCallSites: expected }), + preview: text, + }; + if (!Bun.deepEquals(got, want)) wrong[`${expected} ${text}`] = got; + } + expect(wrong).toEqual({}); + expect(checked).toBeGreaterThan(0); + }); +}); + +describe("bun test --coverage", () => { + const reported = new Map }>(); + for (const record of lcov.split("end_of_record\n")) { + const file = record.match(/^SF:(.*)$/m)?.[1].replaceAll("\\", "/"); + if (!file) continue; + reported.set(file, { + functions: `${record.match(/^FNH:(\d+)$/m)![1]}/${record.match(/^FNF:(\d+)$/m)![1]}`, + hits: new Map(Array.from(record.matchAll(/^DA:(\d+),(\d+)$/gm), ([, line, count]) => [Number(line), count])), + }); + } + + const gutter = (counts: string[], texts: string[]) => counts.map((count, i) => `${count}|${texts[i]}`).join("\n"); + + test.each(programs.map(({ file }) => file))("%s", file => { + const { functions, counts, texts } = programs.find(program => program.file === file)!; + const { functions: reportedFunctions, hits } = reported.get(file)!; + expect({ + functions: reportedFunctions, + lines: gutter( + counts.map((_, i) => (hits.get(i + 1) ?? "-").padStart(2)), + texts, + ), + }).toEqual({ functions, lines: gutter(counts, texts) }); + }); + + // The counts in the corpus are not only what Bun printed one day: where a line is one call of `at`, the program counted. + test("a line ran as often as the call that is all of it", () => { + const wrong: string[] = []; + for (const { id, file, line, text } of probes) { + const { counts, texts } = programs.find(program => program.file === file)!; + if (!text.startsWith("ran(") && !/^\s*(\/\*.*\*\/ )?at\(\d+\);$/.test(texts[line - 1])) continue; + const counted = lanes.test[id]?.count ?? 0; + if (Number(counts[line - 1]) !== counted) wrong.push(`${file}:${line} says ${counts[line - 1]}, ran ${counted}`); + } + expect(wrong).toEqual([]); + }); + + test("the table has the lines that did not run", () => { + const wrong: Record = {}; + for (const { file, counts, functions } of programs) { + const row = table.split("\n").find(line => + line + .replaceAll("\\", "/") + .trimStart() + .startsWith(file + " "), + ); + const ranges: string[] = []; + counts.forEach((count, i) => { + if (count !== " 0") return; + if (counts[i - 1] === " 0") ranges[ranges.length - 1] = `${ranges[ranges.length - 1].split("-")[0]}-${i + 1}`; + else ranges.push(String(i + 1)); + }); + const executable = counts.filter(count => count !== " -"); + const [hit, found] = functions.split("/").map(Number); + const want = [ + (found ? (hit / found) * 100 : 100).toFixed(2), + ((executable.filter(count => count !== " 0").length / executable.length) * 100).toFixed(2), + ranges.join(","), + ]; + const got = row + ?.split("|") + .slice(1) + .map(cell => cell.trim()); + if (!Bun.deepEquals(got, want)) wrong[file] = { got, want }; + } + expect(wrong).toEqual({}); + }); + + test("the JUnit report has the line of each test", () => { + const lineOf = (name: string, text: string) => + `${name} ${text.slice(0, text.indexOf(`test("${name}"`)).split("\n").length}`; + expect( + Array.from( + junit.matchAll(/]* line="(\d+)"/g), + ([, name, line]) => `${name} ${line}`, + ).sort(), + ).toEqual( + [ + lineOf("first", testFile), + lineOf("second", testFile), + ...inEachVariant("", v => lineOf("snapshots", moved(v, snapshots))).map(([, line]) => line), + ].sort(), + ); + }); + + test.each(Object.keys(variants))("%s: an inline snapshot is written where it is called", variantName => { + expect(readFileSync(join(root, variantName, "snapshots.test.ts"), "utf8")).toBe( + moved(variantName, snapshots).replace(/[«»]/g, ""), + ); + }); +}); + +test.each(Object.keys(variants))("%s: where a program that does not build is wrong", variantName => { + const want: Record = {}; + for (const [name, text] of Object.entries(broken)) { + const before = moved(variantName, text).replace("\uFEFF", "").split("«")[0].split(/\r?\n/); + want[`${variantName}/${name}`] = { line: before.length, column: before.at(-1)!.length + 1 }; + } + const got: Record = {}; + for (const { position } of diagnostics) { + const file = position!.file.replaceAll("\\", "/").split("/").slice(-2).join("/"); + if (file in want) got[file] ??= { line: position!.line, column: position!.column }; + } + expect(got).toEqual(want); +}); + +/** `[generatedLine, generatedColumn, source, originalLine, originalColumn]`, all from 0. */ +function decode(mappings: string) { + const digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const decoded: number[][] = []; + let source = 0; + let originalLine = 0; + let originalColumn = 0; + mappings.split(";").forEach((line, generatedLine) => { + let generatedColumn = 0; + for (const segment of line ? line.split(",") : []) { + const fields: number[] = []; + let value = 0; + let shift = 0; + for (const char of segment) { + const digit = digits.indexOf(char); + value += (digit & 31) << shift; + shift += 5; + if (digit & 32) continue; + fields.push(value & 1 ? -(value >> 1) : value >> 1); + value = shift = 0; + } + generatedColumn += fields[0]; + if (fields.length < 4) continue; + decoded.push([ + generatedLine, + generatedColumn, + (source += fields[1]), + (originalLine += fields[2]), + (originalColumn += fields[3]), + ]); + } + }); + return decoded; +} + +describe.each(Object.keys(builds))("the source map of %s", name => { + const wordAt = (line: string | undefined, column: number) => + /[\w$]/.test(line?.[column - 1] ?? "") ? undefined : line?.slice(column).match(/^[A-Za-z_$][\w$]{3,}/)?.[0]; + + test("maps a word to the same word", () => { + const wrong: string[] = []; + let checked = 0; + for (const { code, map } of outputs[name]) { + const generated = code.split("\n"); + const originals = (map.sourcesContent as string[]).map(content => content.split(/\r?\n/)); + let previous = ""; + for (const [generatedLine, generatedColumn, source, originalLine, originalColumn] of decode(map.mappings)) { + // Each line of a token that is on several starts with the mapping before it, again. + const repeated = previous === (previous = `${source}:${originalLine}:${originalColumn}`); + const word = wordAt(generated[generatedLine], generatedColumn); + // A word that Bun made up, or renamed, is not in the source. The output of each member of an enum or of a + // namespace starts with the name of the enum or of the namespace. `else` has the position of what follows it. + if (repeated || !word || word === "else" || !map.sourcesContent[source].includes(word)) continue; + if (map.sources[source].endsWith("lowered.ts")) continue; + checked++; + const original = originals[source][originalLine]?.slice(originalColumn, originalColumn + word.length); + if (original !== word) { + wrong.push( + `${map.sources[source]}:${originalLine + 1}:${originalColumn + 1} is ${JSON.stringify(original)}, not ${word}`, + ); + } + } + } + expect(wrong).toEqual([]); + expect(checked).toBeGreaterThan(0); + }); + + test("node:module's SourceMap finds each call", () => { + const wrong: string[] = []; + let checked = 0; + for (const { code, map } of outputs[name]) { + const sourceMap = new SourceMap(map); + const generated = code.split("\n"); + generated.forEach((line, generatedLine) => { + for (const { 1: id, index } of line.matchAll(/\bat(?:\?\.)?\((\d+)\)/g)) { + const { file, line: originalLine, column } = probes[Number(id)]; + const entry = sourceMap.findEntry(generatedLine, index) as any; + checked++; + const got = `${String(entry.originalSource).replaceAll("\\", "/").split("/").slice(-2).join("/")}:${entry.originalLine + 1}:${entry.originalColumn + 1}`; + if (got !== `${file}:${originalLine}:${column}`) + wrong.push(`at(${id}) of ${file}:${originalLine}:${column} is at ${got}`); + } + }); + } + expect(wrong).toEqual([]); + expect(checked).toBeGreaterThan(0); + }); +}); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 355ea17b2683..41537100f000 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -105,7 +105,7 @@ test("Error inside minified file (no color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:86:7)" + at ([dir]/inspect-error.test.js:87:5)" `); } }); @@ -134,7 +134,7 @@ test("Error inside minified file (color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:114:7)" + at ([dir]/inspect-error.test.js:115:5)" `); } }); diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index d1aed28bbe48..e63c0405ea18 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -637,6 +637,49 @@ console.log(JSON.stringify({ count: fn?.ranges[0].count })); }); }); + test.concurrent("a function's range is its text, and the script is reported once", async () => { + using dir = tempDir("inspector-coverage-ranges", { + "fixture.cjs": ` + const inspector = require("node:inspector"); + const vm = require("node:vm"); + const code = [ + "function called(x) { if (x) { return 1; } return 2; }", + "function neverCalled() { return 3; }", + "const arrow = y => y;", + "called(true); arrow(1);", + "", + ].join("\\n"); + const session = new inspector.Session(); + session.connect(); + session.post("Profiler.enable"); + session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: false }); + vm.runInThisContext(code, { filename: "/virtual/ranges.js" }); + session.post("Profiler.takePreciseCoverage", (err, { result }) => { + const { functions } = result.find(script => script.url.endsWith("/virtual/ranges.js")); + console.log(JSON.stringify(functions.map(f => code.slice(f.ranges[0].startOffset, f.ranges[0].endOffset)))); + }); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.cjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + const [script, ...functions] = JSON.parse(stdout); + expect(script).toStartWith("function called(x)"); + expect(script).toEndWith("arrow(1);\n"); + expect(functions).toEqual([ + "function called(x) { if (x) { return 1; } return 2; }", + "function neverCalled() { return 3; }", + "y => y", + ]); + }); + test.concurrent("respects callCount: false and detailed: false", async () => { using dir = tempDir("inspector-coverage-flags", { "fixture.mjs": coverageVmFixture, From a16bfa5f649db965fc3552fdbf25c3f04f71466b Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 04:48:02 +0000 Subject: [PATCH 02/20] List a precise coverage range only where its count differs from its parent's, keep line counts in one vector and expect Node's column in test-common-must-not-call --- src/js/node/inspector.ts | 33 +++++++------ src/sourcemap_jsc/CodeCoverage.rs | 29 ++++++------ .../node/inspector/inspector-profiler.test.ts | 46 +++++++++++++++++++ .../parallel/test-common-must-not-call.js | 4 +- 4 files changed, 81 insertions(+), 31 deletions(-) diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index e473cf484d10..2ac4145ebb46 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -319,7 +319,7 @@ function buildScriptCoverageList( const blocks = script.blocks .map(([start, end, count]): [number, number, number] => [start, end + 1, count]) .filter(([start, end]) => start >= 0 && end > start) - .sort((a, b) => a[0] - b[0]); + .sort((a, b) => a[0] - b[0] || b[1] - a[1]); // Assign each basic block to the innermost function range containing it. const blocksPerFunction: Array> = functions.map(() => []); @@ -359,19 +359,27 @@ function buildScriptCoverageList( const scriptExecuted = blocks.some(([, , count]) => count > 0) ? 1 : 0; const entries: object[] = []; - const toRange = ([startOffset, endOffset, count]: [number, number, number]) => ({ - startOffset, - endOffset, - count: callCount ? count : count > 0 ? 1 : 0, - }); + // As in V8, a range is listed only where its count is not that of the range it is in. + // ast-v8-to-istanbul takes a range to include the character after it, which a range + // that only repeats its parent's count would give to the function that starts there. + const toRanges = (outer: [number, number, number], inner: Array<[number, number, number]>) => { + const ranges: Array<{ startOffset: number; endOffset: number; count: number }> = []; + const enclosing: typeof ranges = []; + for (const [startOffset, endOffset, blockCount] of detailed ? [outer, ...inner] : [outer]) { + while (enclosing.length > 0 && enclosing[enclosing.length - 1].endOffset <= startOffset) enclosing.pop(); + const count = callCount ? blockCount : blockCount > 0 ? 1 : 0; + if (enclosing.length > 0 && enclosing[enclosing.length - 1].count === count) continue; + const range = { startOffset, endOffset, count }; + ranges.push(range); + enclosing.push(range); + } + return ranges; + }; // Whole-script entry. V8 always reports one covering the entire source. entries.push({ functionName: "", - ranges: [ - { startOffset: 0, endOffset: sourceLength, count: scriptExecuted }, - ...(detailed ? topLevelBlocks.map(toRange) : []), - ], + ranges: toRanges([0, sourceLength, scriptExecuted], topLevelBlocks), isBlockCoverage: detailed, }); @@ -401,10 +409,7 @@ function buildScriptCoverageList( } entries.push({ functionName: "", - ranges: [ - { startOffset, endOffset, count: callCount ? count : count > 0 ? 1 : 0 }, - ...(detailed ? ownBlocks.map(toRange) : []), - ], + ranges: toRanges([startOffset, endOffset, count], ownBlocks), isBlockCoverage: detailed, }); } diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 0dda5838f0dd..86a4b96b90dc 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -765,7 +765,7 @@ impl ByteRangeMapping { functions, executable_lines, lines_which_have_executed, - line_hits: lines.hits, + line_hits: lines.hits(), stmts, functions_which_have_executed, stmts_which_have_executed, @@ -923,35 +923,34 @@ impl ExecutionCounts { /// A line is executable once anything on it is, and ran as often as what ran most on it. #[derive(Default)] -struct LineCounts { - executable: Vec, - hits: LinesHits, -} +struct LineCounts(Vec>); impl LineCounts { fn add(&mut self, line: usize, count: u32) { - if line >= self.hits.len() { - self.hits.resize(line + 1, 0); - self.executable.resize(line + 1, false); + if line >= self.0.len() { + self.0.resize(line + 1, None); } - self.executable[line] = true; - self.hits[line] = self.hits[line].max(count); + self.0[line] = self.0[line].max(Some(count)); } /// Executable lines, and those of them that ran. fn bitsets(&self) -> Result<(Bitset, Bitset), bun_alloc::AllocError> { - let mut executable = Bitset::init_empty(self.hits.len())?; - let mut executed = Bitset::init_empty(self.hits.len())?; - for (line, &hits) in self.hits.iter().enumerate() { - if self.executable[line] { + let mut executable = Bitset::init_empty(self.0.len())?; + let mut executed = Bitset::init_empty(self.0.len())?; + for (line, &hits) in self.0.iter().enumerate() { + if hits.is_some() { executable.set(line); } - if hits > 0 { + if hits > Some(0) { executed.set(line); } } Ok((executable, executed)) } + + fn hits(&self) -> LinesHits { + self.0.iter().map(|hits| hits.unwrap_or(0)).collect() + } } #[unsafe(no_mangle)] diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index e63c0405ea18..74c235d720cb 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -757,6 +757,52 @@ console.log(JSON.stringify({ count: fn?.ranges[0].count })); // Every range across every script satisfies startOffset < endOffset. expect(zeroWidth).toEqual([]); }); + + test.concurrent("lists a range only where its count is not that of the range it is in", async () => { + using dir = tempDir("inspector-coverage-nested", { + "fixture.cjs": ` + const inspector = require("node:inspector"); + const vm = require("node:vm"); + const code = [ + "function called(x) {", + " if (x) {", + " return 1;", + " }", + " return 2;", + "}", + "function neverCalled() {}", + "called(0);", + ].join("\\n"); + const session = new inspector.Session(); + session.connect(); + session.post("Profiler.enable"); + session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); + vm.runInThisContext(code, { filename: "/virtual/nested.js" }); + session.post("Profiler.takePreciseCoverage", (error, { result }) => { + const { functions } = result.find(script => script.url.endsWith("/virtual/nested.js")); + console.log(JSON.stringify(functions.map(f => f.ranges.map(r => [code.slice(r.startOffset, r.endOffset), r.count])))); + }); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.cjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + const [script, called, neverCalled] = JSON.parse(stdout); + // The text between the two functions ran as often as the script: it is not a range, + // and so nothing but the function's own range is next to "function neverCalled". + expect(script).toHaveLength(1); + expect(called[0][1]).toBe(1); + expect(called.slice(1).map(([, count]) => count)).not.toContain(1); + expect(called.slice(1).map(([text]) => text)).toContainEqual(expect.stringContaining("return 1;")); + expect(neverCalled).toEqual([["function neverCalled() {}", 0]]); + }); }); describe("exports", () => { diff --git a/test/js/node/test/parallel/test-common-must-not-call.js b/test/js/node/test/parallel/test-common-must-not-call.js index 4b205be1934e..33e3e1ce9bb1 100644 --- a/test/js/node/test/parallel/test-common-must-not-call.js +++ b/test/js/node/test/parallel/test-common-must-not-call.js @@ -26,14 +26,14 @@ const createValidate = (line, args = []) => common.mustCall((e) => { assert.strictEqual(rest, line + argsInfo); }); -const validate1 = createValidate('9:29'); +const validate1 = createValidate('9:30'); try { testFunction1(); } catch (e) { validate1(e); } -const validate2 = createValidate('11:29', ['hello', 42]); +const validate2 = createValidate('11:30', ['hello', 42]); try { testFunction2('hello', 42); } catch (e) { From 0d10d836f2c9465f03c53f8487750818643cb9d3 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 06:06:38 +0000 Subject: [PATCH 03/20] Keep no pointer to the text JSC compiled in the coverage report, map an exported async function and an abstract class to the same words, and check the preview of an error before and after its stack is read --- scripts/build/deps/webkit.ts | 2 +- src/js_parser/parse/parse_stmt.rs | 11 +-- src/jsc/bindings/CodeCoverage.cpp | 5 +- src/jsc/bindings/JSInspectorProfiler.cpp | 3 + src/sourcemap_jsc/CodeCoverage.rs | 59 ++++++++-------- .../bun/sourcemap/source-positions-corpus.ts | 69 ++++++++++++++++++- .../js/bun/sourcemap/source-positions.test.ts | 22 +++++- 7 files changed, 129 insertions(+), 42 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 4d4868f2b7f1..db4eacf357fc 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-dd699577"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-eede1890"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 5298b27df33d..e3608921ec89 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -913,7 +913,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TFunction)?; opts.is_export = true; - return p.parse_fn_stmt(loc, opts, Some(async_range)); + return p.parse_fn_stmt(async_range.loc, opts, Some(async_range)); } if Self::IS_TYPESCRIPT_ENABLED { @@ -998,7 +998,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O lexical_decl: LexicalDecl::AllowAll, ..Default::default() }; - let stmt = p.parse_fn_stmt(loc, &mut stmt_opts, Some(async_range))?; + let stmt = + p.parse_fn_stmt(async_range.loc, &mut stmt_opts, Some(async_range))?; if matches!(stmt.data, js_ast::StmtData::STypeScript(_)) { // This was just a type annotation return Ok(stmt); @@ -1129,7 +1130,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_name_optional: true, ..Default::default() }; - let stmt: Stmt = p.parse_class_stmt(loc, &mut stmt_opts)?; + let stmt: Stmt = p.parse_class_stmt(p.lexer.loc(), &mut stmt_opts)?; // Use the statement name if present, since it's a better name let default_name: LocRef = 'default_name_getter: { @@ -1150,7 +1151,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::StmtData::SClass(class) => { if let Some(_name) = class.class.class_name { break 'default_name_getter LocRef { - loc: default_loc, + loc: _name.loc, ref_: _name.ref_, }; } @@ -1832,7 +1833,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if !p.lexer.has_newline_before && (p.lexer.token == T::TClass || opts.ts_decorators.is_some()) { - return Ok(Some(p.parse_class_stmt(loc, opts)?)); + return Ok(Some(p.parse_class_stmt(p.lexer.loc(), opts)?)); } if opts.ts_decorators.is_some() { let r = js_lexer::range_of_identifier(p.source, loc); diff --git a/src/jsc/bindings/CodeCoverage.cpp b/src/jsc/bindings/CodeCoverage.cpp index 57fbf7d73330..52640b49752e 100644 --- a/src/jsc/bindings/CodeCoverage.cpp +++ b/src/jsc/bindings/CodeCoverage.cpp @@ -23,11 +23,14 @@ extern "C" bool CodeCoverage__withBlocksAndFunctions( size_t functionStartOffset = basicBlocks.size(); - const Vector>& functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); + auto functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); for (const auto& functionRange : functionRanges) { + bool isFunctionInSource = std::get<3>(functionRange); + if (!isFunctionInSource) + continue; BasicBlockRange range; range.m_hasExecuted = std::get<0>(functionRange); range.m_startOffset = static_cast(std::get<1>(functionRange)); diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index f98e9e8b5799..d769387accde 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -144,6 +144,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl auto functionArray = JSON::Array::create(); for (const auto& functionRange : functionRanges) { + bool isFunctionInSource = std::get<3>(functionRange); + if (!isFunctionInSource) + continue; auto range = JSON::Array::create(); range->pushDouble(static_cast(std::get<1>(functionRange))); range->pushDouble(static_cast(std::get<2>(functionRange))); diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index 86a4b96b90dc..f00662a93e81 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -606,14 +606,15 @@ pub struct BasicBlockRange { } pub struct ByteRangeMapping { - /// The text JSC compiled. JSC's offsets and the source map's generated columns both count - /// its code units. - source: bun_core::String, - /// Code unit offset of the first unit of each line of `source`. + /// A bit for each code unit of the text JSC compiled, which is what JSC's offsets and the + /// source map's generated columns count. Set if the unit can make its line executable: + /// not white space, `{` or `}`. The text itself is only borrowed while this is made. + executable_units: Bitset, + /// Code unit offset of the first unit of each line of the text. line_starts: Vec, /// JSC records coverage per `SourceProvider`, and each load of the file makes one. pub(crate) source_ids: Vec, - /// Of `source`. + /// Of the text. source_hash: u64, pub source_url: Utf8Bytes<'static>, } @@ -692,7 +693,7 @@ impl ByteRangeMapping { blocks.iter().filter_map(CountedRange::from_jsc).collect(); blocks.sort_unstable_by_key(|block| block.range); - let source_len = u32::try_from(self.source.length()).expect("int cast"); + let source_len = u32::try_from(self.executable_units.bit_length()).expect("int cast"); let mut functions: Vec = Vec::with_capacity(function_blocks.len()); for function in function_blocks { let Some(mut function) = CountedRange::from_jsc(function) else { @@ -782,13 +783,9 @@ impl ByteRangeMapping { line: usize, lines: &mut LineCounts, ) { - if matches!( - self.source.char_at(offset as usize), - 0x09 | 0x0A | 0x0D | 0x20 | 0x7B | 0x7D - ) { - return; - } - if let Some(count) = counts.at(offset) { + if self.executable_units.is_set(offset as usize) + && let Some(count) = counts.at(offset) + { lines.add(line, count); } } @@ -797,7 +794,7 @@ impl ByteRangeMapping { let line = usize::try_from(generated.lines.zero_based()).ok()?; let column = u32::try_from(generated.columns.zero_based()).ok()?; let offset = self.line_starts.get(line)?.checked_add(column)?; - ((offset as usize) < self.source.length()).then_some(offset) + ((offset as usize) < self.executable_units.bit_length()).then_some(offset) } fn line_and_column_of(&self, offset: u32) -> (Ordinal, Ordinal) { @@ -811,18 +808,18 @@ impl ByteRangeMapping { } pub(crate) fn compute( - source: bun_core::String, + source: &bun_core::String, source_hash: u64, source_id: i32, source_url: Utf8Bytes<'static>, ) -> ByteRangeMapping { - let line_starts = if source.is_utf16() { - line_starts(source.utf16().iter().copied()) + let (line_starts, executable_units) = if source.is_utf16() { + scan(source.utf16().iter().copied()) } else { - line_starts(source.latin1().iter().map(|&unit| unit as u16)) + scan(source.latin1().iter().map(|&unit| unit as u16)) }; ByteRangeMapping { - source, + executable_units, line_starts, source_ids: vec![source_id], source_hash, @@ -831,9 +828,11 @@ impl ByteRangeMapping { } } -/// Lines end where `bun_sourcemap::Chunk`'s builder ends a generated line. -fn line_starts(units: impl Iterator) -> Vec { +/// `ByteRangeMapping::line_starts` and `executable_units`. Lines end where +/// `bun_sourcemap::Chunk`'s builder ends a generated line. +fn scan(units: impl ExactSizeIterator) -> (Vec, Bitset) { let mut starts = vec![0u32]; + let mut executable = bun_core::handle_oom(Bitset::init_empty(units.len())); let mut units = units.enumerate().peekable(); while let Some((i, unit)) = units.next() { match unit { @@ -841,8 +840,11 @@ fn line_starts(units: impl Iterator) -> Vec { 0x0A | 0x0D | 0x2028 | 0x2029 => starts.push(u32::try_from(i + 1).expect("int cast")), _ => {} } + if !matches!(unit, 0x09 | 0x0A | 0x0D | 0x20 | 0x7B | 0x7D) { + executable.set(i); + } } - starts + (starts, executable) } #[derive(Clone, Copy)] @@ -852,12 +854,11 @@ struct CountedRange { } impl CountedRange { - /// JSC's ranges include their end, and the text between two adjacent functions is a range - /// that ends before it starts. + /// JSC's ranges include their end. The program of an empty file ends at -1. fn from_jsc(range: &BasicBlockRange) -> Option { let start = u32::try_from(range.start_offset).ok()?; let end = u32::try_from(range.end_offset).ok()?.checked_add(1)?; - (start < end).then(|| CountedRange { + Some(CountedRange { range: ByteRange { start, end }, count: u32::try_from(range.execution_count).unwrap_or(u32::MAX), }) @@ -977,12 +978,8 @@ extern "C" fn ByteRangeMapping__generate( return; } - let new_value = ByteRangeMapping::compute( - source_contents_str.clone(), - source_hash, - source_id, - source_url, - ); + let new_value = + ByteRangeMapping::compute(source_contents_str, source_hash, source_id, source_url); map.insert(hash, new_value); } diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index f784fca35f38..8dd516e80b3c 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -265,6 +265,46 @@ export const cases: Record = { 1|once().next(); 1|never(); 1|stuck(); +`, + }, + // What is a function to the engine alone: not one of the file's functions, and not run until it is. + "engine-functions.ts": { + functions: "5/6", + lines: ` + 1|function* branches(x: boolean) { + 1| if (x) { + 1| yield 1; + -| } + 1| yield 2; + -|} + 1|function thrower(): number { + 1| throw new Error(); + -|} + 1|try { + 1| class A { + 1| static a = thrower(); + -| static { + 0| ran(); + -| } + -| } + 1|} catch {} + 1|const key = "k"; + 1|class B { + 1| [key] = + 3| () => { at(); }; + -|} + 1|const b: any = new B(); + 1|b.k(); + 1|b.k(); + 1|b.k(); + 1|const pick = (x: boolean) => + 1| x + 2| ? () => { at(); } + 0| : () => { ran(); }; + 1|const picked = pick(true); + 1|picked(); + 1|picked(); + 1|[...branches(true)]; `, }, "logical.ts": { @@ -454,7 +494,7 @@ export const cases: Record = { `, }, "module.mjs": { - functions: "1/2", + functions: "2/3", lines: ` 1|export const value = at(); 0|export default function never() { @@ -463,7 +503,11 @@ export const cases: Record = { 1|export function called() { 1| at(); -|} + 1|export async function awaited() { + 1| at(); + -|} 1|called(); + 1|awaited(); `, }, // What a bundle keeps of a default export is a declaration without `export default`. @@ -483,15 +527,38 @@ export const cases: Record = { 1| at(); -| } -|} +`, + }, + "async.ts": { + functions: "1/1", + lines: ` + 1|export default async function awaited() { + 1| at(); + -|} +`, + }, + "abstract.ts": { + functions: "1/1", + lines: ` + 1|abstract class Plain { + 1| method() { + 1| at(); + -| } + -|} + 1|export default abstract class Base extends Plain {} `, }, "importer.ts": { functions: "0/0", lines: ` + 1|import Base from "./abstract.ts"; + 1|import awaited from "./async.ts"; 1|import Imported from "./class.ts"; 1|import imported from "./function.ts"; 1|imported(); + 1|awaited(); 1|new Imported().method(); + 1|new (class extends Base {})().method(); `, }, // The error is from the file that is loaded. Below it is who loads it. diff --git a/test/js/bun/sourcemap/source-positions.test.ts b/test/js/bun/sourcemap/source-positions.test.ts index 61413f52e50c..08dce847af92 100644 --- a/test/js/bun/sourcemap/source-positions.test.ts +++ b/test/js/bun/sourcemap/source-positions.test.ts @@ -91,7 +91,9 @@ const records = (lanes[process.env.SOURCE_POSITIONS_LANE] = { failed: {} }); function record(id, error) { const previous = records[id]; if (previous) return void previous.count++; - records[id] = { count: 1, stack: error.stack, preview: Bun.inspect(error, { colors: false }) }; + // An error that nothing catches is printed without its stack having been read. + const preview = Bun.inspect(error, { colors: false }); + records[id] = { count: 1, preview, stack: error.stack, previewAfterStack: Bun.inspect(error, { colors: false }) }; return records[id]; } globalThis.at = function at(id) { @@ -139,7 +141,14 @@ const builds: Record> = { // Bun reads the source map of what was built for it. const bundles = Object.keys(builds).filter(name => name !== "browser"); -type Record_ = { count: number; stack?: string; preview: string; callSite?: string; getCallSites?: string }; +type Record_ = { + count: number; + stack?: string; + preview: string; + previewAfterStack: string; + callSite?: string; + getCallSites?: string; +}; type Lane = { failed: Record } & Record; const lanes: Record = {}; @@ -254,10 +263,15 @@ function underCaret(preview: string, text: string) { const lines = preview.split("\n"); const caret = lines.findIndex(line => /^\s*\^$/.test(line)); if (caret < 1) return lines.some(line => Buffer.byteLength(line) > 1024) ? text : preview; - return lines[caret - 1].slice(lines[caret].indexOf("^")).slice(0, text.length); + const under = lines[caret - 1].slice(lines[caret].indexOf("^")); + // In most lanes `new X()` is at `new`, where V8 has it, until the stack of the error is read, and at `X` after that. + return (under.startsWith("new " + text) ? under.slice(4) : under).slice(0, text.length); } describe.each(["test", "run", ...bundles, "compiled"])("%s", laneName => { + // On Windows, an executable has no preview of an error whose stack has been read. + const hasPreviewAfterStack = !(isWindows && laneName === "compiled"); + test("every program loads", () => { expect(lanes[laneName].failed).toEqual({}); }); @@ -278,11 +292,13 @@ describe.each(["test", "run", ...bundles, "compiled"])("%s", laneName => { ...(said.callSite && { callSite: position(said.callSite), getCallSites: position(said.getCallSites) }), // The preview is of the first frame. preview: frame === 0 ? underCaret(said.preview, text) : text, + previewAfterStack: frame === 0 && hasPreviewAfterStack ? underCaret(said.previewAfterStack, text) : text, }; const want = { stack: expected, ...(said.callSite && { callSite: expected, getCallSites: expected }), preview: text, + previewAfterStack: text, }; if (!Bun.deepEquals(got, want)) wrong[`${expected} ${text}`] = got; } From d237c0337dbd4973ad62eccb7d05f2a6bf37a467 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 06:35:16 +0000 Subject: [PATCH 04/20] Map require.resolve() of a known path to the call, so that a line with only that call is in the coverage report --- src/js_parser/p.rs | 10 +++++++--- src/js_parser/visit/visit_expr.rs | 2 +- src/js_printer/lib.rs | 1 + test/js/bun/sourcemap/source-positions-corpus.ts | 4 ++++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 562fe78ae6c3..ac8a8981c211 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1558,7 +1558,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) -> Expr { // The argument must be a string if matches!(arg.data, js_ast::ExprData::EString(_)) { - return self.transpose_require_resolve_known_string(arg); + return self.transpose_require_resolve_known_string(arg, require_resolve_ref.loc); } if self.options.warn_about_unbundled_modules { @@ -1587,7 +1587,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } #[inline] - pub(crate) fn transpose_require_resolve_known_string(&mut self, arg: Expr) -> Expr { + pub(crate) fn transpose_require_resolve_known_string( + &mut self, + arg: Expr, + loc: bun_ast::Loc, + ) -> Expr { debug_assert!(matches!(arg.data, js_ast::ExprData::EString(_))); // Ignore calls to import() if the control flow is provably dead here. @@ -1620,7 +1624,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O import_record_index, // .leading_interior_comments = arg.getString(). }, - arg.loc, + loc, ) } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index b8219644717c..5bd49840e95d 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2233,7 +2233,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Data::EString(..) => { // require.resolve(FOO) => require.resolve(FOO) // (this will register dependencies) - *e = p.transpose_require_resolve_known_string(first); + *e = p.transpose_require_resolve_known_string(first, expr.loc); return; } Data::EIf(..) => { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index af6f8a5f3b85..79371cc176bf 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -3591,6 +3591,7 @@ pub(crate) mod __gated_printer { } self.print_space_before_identifier(); + self.add_source_mapping(expr.loc); if let Some(require_ref) = self.options.require_ref { self.print_symbol(require_ref); diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index 8dd516e80b3c..cc5488041566 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -570,6 +570,10 @@ export const cases: Record = { 1|} catch (e) { 1| caught(e, "require("); -|} + 1|require.resolve("node:fs"); + 1|require.resolve( + -| "node:fs", + -|); `, }, // The path of a chunk is put in once its length is known, which moves what follows it on the line. From 3de2467add29b5777a3aaa00949948fb2ee0d8a5 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 06:38:21 +0000 Subject: [PATCH 05/20] Do not expect a preview from an executable once the stack of the error has been read, on any platform --- test/js/bun/sourcemap/source-positions.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/bun/sourcemap/source-positions.test.ts b/test/js/bun/sourcemap/source-positions.test.ts index 08dce847af92..5490abf4a358 100644 --- a/test/js/bun/sourcemap/source-positions.test.ts +++ b/test/js/bun/sourcemap/source-positions.test.ts @@ -269,8 +269,9 @@ function underCaret(preview: string, text: string) { } describe.each(["test", "run", ...bundles, "compiled"])("%s", laneName => { - // On Windows, an executable has no preview of an error whose stack has been read. - const hasPreviewAfterStack = !(isWindows && laneName === "compiled"); + // Once the stack of an error has been read, an executable reads the preview from the file on disk. It looks for the + // path in the frame, which is relative to where it was built, from where it runs. + const hasPreviewAfterStack = laneName !== "compiled"; test("every program loads", () => { expect(lanes[laneName].failed).toEqual({}); From 3a49b64525ff4d6f26d3d195db847653f23bbf15 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 07:43:07 +0000 Subject: [PATCH 06/20] List a static block as a function in Profiler.takePreciseCoverage, as V8 does, with a test for which functions are listed --- scripts/build/deps/webkit.ts | 2 +- src/jsc/bindings/CodeCoverage.cpp | 3 +- src/jsc/bindings/JSInspectorProfiler.cpp | 4 +- .../node/inspector/inspector-profiler.test.ts | 48 +++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index db4eacf357fc..90a32497f671 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-eede1890"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-c2c53d42"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/jsc/bindings/CodeCoverage.cpp b/src/jsc/bindings/CodeCoverage.cpp index 52640b49752e..410e3fdf62f2 100644 --- a/src/jsc/bindings/CodeCoverage.cpp +++ b/src/jsc/bindings/CodeCoverage.cpp @@ -28,8 +28,7 @@ extern "C" bool CodeCoverage__withBlocksAndFunctions( basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); for (const auto& functionRange : functionRanges) { - bool isFunctionInSource = std::get<3>(functionRange); - if (!isFunctionInSource) + if (!JSC::FunctionHasExecutedCache::isFunctionInSource(std::get<3>(functionRange))) continue; BasicBlockRange range; range.m_hasExecuted = std::get<0>(functionRange); diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index d769387accde..3118d2b6a239 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -144,8 +144,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl auto functionArray = JSON::Array::create(); for (const auto& functionRange : functionRanges) { - bool isFunctionInSource = std::get<3>(functionRange); - if (!isFunctionInSource) + // V8 has a static block as a function, and a generator or an async function once. + if (JSC::isGeneratorOrAsyncFunctionBodyParseMode(std::get<3>(functionRange))) continue; auto range = JSON::Array::create(); range->pushDouble(static_cast(std::get<1>(functionRange))); diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 74c235d720cb..f61ffca561d2 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -586,6 +586,54 @@ console.log(JSON.stringify({ count: fn?.ranges[0].count })); expect(JSON.parse(stdout.trim())).toEqual({ count: 2 }); }); + // What Node 26 lists for this script, less the "" of Fields and of Inner. + test.concurrent("lists a function once, and nothing that is not in the text", async () => { + using dir = tempDir("inspector-coverage-functions", { + "fixture.mjs": ` +import { Session } from "node:inspector/promises"; +import vm from "node:vm"; +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); +const code = [ + "class Fields { a = 1; ['b'] = 2; }", + "class Derived extends Fields { }", + "class Empty { }", + "function around() { class Inner { c = 7; } new Inner; }", + "new Fields; new Derived; new Empty; around();", + "function* generator() { yield 3; }", + "function* notResumed() { yield 4; }", + "function* notCalled() { yield 5; }", + "async function asynchronous() { await 6; }", + "class Static { static { generator; } }", + "[...generator()]; notResumed(); asynchronous();", +].join("\\n"); +const url = "file:///functions-fixture/virtual.js"; +vm.runInThisContext(code, { filename: url }); +const { result } = await session.post("Profiler.takePreciseCoverage"); +session.disconnect(); +const functions = result.find(script => script.url === url).functions.map(({ ranges: [range] }) => [ + range.count, + range.startOffset === 0 && range.endOffset === code.length ? "the script" : code.slice(range.startOffset, range.endOffset), +]); +console.log(JSON.stringify(functions)); +`, + }); + await using proc = Bun.spawn({ cmd: [bunExe(), "fixture.mjs"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + expect(JSON.parse(stdout.trim())).toEqual([ + [1, "the script"], + [1, "function around() { class Inner { c = 7; } new Inner; }"], + [1, "function* generator() { yield 3; }"], + [1, "function* notResumed() { yield 4; }"], + [0, "function* notCalled() { yield 5; }"], + [1, "async function asynchronous() { await 6; }"], + [1, "static { generator; }"], + ]); + }); + test.concurrent("collects block coverage with call counts for vm scripts", async () => { using dir = tempDir("inspector-coverage-vm", { "fixture.mjs": coverageVmFixture, From c7677ab9b6c40273bd931b3caebcbc5121ae04c8 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 09:21:47 +0000 Subject: [PATCH 07/20] Use the WebKit preview with the end of the empty finally, and have a try that returns in the constructor of a derived class in the corpus --- scripts/build/deps/webkit.ts | 2 +- test/js/bun/sourcemap/source-positions-corpus.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 90a32497f671..a9c4de3a39b1 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-c2c53d42"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-5426125b"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index cc5488041566..521056d4b1e1 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -203,7 +203,7 @@ export const cases: Record = { }, // An implicit constructor, the initializers of the fields and a static block are not functions of the file. "classes.ts": { - functions: "1/2", + functions: "2/3", lines: ` 4|function v() { 4| return 1; @@ -223,8 +223,20 @@ export const cases: Record = { 0| return 1; -| } -|} + 1|export class Returns extends Implicit { + 1| constructor(x: object) { + 1| super(); + 1| try { + 1| return x; + 0| } catch { + 0| v(); + -| } + 0| v(); + -| } + -|} 1|new Fields(); 1|new Fields(); + 1|new Returns({}); `, }, "loops.ts": { From dc49e204cfbbb2830f76cf818fa44597eb80e8af Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 21:20:34 +0000 Subject: [PATCH 08/20] Leave the program out of the function ranges by its parse mode, so that a function that is the whole script is listed --- scripts/build/deps/webkit.ts | 2 +- src/js/node/inspector.ts | 3 +-- src/jsc/bindings/CodeCoverage.cpp | 3 ++- src/jsc/bindings/JSInspectorProfiler.cpp | 4 ++- src/sourcemap_jsc/CodeCoverage.rs | 23 ++++++---------- .../node/inspector/inspector-profiler.test.ts | 27 +++++++++++++++++++ 6 files changed, 42 insertions(+), 20 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index a9c4de3a39b1..88ad27429e97 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-5426125b"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-e0cf9aee"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index 2ac4145ebb46..d249daf77b6c 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -310,11 +310,10 @@ function buildScriptCoverageList( // JSC's ranges include their last character; V8's end after it. What is empty or inverted // after that is text JSC has between two adjacent functions: V8 never emits // startOffset === endOffset, and @bcoe/v8-coverage recurses forever on one. - // The program is one of JSC's function ranges, and has the whole-script entry below. // Outer functions before nested ones, so a stack-based sweep below sees enclosing ranges first. const functions = script.functions .map(([start, end, executed]): [number, number, boolean] => [start, end + 1, executed]) - .filter(([start, end]) => start >= 0 && end > start && !(start === 0 && end === sourceLength)) + .filter(([start, end]) => start >= 0 && end > start) .sort((a, b) => a[0] - b[0] || b[1] - a[1]); const blocks = script.blocks .map(([start, end, count]): [number, number, number] => [start, end + 1, count]) diff --git a/src/jsc/bindings/CodeCoverage.cpp b/src/jsc/bindings/CodeCoverage.cpp index 410e3fdf62f2..893ea67d926d 100644 --- a/src/jsc/bindings/CodeCoverage.cpp +++ b/src/jsc/bindings/CodeCoverage.cpp @@ -28,7 +28,8 @@ extern "C" bool CodeCoverage__withBlocksAndFunctions( basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); for (const auto& functionRange : functionRanges) { - if (!JSC::FunctionHasExecutedCache::isFunctionInSource(std::get<3>(functionRange))) + auto parseMode = std::get<3>(functionRange); + if (JSC::isProgramOrModuleParseMode(parseMode) || !JSC::FunctionHasExecutedCache::isFunctionInSource(parseMode)) continue; BasicBlockRange range; range.m_hasExecuted = std::get<0>(functionRange); diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index 3118d2b6a239..0ff531af5bce 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -145,7 +145,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl auto functionArray = JSON::Array::create(); for (const auto& functionRange : functionRanges) { // V8 has a static block as a function, and a generator or an async function once. - if (JSC::isGeneratorOrAsyncFunctionBodyParseMode(std::get<3>(functionRange))) + // The program has the whole-script entry. + auto parseMode = std::get<3>(functionRange); + if (JSC::isProgramOrModuleParseMode(parseMode) || JSC::isGeneratorOrAsyncFunctionBodyParseMode(parseMode)) continue; auto range = JSON::Array::create(); range->pushDouble(static_cast(std::get<1>(functionRange))); diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index f00662a93e81..47bfd13eaba8 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -747,15 +747,14 @@ impl ByteRangeMapping { } } - // The program is a range of JSC's like any function, and so is a function the source - // does not have, such as the CommonJS wrapper. - functions.retain(|function| { - function.range != ByteRange::of(0, source_len) - && source_map.as_deref().is_none_or(|source_map| { - let (line, column) = self.line_and_column_of(function.range.start); - source_map.find_mapping(line, column).is_some() - }) - }); + // A function the source does not have, such as the CommonJS wrapper, is a range of JSC's + // like any function. + if let Some(source_map) = source_map.as_deref() { + functions.retain(|function| { + let (line, column) = self.line_and_column_of(function.range.start); + source_map.find_mapping(line, column).is_some() + }); + } let (executable_lines, lines_which_have_executed) = lines.bitsets()?; let (functions, functions_which_have_executed) = split_executed(&functions)?; @@ -1067,9 +1066,3 @@ pub struct ByteRange { pub start: u32, pub end: u32, } - -impl ByteRange { - fn of(start: u32, end: u32) -> ByteRange { - ByteRange { start, end } - } -} diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index f61ffca561d2..633e961bcbd5 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -634,6 +634,33 @@ console.log(JSON.stringify(functions)); ]); }); + test.concurrent("lists a function that is the whole script", async () => { + using dir = tempDir("inspector-coverage-whole-script", { + "fixture.mjs": ` +import { Session } from "node:inspector/promises"; +import vm from "node:vm"; +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); +const url = "file:///whole-script-fixture/virtual.js"; +vm.runInThisContext("function whole() { }", { filename: url }); +whole(); +whole(); +const { result } = await session.post("Profiler.takePreciseCoverage"); +session.disconnect(); +console.log(JSON.stringify(result.find(script => script.url === url).functions.map(({ ranges }) => ranges))); +`, + }); + await using proc = Bun.spawn({ cmd: [bunExe(), "fixture.mjs"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + expect(JSON.parse(stdout.trim())).toEqual([ + [{ startOffset: 0, endOffset: 20, count: 1 }], + [{ startOffset: 0, endOffset: 20, count: 2 }], + ]); + }); + test.concurrent("collects block coverage with call counts for vm scripts", async () => { using dir = tempDir("inspector-coverage-vm", { "fixture.mjs": coverageVmFixture, From 97070d4a93174a37d5127b2e393f1a57dc92178a Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 17 Oct 2026 23:34:41 +0000 Subject: [PATCH 09/20] Give V8's range ends from the binding, drop the filter for the empty ranges JSC no longer has, and say in CodeCoverage.cpp which ranges are not functions --- src/js/node/inspector.ts | 18 ++++-------------- src/jsc/bindings/CodeCoverage.cpp | 3 ++- src/jsc/bindings/JSInspectorProfiler.cpp | 5 +++-- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index d249daf77b6c..b6a638d6bbf6 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -307,18 +307,10 @@ function buildScriptCoverageList( url = pathToFileURL(url).href; } - // JSC's ranges include their last character; V8's end after it. What is empty or inverted - // after that is text JSC has between two adjacent functions: V8 never emits - // startOffset === endOffset, and @bcoe/v8-coverage recurses forever on one. - // Outer functions before nested ones, so a stack-based sweep below sees enclosing ranges first. - const functions = script.functions - .map(([start, end, executed]): [number, number, boolean] => [start, end + 1, executed]) - .filter(([start, end]) => start >= 0 && end > start) - .sort((a, b) => a[0] - b[0] || b[1] - a[1]); - const blocks = script.blocks - .map(([start, end, count]): [number, number, number] => [start, end + 1, count]) - .filter(([start, end]) => start >= 0 && end > start) - .sort((a, b) => a[0] - b[0] || b[1] - a[1]); + // Outer ranges before nested ones, so a stack-based sweep below sees + // enclosing ranges first. + const functions = script.functions.sort((a, b) => a[0] - b[0] || b[1] - a[1]); + const blocks = script.blocks.sort((a, b) => a[0] - b[0] || b[1] - a[1]); // Assign each basic block to the innermost function range containing it. const blocksPerFunction: Array> = functions.map(() => []); @@ -359,8 +351,6 @@ function buildScriptCoverageList( const entries: object[] = []; // As in V8, a range is listed only where its count is not that of the range it is in. - // ast-v8-to-istanbul takes a range to include the character after it, which a range - // that only repeats its parent's count would give to the function that starts there. const toRanges = (outer: [number, number, number], inner: Array<[number, number, number]>) => { const ranges: Array<{ startOffset: number; endOffset: number; count: number }> = []; const enclosing: typeof ranges = []; diff --git a/src/jsc/bindings/CodeCoverage.cpp b/src/jsc/bindings/CodeCoverage.cpp index 893ea67d926d..eb829838e3c3 100644 --- a/src/jsc/bindings/CodeCoverage.cpp +++ b/src/jsc/bindings/CodeCoverage.cpp @@ -28,8 +28,9 @@ extern "C" bool CodeCoverage__withBlocksAndFunctions( basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); for (const auto& functionRange : functionRanges) { + // A generator or an async function is one function, and a static block is none. auto parseMode = std::get<3>(functionRange); - if (JSC::isProgramOrModuleParseMode(parseMode) || !JSC::FunctionHasExecutedCache::isFunctionInSource(parseMode)) + if (JSC::isProgramOrModuleParseMode(parseMode) || JSC::isGeneratorOrAsyncFunctionBodyParseMode(parseMode) || parseMode == JSC::SourceParseMode::ClassStaticBlockMode) continue; BasicBlockRange range; range.m_hasExecuted = std::get<0>(functionRange); diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index 0ff531af5bce..ba81d9b494e2 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -132,11 +132,12 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl script->setDouble("scriptId"_s, static_cast(sourceID)); script->setDouble("sourceLength"_s, static_cast(provider->source().length())); + // JSC's ranges include their last character; V8's end after it. auto blockArray = JSON::Array::create(); for (const auto& block : blocks) { auto range = JSON::Array::create(); range->pushInteger(block.m_startOffset); - range->pushInteger(block.m_endOffset); + range->pushInteger(block.m_endOffset + 1); range->pushDouble(static_cast(block.m_executionCount)); blockArray->pushValue(WTF::move(range)); } @@ -151,7 +152,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl continue; auto range = JSON::Array::create(); range->pushDouble(static_cast(std::get<1>(functionRange))); - range->pushDouble(static_cast(std::get<2>(functionRange))); + range->pushDouble(static_cast(std::get<2>(functionRange)) + 1); range->pushBoolean(std::get<0>(functionRange)); functionArray->pushValue(WTF::move(range)); } From f4575f80c6583e54cda212424aa91d7b1f5348c7 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 00:03:40 +0000 Subject: [PATCH 10/20] Use the WebKit preview without isFunctionInSource() --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 88ad27429e97..830a59ec587c 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-e0cf9aee"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-bd30236f"; /** * WebKit (JavaScriptCore) — the JS engine. From 23b9e75563c56185a21be35a2214f47a62f01a00 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 01:20:54 +0000 Subject: [PATCH 11/20] Have the ranges of Profiler.takePreciseCoverage nest as the source does, with nothing after the last statement of a block --- scripts/build/deps/webkit.ts | 2 +- src/js/node/inspector.ts | 5 + src/jsc/bindings/JSInspectorProfiler.cpp | 2 +- .../bun/sourcemap/source-positions-corpus.ts | 131 ++++++ .../node/inspector/inspector-profiler.test.ts | 404 +++++++++++++++++- 5 files changed, 536 insertions(+), 8 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 830a59ec587c..aaca6b30f9c0 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-bd30236f"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-2e56a0b1"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index b6a638d6bbf6..7f66cbc43848 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -318,6 +318,11 @@ function buildScriptCoverageList( const stack: number[] = []; let nextFunction = 0; for (const block of blocks) { + // The script's own, which is not in a function that is the whole script. + if (block[0] === 0 && block[1] === sourceLength) { + topLevelBlocks.push(block); + continue; + } while (nextFunction < functions.length && functions[nextFunction][0] <= block[0]) { stack.push(nextFunction); nextFunction++; diff --git a/src/jsc/bindings/JSInspectorProfiler.cpp b/src/jsc/bindings/JSInspectorProfiler.cpp index ba81d9b494e2..44b6067cbe81 100644 --- a/src/jsc/bindings/JSInspectorProfiler.cpp +++ b/src/jsc/bindings/JSInspectorProfiler.cpp @@ -119,7 +119,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_collectPreciseCoverage, (JSGlobalObject * gl auto scripts = JSON::Array::create(); for (auto& provider : providers) { SourceID sourceID = provider->asID(); - auto blocks = profiler->getBasicBlocksForSourceIDWithoutFunctionRange(sourceID, vm); + auto blocks = profiler->getBasicBlocksForSourceIDWithoutFunctionRange(sourceID, vm, JSC::ControlFlowProfiler::Gaps::With); auto functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); if (blocks.isEmpty() && functionRanges.isEmpty()) continue; diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index 521056d4b1e1..4ea89e4a770d 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -260,6 +260,137 @@ export const cases: Record = { 0| f(); -|} 1|f(); +`, + }, + // What is after a loop runs once, however the loop ends. + "iteration.ts": { + functions: "2/2", + lines: ` + 9|function g() {} + 1|let n = 0; + 1|for (const key in { a: 1, b: 2, c: 3 }) { + 3| n++; + -|} + 1|g(); + 1|for (const key in {}) { + 0| n++; + -|} + 1|g(); + 1|for (const value of [1, 2]) { + 2| n++; + -|} + 1|g(); + 1|while (n > 3) { + 2| n--; + -|} + 1|g(); + 1|while (n > 3) { + 0| n--; + -|} + 1|g(); + 1|outer: for (let i = 0; i < 2; i++) { + 2| for (let j = 0; j < 3; j++) { + 4| if (j === 1) { + 2| continue outer; + -| } + 2| g(); + -| } + 0| g(); + -|} + 1|g(); + 3|function first(list: number[]) { + 3| for (const value of list) { + 2| return value; + -| } + 1| g(); + -|} + 1|first([1]); + 1|first([1]); + 1|first([]); +`, + }, + // What is after a statement that control flow left did not run. + "leaves.ts": { + functions: "7/7", + lines: ` +18|function g() {} + 3|function afterIf(x: boolean) { + 3| if (x) { + 2| return 1; + -| } + 1| g(); + 1| return 2; + -|} + 1|afterIf(true); + 1|afterIf(true); + 1|afterIf(false); + 3|function nested(x: boolean, y: boolean) { + 3| if (x) { + 2| if (y) { + 1| return; + -| } + 1| g(); + -| } + 2| g(); + -|} + 1|nested(true, true); + 1|nested(true, false); + 1|nested(false, false); + 3|function block(x: boolean) { + -| { + 3| if (x) { + 2| return; + -| } + 1| g(); + -| } + 1| g(); + -|} + 1|block(true); + 1|block(true); + 1|block(false); + 4|function pick(x: number) { + 4| switch (x) { + 1| case 1: + 1| return 1; + 1| case 2: + 1| g(); + 3| case 3: + 3| g(); + 3| break; + -| default: + 0| return 4; + -| } + 3| g(); + -|} + 1|pick(1); + 1|pick(2); + 1|pick(3); + 1|pick(3); + 3|function cleanup(x: boolean) { + 3| try { + 3| if (x) { + 2| return 1; + -| } + 1| g(); + -| } finally { + 3| g(); + -| } + 1| g(); + -|} + 1|cleanup(true); + 1|cleanup(true); + 1|cleanup(false); + 1|function rethrows() { + 1| try { + 1| throw new Error(); + 1| } catch (e) { + 1| throw e; + -| } + 0| g(); + -|} + 1|try { + 1| rethrows(); + 1|} catch {} `, }, "generators.ts": { diff --git a/test/js/node/inspector/inspector-profiler.test.ts b/test/js/node/inspector/inspector-profiler.test.ts index 633e961bcbd5..f8a6b8cf2c18 100644 --- a/test/js/node/inspector/inspector-profiler.test.ts +++ b/test/js/node/inspector/inspector-profiler.test.ts @@ -647,17 +647,22 @@ const url = "file:///whole-script-fixture/virtual.js"; vm.runInThisContext("function whole() { }", { filename: url }); whole(); whole(); +const notCalled = "file:///whole-script-fixture/not-called.js"; +vm.runInThisContext("function never() { }", { filename: notCalled }); const { result } = await session.post("Profiler.takePreciseCoverage"); session.disconnect(); -console.log(JSON.stringify(result.find(script => script.url === url).functions.map(({ ranges }) => ranges))); +console.log( + JSON.stringify([url, notCalled].map(url => result.find(script => script.url === url).functions.map(({ ranges }) => ranges))), +); `, }); await using proc = Bun.spawn({ cmd: [bunExe(), "fixture.mjs"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); expect(JSON.parse(stdout.trim())).toEqual([ - [{ startOffset: 0, endOffset: 20, count: 1 }], - [{ startOffset: 0, endOffset: 20, count: 2 }], + [[{ startOffset: 0, endOffset: 20, count: 1 }], [{ startOffset: 0, endOffset: 20, count: 2 }]], + // The script ran, and only declared the function. + [[{ startOffset: 0, endOffset: 20, count: 1 }], [{ startOffset: 0, endOffset: 20, count: 0 }]], ]); }); @@ -873,11 +878,398 @@ console.log(JSON.stringify(result.find(script => script.url === url).functions.m // The text between the two functions ran as often as the script: it is not a range, // and so nothing but the function's own range is next to "function neverCalled". expect(script).toHaveLength(1); - expect(called[0][1]).toBe(1); - expect(called.slice(1).map(([, count]) => count)).not.toContain(1); - expect(called.slice(1).map(([text]) => text)).toContainEqual(expect.stringContaining("return 1;")); + expect(called).toEqual([ + ["function called(x) {\n if (x) {\n return 1;\n }\n return 2;\n}", 1], + ["{\n return 1;\n }", 0], + ]); expect(neverCalled).toEqual([["function neverCalled() {}", 0]]); }); + + // A range is between « and », after its count. As in Node 26, but where it says otherwise. + const rangesInFunctions: Record = { + "a return that is last": ` +function f() { + return 2; +} +f(); +`, + "a throw that is last": ` +function f() { + throw 1; +} +try { f() } catch {} +`, + "after a return": ` +function f() { + return 2;«0: + f(); +»} +f(); +`, + "a function declaration after a return": ` +function f() { + return g(); + function g() {} +} +f(); +`, + "after an if that returns": ` +function f(x) { + if (x) { + return 1; + }«0: + return 2; +»} +f(1); +`, + "an if that is not taken": ` +function f(x) { + if (x) «0:{ + return 1; + }» + return 2; +} +f(0); +`, + "an if that is last": ` +function f(x) { + g(); + if (x) «0:{ + g(); + }» +} +function g() {} +f(0); +`, + "else": ` +function f(x) { + if (x) { + g(); + }«0: else { + g(); + }» + g(); +} +function g() {} +f(1); +`, + "else if": ` +function f(x) { + if (x === 1) «0:{ + g(); + }» else if (x === 2) { + g(); + }«0: else { + g(); + }» +} +function g() {} +f(2); +`, + "an if without braces": ` +function f(x) { + if (x) + return 1;«0: + return 2; +»} +f(1); +`, + "an if in an if that is not taken": ` +function f(x, y) { + if (x) «0:{ + if (y) { + g(); + } + g(); + }» + g(); +} +function g() {} +f(0, 0); +`, + "an if that is last in an if that is last": ` +function f(x, y) { + if (x) { + g(); + if (y) «0:{ + g(); + }» + } +} +function g() {} +f(1, 0); +`, + // Node has the two as one range. + "after an if that returns, in an if": ` +function f(x, y) { + if (x) { + if (y) { + return; + }«0: + g(); + }»«0: + g(); +»} +function g() {} +f(1, 1); +`, + "two ifs that return": ` +function f(x, y) { + if (x) «0:{ + return 1; + }» + g(); + if (y) { + return 2; + }«0: + g(); +»} +function g() {} +f(0, 1); +`, + // Node ends the range before `|| g()` and has what is after it as run. + "a part of an expression after an if that returns": ` +function f(x) { + if (x) { + return 1; + }«0: + let a = x || g(); + g(); +»} +function g() {} +f(1); +`, + "a break that is last": ` +function f() { + for (;;) { + break; + } + g(); +} +function g() {} +f(); +`, + "a continue that is last": ` +function f() { + for (let i = 0; i < 2; i++) «2:{ + continue; + }» + g(); +} +function g() {} +f(); +`, + "a loop that is not entered": ` +function f() { + for (let i = 0; i < 0; i++) «0:{ + g(); + }» +} +function g() {} +f(); +`, + // Node has the last two as one range. + "after an if that continues": ` +function f() { + for (let i = 0; i < 2; i++) «2:{ + if (i) «1:{ + continue; + }»«1: + g(); + }»» +} +function g() {} +f(); +`, + // Node has no count for the condition. + "do while": ` +function f() { + let n = 0; + do «3:{ + n++; + }»«3: while (n < 3);» + g(); +} +function g() {} +f(); +`, + "for in": ` +function f() { + for (const x in {}) «0:{ + g(); + }» + g(); +} +function g() {} +f(); +`, + "a switch that returns": ` +function f(x) { + switch (x) { + case 1: + return 1; + «0:default: + return 2;» + } +} +f(1); +`, + "a switch that breaks": ` +function f(x) { + switch (x) { + case 1: + g(); + break; + «0:case 2: + g(); + break;» + } + g(); +} +function g() {} +f(1); +`, + "a switch that falls through": ` +function f(x) { + switch (x) { + «0:case 1: + g();» + case 2: + g(); + } +} +function g() {} +f(2); +`, + "a try that returns": ` +function f() { + try { + return 1; + } finally { + } +} +f(); +`, + // Node starts the range at `catch`. + "try, catch and finally": ` +function f() { + try { + g(); + }«0: catch (e) { + g(); + }» finally { + g(); + } + g(); +} +function g() {} +f(); +`, + "after a catch that throws": ` +function f() { + try { + throw 1; + } catch (e) { + throw e; + }«0: + g(); +»} +function g() {} +try { f() } catch {} +`, + "after a block that returns": ` +function f() { + { + return 1; + }«0: + g(); +»} +function g() {} +f(); +`, + "a break with a label": ` +function f() { + a: for (;;) { + for (;;) { + break a; + }«0: + g(); + }» + g(); +} +function g() {} +f(); +`, + "an arrow function": ` +const f = x => { + if (x) { + return 1; + }«0: + return 2; +»}; +f(1); +`, + "a generator": ` +function* f(x) { + if (x) { + return 1; + }«0: + yield 2; +»} +[...f(1)]; +`, + "the script": ` +var x = 1; +if (x) { + x++; +}«0: else { + x--; +}» +for (;;) { + break; +} +`, + }; + + test.concurrent("the ranges in a function are the statements that do not have its count", async () => { + const scripts = Object.fromEntries( + Object.entries(rangesInFunctions).map(([name, marked]) => [name, marked.trim().replace(/«\d+:|»/g, "")]), + ); + using dir = tempDir("inspector-coverage-ranges", { + "scripts.json": JSON.stringify(scripts), + "fixture.mjs": ` +import { readFileSync } from "node:fs"; +import { Session } from "node:inspector/promises"; +import vm from "node:vm"; +const scripts = JSON.parse(readFileSync(new URL("./scripts.json", import.meta.url), "utf8")); +const session = new Session(); +session.connect(); +await session.post("Profiler.enable"); +await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }); +const url = name => "file:///ranges/" + encodeURIComponent(name); +for (const name in scripts) vm.runInContext(scripts[name], vm.createContext({}), { filename: url(name) }); +const { result } = await session.post("Profiler.takePreciseCoverage"); +for (const name in scripts) { + const ranges = result.find(script => script.url === url(name)).functions.flatMap(({ ranges }) => ranges.slice(1)); + let marked = ""; + for (let offset = 0; offset <= scripts[name].length; offset++) { + marked += "»".repeat(ranges.filter(range => range.endOffset === offset).length); + for (const range of ranges.filter(range => range.startOffset === offset).sort((a, b) => b.endOffset - a.endOffset)) + marked += "«" + range.count + ":"; + marked += scripts[name][offset] ?? ""; + } + scripts[name] = marked; +} +console.log(JSON.stringify(scripts)); +`, + }); + await using proc = Bun.spawn({ cmd: [bunExe(), "fixture.mjs"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderrIfFailed: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ stderrIfFailed: "", exitCode: 0 }); + expect(JSON.parse(stdout)).toEqual( + Object.fromEntries(Object.entries(rangesInFunctions).map(([name, marked]) => [name, marked.trim()])), + ); + }); }); describe("exports", () => { From 9d232df02d12570a39f5026aecd889a5f2287b83 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 01:39:02 +0000 Subject: [PATCH 12/20] Bump the transpiler cache version for the new mappings, and delete the input line count that only the old coverage read --- src/js_printer/lib.rs | 1 - src/jsc/RuntimeTranspilerCache.rs | 4 +- .../bake/dev_server/source_map_store.rs | 1 - src/runtime/cli/pm_diff_normalize.rs | 4 -- src/sourcemap/Chunk.rs | 5 -- src/sourcemap/InternalSourceMap.rs | 46 +++++++------------ src/sourcemap/Mapping.rs | 2 - src/sourcemap/ParsedSourceMap.rs | 3 -- src/sourcemap/lib.rs | 1 - src/sourcemap_jsc/JSSourceMap.rs | 1 - src/sourcemap_jsc/internal_jsc.rs | 2 +- src/standalone_graph/StandaloneModuleGraph.rs | 4 +- test/cli/run/transpiler-cache.test.ts | 8 ++-- .../internal-sourcemap-roundtrip.test.ts | 18 ++++---- 14 files changed, 35 insertions(+), 65 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 79371cc176bf..00cc8172cb80 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -7651,7 +7651,6 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, ), cover_lines_without_mappings: true, - approximate_input_line_count: tree.approximate_newline_count, prepend_count: IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, line_offset_tables: match opts.line_offset_tables.take() { Some(table) => LineOffsetTables::Borrowed(table), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ac8f5b5f8124..2c5640da5680 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -65,7 +65,9 @@ bun_core::declare_scope!(cache, visible); /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. /// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. -const EXPECTED_VERSION: u32 = 35; +/// Version 36: InternalSourceMap has no input line count, and `export`, `require()`, `import()`, +/// a JSX element, an exported async function and an abstract class are mapped elsewhere. +const EXPECTED_VERSION: u32 = 36; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index 6b66a84617c3..4c8ae120db06 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -675,7 +675,6 @@ impl SourceMapStore { &vlq_bytes, None, i32::try_from(entry.paths.len()).expect("int cast"), - 0, // unused Default::default(), ) { Err(fail) => { diff --git a/src/runtime/cli/pm_diff_normalize.rs b/src/runtime/cli/pm_diff_normalize.rs index 1613736d4aca..5499aa3b34ce 100644 --- a/src/runtime/cli/pm_diff_normalize.rs +++ b/src/runtime/cli/pm_diff_normalize.rs @@ -425,14 +425,10 @@ fn print_js<'a>( None => (line, col), } }; - let printed_lines = printed - .as_ref() - .map_or_else(|| bun_core::strings::count_char(&text, b'\n') + 1, Vec::len); if let Ok(parsed) = bun_sourcemap::mapping::parse( chunk.buffer.list.as_slice(), None, 1, - printed_lines, bun_sourcemap::mapping::ParseOptions { allow_names: false, sort: true, diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 01a5ab7d78d8..8ce05d725e7c 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -380,8 +380,6 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { pub line_starts_with_mapping: bool, pub cover_lines_without_mappings: bool, - pub approximate_input_line_count: usize, - /// When generating sourcemappings for bun, we store a count of how many mappings there were pub prepend_count: bool, } @@ -402,7 +400,6 @@ impl Default for NewBuilder<'_, T> { line_offset_table_first_non_ascii: RawSlice::EMPTY, line_starts_with_mapping: false, cover_lines_without_mappings: false, - approximate_input_line_count: 0, prepend_count: false, } } @@ -463,12 +460,10 @@ impl NewBuilder<'_, VLQSourceMap> { // `get_buffer`, to satisfy the borrow checker. if self.prepend_count { let count = self.source_map.get_count(); - let approx = self.approximate_input_line_count; let buffer = self.source_map.get_buffer(); let len = buffer.list.len(); buffer.list[0..8].copy_from_slice(&(len as u64).to_ne_bytes()); buffer.list[8..16].copy_from_slice(&(count as u64).to_ne_bytes()); - buffer.list[16..24].copy_from_slice(&(approx as u64).to_ne_bytes()); } else { // Finalize the internal builder eagerly; `take_buffer()` below // also finalizes, so the effect is preserved. diff --git a/src/sourcemap/InternalSourceMap.rs b/src/sourcemap/InternalSourceMap.rs index 0daa7bc507ed..bd1ebf8f605c 100644 --- a/src/sourcemap/InternalSourceMap.rs +++ b/src/sourcemap/InternalSourceMap.rs @@ -63,10 +63,9 @@ //! ```text //! [ 0.. 8] total_len: u64 -- written by Chunk.Builder.generateChunk //! [ 8..16] mapping_count: u64 -- written by Chunk.Builder.generateChunk -//! [16..24] input_line_count: u64 -- written by Chunk.Builder.generateChunk -//! [24..28] sync_count: u32 -//! [28..32] stream_offset: u32 -- byte offset from blob start to stream -//! [32.. ] SyncEntry[sync_count] -- 24 bytes each +//! [16..20] sync_count: u32 +//! [20..24] stream_offset: u32 -- byte offset from blob start to stream +//! [24.. ] SyncEntry[sync_count] -- 24 bytes each //! [stream_offset..total_len-stream_tail_pad] Window[sync_count] //! [total_len-stream_tail_pad..total_len] zero bytes (read-past pad) //! ``` @@ -106,7 +105,7 @@ use crate::{LineColumnOffset, Mapping, SourceMapState, append_mapping_to_buffer} /// A sync entry is emitted every `SYNC_INTERVAL` mappings. pub(crate) const SYNC_INTERVAL: usize = 64; -pub const HEADER_SIZE: usize = 32; +pub const HEADER_SIZE: usize = 24; /// `read_varint`'s 1-byte fast path reads `bytes[pos]` unconditionally; the /// exception cursors in `WindowReader` advance to one byte past their last @@ -166,22 +165,16 @@ impl InternalSourceMap { unsafe { u64::from_ne_bytes(*self.data.add(8).cast::<[u8; 8]>()) as usize } } - #[inline] - pub(crate) fn input_line_count(self) -> usize { - // SAFETY: blob is at least HEADER_SIZE bytes. - unsafe { u64::from_ne_bytes(*self.data.add(16).cast::<[u8; 8]>()) as usize } - } - #[inline] pub(crate) fn sync_count(self) -> u32 { // SAFETY: blob is at least HEADER_SIZE bytes. - unsafe { u32::from_ne_bytes(*self.data.add(24).cast::<[u8; 4]>()) } + unsafe { u32::from_ne_bytes(*self.data.add(16).cast::<[u8; 4]>()) } } #[inline] pub(crate) fn stream_offset(self) -> u32 { // SAFETY: blob is at least HEADER_SIZE bytes. - unsafe { u32::from_ne_bytes(*self.data.add(28).cast::<[u8; 4]>()) } + unsafe { u32::from_ne_bytes(*self.data.add(20).cast::<[u8; 4]>()) } } pub(crate) fn sync_entry(self, index: usize) -> SyncEntry { @@ -242,8 +235,8 @@ impl InternalSourceMap { /// /// Associated-fn alias so callers can write `InternalSourceMap::from_vlq(..)`. #[inline] - pub fn from_vlq(vlq: &[u8], input_line_count_hint: u32) -> Result, FromVlqError> { - from_vlq(vlq, input_line_count_hint) + pub fn from_vlq(vlq: &[u8]) -> Result, FromVlqError> { + from_vlq(vlq) } } @@ -1006,9 +999,9 @@ impl Builder { self.pending_n = 0; } - /// Serialize into the single-allocation blob layout. The first 24 header + /// Serialize into the single-allocation blob layout. The first 16 header /// bytes are left for `Chunk.Builder.generateChunk` to fill in (length, - /// count, input line count) so this path flows through the existing + /// count) so this path flows through the existing /// `Chunk.buffer` plumbing unchanged. pub(crate) fn finalize(&mut self) -> &mut MutableString { if self.finalized.is_none() { @@ -1019,22 +1012,22 @@ impl Builder { let total: usize = stream_offset as usize + self.win_stream.len() + STREAM_TAIL_PAD; let mut out = MutableString::init_empty(); - // Every byte in [0..total) is written below: [0..24] zero-filled, - // [24..32] header u32s, [32..32+sync_bytes] sync table memcpy, + // Every byte in [0..total) is written below: [0..16] zero-filled, + // [16..24] header u32s, [24..24+sync_bytes] sync table memcpy, // [stream_offset..stream_offset+win_stream.len()] stream memcpy, // [total-STREAM_TAIL_PAD..total] zero-filled. These ranges are - // contiguous (HEADER_SIZE==32, stream_offset==32+sync_bytes), so + // contiguous (HEADER_SIZE==24, stream_offset==24+sync_bytes), so // no uninit bytes are exposed by set_len. // SAFETY: every byte in [0..total) is written below (see comment above). let blob = unsafe { out.list.writable_slice_exact(total) }; - blob[0..24].fill(0); - blob[24..28].copy_from_slice( + blob[0..16].fill(0); + blob[16..20].copy_from_slice( &u32::try_from(self.sync_entries.len()) .expect("int cast") .to_ne_bytes(), ); - blob[28..32].copy_from_slice(&stream_offset.to_ne_bytes()); + blob[20..24].copy_from_slice(&stream_offset.to_ne_bytes()); if sync_bytes > 0 { // SAFETY: SyncEntry is #[repr(C)] POD with no padding (size==24, // 6×4-byte fields); reinterpreting as bytes is sound. @@ -1075,7 +1068,7 @@ pub enum FromVlqError { /// 1-field segments are skipped (no original location). The 5th field /// (`name_index`) is decoded but discarded; nothing in the stack-trace remap /// path reads it. -pub fn from_vlq(vlq: &[u8], input_line_count_hint: u32) -> Result, FromVlqError> { +pub fn from_vlq(vlq: &[u8]) -> Result, FromVlqError> { /// Accumulate a VLQ delta into an absolute value. Like `Mapping::parse`, /// reject streams whose absolute value overflows `i32` or goes negative — /// both make the mapping meaningless, and unchecked garbage here would @@ -1094,7 +1087,6 @@ pub fn from_vlq(vlq: &[u8], input_line_count_hint: u32) -> Result, Fro let mut source_index: i32 = 0; let mut original_line: i32 = 0; let mut original_column: i32 = 0; - let mut max_original_line: i32 = 0; let mut remain = vlq; while !remain.is_empty() { @@ -1155,7 +1147,6 @@ pub fn from_vlq(vlq: &[u8], input_line_count_hint: u32) -> Result, Fro remain = &remain[1..]; } - max_original_line = max_original_line.max(original_line); builder.append_mapping(&SourceMapState { generated_column, source_index, @@ -1171,11 +1162,8 @@ pub fn from_vlq(vlq: &[u8], input_line_count_hint: u32) -> Result, Fro let out = builder.finalize(); let blob = out.list.as_mut_slice(); let total_len: u64 = blob.len() as u64; - let input_lines: u64 = - (input_line_count_hint as u64).max(u64::try_from(max_original_line).expect("int cast") + 1); blob[0..8].copy_from_slice(&total_len.to_ne_bytes()); blob[8..16].copy_from_slice(&mapping_count.to_ne_bytes()); - blob[16..24].copy_from_slice(&input_lines.to_ne_bytes()); let owned = core::mem::take(&mut out.list).into_boxed_slice(); builder.finalized = None; diff --git a/src/sourcemap/Mapping.rs b/src/sourcemap/Mapping.rs index e2ba5b4e344a..d16f464ecdb5 100644 --- a/src/sourcemap/Mapping.rs +++ b/src/sourcemap/Mapping.rs @@ -439,7 +439,6 @@ pub fn parse( bytes: &[u8], estimated_mapping_count: Option, sources_count: i32, - input_line_count: usize, options: ParseOptions, ) -> ParseResult { scoped_log!(SourceMap, "parse mappings ({} bytes)", bytes.len()); @@ -725,7 +724,6 @@ pub fn parse( let mut psm = ParsedSourceMap::default(); psm.mappings = mapping; - psm.input_line_count = input_line_count; Ok(psm) } diff --git a/src/sourcemap/ParsedSourceMap.rs b/src/sourcemap/ParsedSourceMap.rs index 3cc9be8dfae1..7e1454e7751f 100644 --- a/src/sourcemap/ParsedSourceMap.rs +++ b/src/sourcemap/ParsedSourceMap.rs @@ -13,7 +13,6 @@ use crate::{ /// ParsedSourceMap can be acquired by different threads via the thread-safe /// source map store (SavedSourceMap), so the reference count must be thread-safe. pub struct ParsedSourceMap { - pub input_line_count: usize, pub mappings: mapping::List, /// Set when this map's mappings are backed by an InternalSourceMap blob /// instead of a materialized `Mapping.List`. The blob is *owned* (freed in @@ -63,7 +62,6 @@ impl Drop for ParsedSourceMap { impl Default for ParsedSourceMap { fn default() -> Self { Self { - input_line_count: 0, mappings: mapping::List::default(), internal: None, external_source_names: Vec::new(), @@ -227,7 +225,6 @@ impl ParsedSourceMap { /// [`Self::is_standalone_module_graph`]. pub fn from_internal(internal: InternalSourceMap) -> Self { Self { - input_line_count: internal.input_line_count(), mappings: mapping::List::default(), internal: Some(internal), external_source_names: Vec::new(), diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index ed0ae364c0d6..b81937ac4ce3 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -946,7 +946,6 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu mappings_vlq, None, i32::MAX, - i32::MAX as usize, mapping::ParseOptions { allow_names: matches!( hint, diff --git a/src/sourcemap_jsc/JSSourceMap.rs b/src/sourcemap_jsc/JSSourceMap.rs index dc44ed2d983c..1afa97754184 100644 --- a/src/sourcemap_jsc/JSSourceMap.rs +++ b/src/sourcemap_jsc/JSSourceMap.rs @@ -149,7 +149,6 @@ impl JSSourceMap { mappings_str.slice(), None, // estimated_mapping_count i32::try_from(sources.len()).expect("int cast"), // sources_count - i32::MAX as usize, mapping::ParseOptions { allow_names: true, sort: true, diff --git a/src/sourcemap_jsc/internal_jsc.rs b/src/sourcemap_jsc/internal_jsc.rs index de9b14bc9100..696acddfb34e 100644 --- a/src/sourcemap_jsc/internal_jsc.rs +++ b/src/sourcemap_jsc/internal_jsc.rs @@ -12,7 +12,7 @@ impl TestingAPIs { let vlq_str = frame.argument(0).to_bun_string(global)?; let vlq = vlq_str.to_utf8(); - let Ok(blob) = internal_source_map::from_vlq(vlq.slice(), 0) else { + let Ok(blob) = internal_source_map::from_vlq(vlq.slice()) else { return Err(global.throw(format_args!("InternalSourceMap.fromVLQ: invalid VLQ input"))); }; ArrayBuffer::create_uint8_array(global, &blob) diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index c8eca72a59b8..f08cb4171f42 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -839,8 +839,6 @@ impl LazySourceMap { let ism = SourceMap::InternalSourceMap { data: blob.as_ptr(), }; - // Note: `from_internal` fills `internal = Some(ism)` + - // `input_line_count = ism.input_line_count()` and defaults the rest. let mut stored = SourceMap::ParsedSourceMap::from_internal(ism); let source_files_count = serialized.source_files_count(); @@ -3476,7 +3474,7 @@ pub(crate) fn serialize_json_source_map_for_standalone( return Err(crate::Error::InvalidSourceMap); } - let map_blob = SourceMap::InternalSourceMap::from_vlq(map_vlq, 0) + let map_blob = SourceMap::InternalSourceMap::from_vlq(map_vlq) .map_err(|_| crate::Error::InvalidSourceMap)?; // Every offset/length in the serialized map is a u32 `StringPointer`; diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index d3548a6c17f2..3a8b5b7b23ef 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -629,8 +629,8 @@ test("rejects a cached entry whose sourcemap section header is corrupt", () => { // then twelve u64 fields; sourcemap_byte_offset @ 54, // sourcemap_byte_length @ 62, sourcemap_hash @ 70. // InternalSourceMap header (src/sourcemap/InternalSourceMap.rs): - // 0: total_len u64, 8: mapping_count u64, 16: input_line_count u64, - // 24: sync_count u32, 28: stream_offset u32. + // 0: total_len u64, 8: mapping_count u64, 16: sync_count u32, + // 20: stream_offset u32. const SOURCEMAP_BYTE_OFFSET_AT = 54; const SOURCEMAP_BYTE_LENGTH_AT = 62; @@ -639,10 +639,10 @@ test("rejects a cached entry whose sourcemap section header is corrupt", () => { if (data.length < 102) return false; const smOff = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_OFFSET_AT)); const smLen = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_LENGTH_AT)); - if (smLen < 32 || smOff + smLen > data.length) return false; + if (smLen < 24 || smOff + smLen > data.length) return false; // Inflate sync_count so the SyncEntry array claims far more entries than // the section holds. - data.writeUInt32LE(0x0fffffff, smOff + 24); + data.writeUInt32LE(0x0fffffff, smOff + 16); writeFileSync(file, data); return true; } diff --git a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts index ba26e5112af1..c95ae63c11a6 100644 --- a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts +++ b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts @@ -269,17 +269,17 @@ describe("InternalSourceMap.toVLQ", () => { `const { internalSourceMap } = require("bun:internal-for-testing"); // Blob layout (see src/sourcemap/InternalSourceMap.rs): // [0..8] total_len u64, [8..16] mapping_count u64, - // [16..24] input_line_count u64, [24..28] sync_count u32, - // [28..32] stream_offset u32, [32..56] SyncEntry, [56..88] window - // header (count=1, no deltas), [88] stream tail pad. - const blob = new Uint8Array(89); + // [16..20] sync_count u32, [20..24] stream_offset u32, + // [24..48] SyncEntry, [48..80] window header (count=1, no deltas), + // [80] stream tail pad. + const blob = new Uint8Array(81); const dv = new DataView(blob.buffer); - dv.setBigUint64(0, 89n, true); // total_len + dv.setBigUint64(0, 81n, true); // total_len dv.setBigUint64(8, 1n, true); // mapping_count - dv.setUint32(24, 1, true); // sync_count - dv.setUint32(28, 56, true); // stream_offset - dv.setInt32(36, -2147483648, true); // SyncEntry.generated_column - blob[56] = 1; // window mapping count + dv.setUint32(16, 1, true); // sync_count + dv.setUint32(20, 48, true); // stream_offset + dv.setInt32(28, -2147483648, true); // SyncEntry.generated_column + blob[48] = 1; // window mapping count console.log("TOVLQ: " + internalSourceMap.toVLQ(blob));`, ], env: bunEnv, From 62a01b425eba5e57ee3a3b3e63f8c97f009138a5 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 02:18:16 +0000 Subject: [PATCH 13/20] Use the WebKit preview that leaves the end offset of a label to parseStatement() --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index aaca6b30f9c0..4559b1e67b7b 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-2e56a0b1"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-f59bbe07"; /** * WebKit (JavaScriptCore) — the JS engine. From 6c5273d8e89c612e50a81d1a26c88c30194d96a1 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 04:33:46 +0000 Subject: [PATCH 14/20] Test what is after a function in a block that did not run --- test/js/bun/sourcemap/source-positions-corpus.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index 4ea89e4a770d..e297befcb950 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -49,7 +49,7 @@ export const cases: Record = { `, }, "branches.ts": { - functions: "3/3", + functions: "4/5", lines: ` 2|export function f(x: boolean) { 2| if (x) { @@ -77,9 +77,18 @@ export const cases: Record = { 1| f(true); -| } -|} + 1|export function k(x: boolean) { + 1| if (x) { + 0| const inner = () => true; + 0| f(inner()); + 0| f(true); + -| } + 1| return x; + -|} 1|f(true); 1|g(2); 1|h(); + 1|k(false); `, }, "else.ts": { From 1251787036fb7613528aa6acf24ceb1119100032 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 04:33:46 +0000 Subject: [PATCH 15/20] Use the WebKit preview where what is after a statement goes up to the end of its list --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 4559b1e67b7b..a35c2915afe7 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-f59bbe07"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-984d4a24"; /** * WebKit (JavaScriptCore) — the JS engine. From 21dfdb9a9aba964ee451b7d447310ca7464e9b46 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 05:30:58 +0000 Subject: [PATCH 16/20] Take the mappings of a SourceMap with get_stringish_allow_empty(), so that a symbol has the error it had --- src/jsc/JSValue.rs | 15 ++++++++++++--- src/sourcemap_jsc/JSSourceMap.rs | 5 +---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index f02648e22214..2a14e8767a4e 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -1193,10 +1193,20 @@ impl JSValue { self, global: &JSGlobalObject, property: impl AsRef<[u8]>, + ) -> JsResult> { + Ok(self + .get_stringish_allow_empty(global, property)? + .filter(|s| !s.is_empty())) + } + /// `get_stringish`, for a property that "" is a value of. + pub fn get_stringish_allow_empty( + self, + global: &JSGlobalObject, + property: impl AsRef<[u8]>, ) -> JsResult> { let property = property.as_ref(); // `get(prop)`, filter null/false → None, - // reject symbols, otherwise coerce via `toBunString` and filter "" → None. + // reject symbols, otherwise coerce via `toBunString`. let Some(prop) = self.get(global, property)? else { return Ok(None); }; @@ -1213,8 +1223,7 @@ impl JSValue { bstr::BStr::new(property), ))); } - let s = prop.to_bun_string(global)?; - if s.is_empty() { Ok(None) } else { Ok(Some(s)) } + Ok(Some(prop.to_bun_string(global)?)) } /// Own/prototype lookup, /// `null`/`undefined` → `None`, non-string → `ERR_INVALID_ARG_TYPE`, diff --git a/src/sourcemap_jsc/JSSourceMap.rs b/src/sourcemap_jsc/JSSourceMap.rs index 1afa97754184..04a973c0fe49 100644 --- a/src/sourcemap_jsc/JSSourceMap.rs +++ b/src/sourcemap_jsc/JSSourceMap.rs @@ -113,16 +113,13 @@ impl JSSourceMap { // Parse the payload to create a proper sourcemap // Extract mappings string from payload - let Some(mappings_value) = payload_arg - .get(global, b"mappings")? - .filter(|mappings| !mappings.is_null() && *mappings != JSValue::FALSE) + let Some(mappings_value) = payload_arg.get_stringish_allow_empty(global, b"mappings")? else { return Err( global.throw_invalid_arguments(format_args!("payload 'mappings' must be a string")) ); }; - let mappings_value = mappings_value.to_bun_string(global)?; let mappings_str = mappings_value.to_utf8(); let mut names: Vec = Vec::new(); From 1115ddc179c5dbb15ebb789c3b10bc4fe51bad5a Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 05:58:37 +0000 Subject: [PATCH 17/20] Keep the layout of the header of InternalSourceMap, which the Bun of another version reads in a compiled executable --- src/jsc/RuntimeTranspilerCache.rs | 4 +-- src/sourcemap/InternalSourceMap.rs | 25 ++++++++++--------- test/cli/run/transpiler-cache.test.ts | 8 +++--- .../internal-sourcemap-roundtrip.test.ts | 18 ++++++------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 2c5640da5680..8b9460b7d3bc 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -65,8 +65,8 @@ bun_core::declare_scope!(cache, visible); /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. /// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. -/// Version 36: InternalSourceMap has no input line count, and `export`, `require()`, `import()`, -/// a JSX element, an exported async function and an abstract class are mapped elsewhere. +/// Version 36: `export`, `require()`, `import()`, a JSX element, an exported async function and +/// an abstract class are mapped elsewhere. const EXPECTED_VERSION: u32 = 36; /// Source files smaller than this are not written to / read from the on-disk diff --git a/src/sourcemap/InternalSourceMap.rs b/src/sourcemap/InternalSourceMap.rs index bd1ebf8f605c..06be5175c69f 100644 --- a/src/sourcemap/InternalSourceMap.rs +++ b/src/sourcemap/InternalSourceMap.rs @@ -63,9 +63,10 @@ //! ```text //! [ 0.. 8] total_len: u64 -- written by Chunk.Builder.generateChunk //! [ 8..16] mapping_count: u64 -- written by Chunk.Builder.generateChunk -//! [16..20] sync_count: u32 -//! [20..24] stream_offset: u32 -- byte offset from blob start to stream -//! [24.. ] SyncEntry[sync_count] -- 24 bytes each +//! [16..24] unused -- `--compile` embeds a blob for the Bun of another version to read +//! [24..28] sync_count: u32 +//! [28..32] stream_offset: u32 -- byte offset from blob start to stream +//! [32.. ] SyncEntry[sync_count] -- 24 bytes each //! [stream_offset..total_len-stream_tail_pad] Window[sync_count] //! [total_len-stream_tail_pad..total_len] zero bytes (read-past pad) //! ``` @@ -105,7 +106,7 @@ use crate::{LineColumnOffset, Mapping, SourceMapState, append_mapping_to_buffer} /// A sync entry is emitted every `SYNC_INTERVAL` mappings. pub(crate) const SYNC_INTERVAL: usize = 64; -pub const HEADER_SIZE: usize = 24; +pub const HEADER_SIZE: usize = 32; /// `read_varint`'s 1-byte fast path reads `bytes[pos]` unconditionally; the /// exception cursors in `WindowReader` advance to one byte past their last @@ -168,13 +169,13 @@ impl InternalSourceMap { #[inline] pub(crate) fn sync_count(self) -> u32 { // SAFETY: blob is at least HEADER_SIZE bytes. - unsafe { u32::from_ne_bytes(*self.data.add(16).cast::<[u8; 4]>()) } + unsafe { u32::from_ne_bytes(*self.data.add(24).cast::<[u8; 4]>()) } } #[inline] pub(crate) fn stream_offset(self) -> u32 { // SAFETY: blob is at least HEADER_SIZE bytes. - unsafe { u32::from_ne_bytes(*self.data.add(20).cast::<[u8; 4]>()) } + unsafe { u32::from_ne_bytes(*self.data.add(28).cast::<[u8; 4]>()) } } pub(crate) fn sync_entry(self, index: usize) -> SyncEntry { @@ -1012,22 +1013,22 @@ impl Builder { let total: usize = stream_offset as usize + self.win_stream.len() + STREAM_TAIL_PAD; let mut out = MutableString::init_empty(); - // Every byte in [0..total) is written below: [0..16] zero-filled, - // [16..24] header u32s, [24..24+sync_bytes] sync table memcpy, + // Every byte in [0..total) is written below: [0..24] zero-filled, + // [24..32] header u32s, [32..32+sync_bytes] sync table memcpy, // [stream_offset..stream_offset+win_stream.len()] stream memcpy, // [total-STREAM_TAIL_PAD..total] zero-filled. These ranges are - // contiguous (HEADER_SIZE==24, stream_offset==24+sync_bytes), so + // contiguous (HEADER_SIZE==32, stream_offset==32+sync_bytes), so // no uninit bytes are exposed by set_len. // SAFETY: every byte in [0..total) is written below (see comment above). let blob = unsafe { out.list.writable_slice_exact(total) }; - blob[0..16].fill(0); - blob[16..20].copy_from_slice( + blob[0..24].fill(0); + blob[24..28].copy_from_slice( &u32::try_from(self.sync_entries.len()) .expect("int cast") .to_ne_bytes(), ); - blob[20..24].copy_from_slice(&stream_offset.to_ne_bytes()); + blob[28..32].copy_from_slice(&stream_offset.to_ne_bytes()); if sync_bytes > 0 { // SAFETY: SyncEntry is #[repr(C)] POD with no padding (size==24, // 6×4-byte fields); reinterpreting as bytes is sound. diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 3a8b5b7b23ef..28beef1cb416 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -629,8 +629,8 @@ test("rejects a cached entry whose sourcemap section header is corrupt", () => { // then twelve u64 fields; sourcemap_byte_offset @ 54, // sourcemap_byte_length @ 62, sourcemap_hash @ 70. // InternalSourceMap header (src/sourcemap/InternalSourceMap.rs): - // 0: total_len u64, 8: mapping_count u64, 16: sync_count u32, - // 20: stream_offset u32. + // 0: total_len u64, 8: mapping_count u64, 16: unused, + // 24: sync_count u32, 28: stream_offset u32. const SOURCEMAP_BYTE_OFFSET_AT = 54; const SOURCEMAP_BYTE_LENGTH_AT = 62; @@ -639,10 +639,10 @@ test("rejects a cached entry whose sourcemap section header is corrupt", () => { if (data.length < 102) return false; const smOff = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_OFFSET_AT)); const smLen = Number(data.readBigUInt64LE(SOURCEMAP_BYTE_LENGTH_AT)); - if (smLen < 24 || smOff + smLen > data.length) return false; + if (smLen < 32 || smOff + smLen > data.length) return false; // Inflate sync_count so the SyncEntry array claims far more entries than // the section holds. - data.writeUInt32LE(0x0fffffff, smOff + 16); + data.writeUInt32LE(0x0fffffff, smOff + 24); writeFileSync(file, data); return true; } diff --git a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts index c95ae63c11a6..3eecfb491cd1 100644 --- a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts +++ b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts @@ -269,17 +269,17 @@ describe("InternalSourceMap.toVLQ", () => { `const { internalSourceMap } = require("bun:internal-for-testing"); // Blob layout (see src/sourcemap/InternalSourceMap.rs): // [0..8] total_len u64, [8..16] mapping_count u64, - // [16..20] sync_count u32, [20..24] stream_offset u32, - // [24..48] SyncEntry, [48..80] window header (count=1, no deltas), - // [80] stream tail pad. - const blob = new Uint8Array(81); + // [16..24] unused, [24..28] sync_count u32, + // [28..32] stream_offset u32, [32..56] SyncEntry, [56..88] window + // header (count=1, no deltas), [88] stream tail pad. + const blob = new Uint8Array(89); const dv = new DataView(blob.buffer); - dv.setBigUint64(0, 81n, true); // total_len + dv.setBigUint64(0, 89n, true); // total_len dv.setBigUint64(8, 1n, true); // mapping_count - dv.setUint32(16, 1, true); // sync_count - dv.setUint32(20, 48, true); // stream_offset - dv.setInt32(28, -2147483648, true); // SyncEntry.generated_column - blob[48] = 1; // window mapping count + dv.setUint32(24, 1, true); // sync_count + dv.setUint32(28, 56, true); // stream_offset + dv.setInt32(36, -2147483648, true); // SyncEntry.generated_column + blob[56] = 1; // window mapping count console.log("TOVLQ: " + internalSourceMap.toVLQ(blob));`, ], env: bunEnv, From 250cbf280c3d78d32edf6020a80093b1c22250f9 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sun, 18 Oct 2026 05:58:37 +0000 Subject: [PATCH 18/20] Use the WebKit preview where the end offset of a block is after its brace --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index a35c2915afe7..c1508c504351 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-984d4a24"; +export const WEBKIT_VERSION = "autobuild-preview-pr-828-0d1cda97"; /** * WebKit (JavaScriptCore) — the JS engine. From 39d2ee91c6dea9a6926dccd0d845ef5af8f8b2ef Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 20 Oct 2026 01:25:13 +0000 Subject: [PATCH 19/20] Use the WebKit where the basic blocks of the control flow profiler match the text --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index c1508c504351..37dbd9913885 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "autobuild-preview-pr-828-0d1cda97"; +export const WEBKIT_VERSION = "274ebb3cf6008f627197d818e3939d1e2bfc514d"; /** * WebKit (JavaScriptCore) — the JS engine. From b61515754059e7ee7ebde676f33df469a5f6f856 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 20 Oct 2026 01:25:13 +0000 Subject: [PATCH 20/20] Test a do while that is the body of an if and a file with nothing to run --- .../bun/sourcemap/source-positions-corpus.ts | 19 +++++++++++++++++++ .../js/bun/sourcemap/source-positions.test.ts | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/test/js/bun/sourcemap/source-positions-corpus.ts b/test/js/bun/sourcemap/source-positions-corpus.ts index e297befcb950..22b5e6e91ae3 100644 --- a/test/js/bun/sourcemap/source-positions-corpus.ts +++ b/test/js/bun/sourcemap/source-positions-corpus.ts @@ -31,6 +31,17 @@ export const cases: Record = { 0| return b; -|} 1|called(); +`, + }, + // A file with nothing to run is listed. + "only-types.ts": { + functions: "0/0", + lines: ` + -|// a comment + -|export type T = number; + -|export interface I { + -| a: T; + -|} `, }, // How often, not how many characters. @@ -263,6 +274,14 @@ export const cases: Record = { 1|do { 3| n++; 3|} while (n < 3); + 1|if (n === 0) + 0| do { + 0| n++; + 0| } while (n < 3); + 1|if (n === 3) + 1| do { + 2| n++; + 2| } while (n < 5); 1|out: { 1| if (n) 1| break out; diff --git a/test/js/bun/sourcemap/source-positions.test.ts b/test/js/bun/sourcemap/source-positions.test.ts index 5490abf4a358..a2200119669e 100644 --- a/test/js/bun/sourcemap/source-positions.test.ts +++ b/test/js/bun/sourcemap/source-positions.test.ts @@ -364,7 +364,10 @@ describe("bun test --coverage", () => { const [hit, found] = functions.split("/").map(Number); const want = [ (found ? (hit / found) * 100 : 100).toFixed(2), - ((executable.filter(count => count !== " 0").length / executable.length) * 100).toFixed(2), + (executable.length + ? (executable.filter(count => count !== " 0").length / executable.length) * 100 + : 100 + ).toFixed(2), ranges.join(","), ]; const got = row