From c84e4afacc67b3f814214fa558be090503320e4a Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 13 Mar 2026 13:12:17 +0100 Subject: [PATCH 1/4] test: document webpack source map behaviour for column=0 lookups (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for SourceMapper.mappingInfo with a synthetic webpack-style single-line bundle to document the known limitation introduced by #248. Background ---------- PR #81 changed originalPositionFor to always try LEAST_UPPER_BOUND first (then fall back to GREATEST_LOWER_BOUND). This was reverted in #106 because it broke webpack source maps: for real non-zero columns LEAST_UPPER_BOUND finds the *next* mapping (≥ column) rather than the one at the column, returning wrong function names. PR #248 fixed that regression by using LEAST_UPPER_BOUND only when column === 0, and GREATEST_LOWER_BOUND otherwise. This correctly handles Node.js ≥ 25 where V8's LineTick struct carries real column numbers. Residual limitation (Node.js < 25) ----------------------------------- On Node.js < 25, the LineTick struct has no column field. The C++ layer therefore always emits column=0 for every LineTick sample. With column=0, the sourcemapper uses LEAST_UPPER_BOUND, which finds the *first* mapping on the line. In a webpack bundle (all output on one line) every function maps to the same first source function in the map. This is not a regression vs. the pre-#248 state: before #248, those functions were simply unmapped (column=0 + GREATEST_LOWER_BOUND → nothing ≤ 0 → null → falls back to generated name/file). Both outcomes are imperfect; #248 trades "unmapped" for "mapped to first function", which may or may not be preferable depending on the use case. The two new tests pin both behaviours explicitly so any future change to this logic is immediately visible. Co-Authored-By: Claude Sonnet 4.6 --- ts/test/test-sourcemapper.ts | 103 +++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 350df364..487c279a 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -17,6 +17,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import * as tmp from 'tmp'; +import * as sourceMap from 'source-map'; import { ANNOTATION_TAIL_BYTES, @@ -272,3 +273,105 @@ describe('SourceMapper.loadDirectory', () => { ); }); }); + +// Regression test for the webpack source map issue originally surfaced in #81 +// and relevant to #248. +// +// Webpack minifies output into a single line, placing multiple functions at +// different columns. On Node.js < 25, V8's LineTick struct has no column +// field, so the C++ layer always emits column=0 for every sample. The +// sourcemapper's LEAST_UPPER_BOUND path (triggered when column===0) then finds +// the first mapping on the line for every lookup — attributing all functions in +// the bundle to whichever source function appears first. +// +// On Node.js >= 25, V8 fills in real column numbers, so each function is +// looked up with GREATEST_LOWER_BOUND and resolves correctly. +// +// This test documents both behaviours so a regression would be immediately +// visible. +describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => { + const BUNDLE_PATH = '/app/dist/bundle.js'; + const MAP_DIR = '/app/dist'; + + // Build a source map that places three functions on line 1 of bundle.js at + // columns 10, 30 and 50, each originating from a different source file. + async function buildMapper(): Promise { + const gen = new sourceMap.SourceMapGenerator({file: 'bundle.js'}); + + // funcA — bundle.js line 1, col 10 → a.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 10}, + source: 'a.ts', + original: {line: 1, column: 0}, + name: 'funcA', + }); + // funcB — bundle.js line 1, col 30 → b.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 30}, + source: 'b.ts', + original: {line: 1, column: 0}, + name: 'funcB', + }); + // funcC — bundle.js line 1, col 50 → c.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 50}, + source: 'c.ts', + original: {line: 1, column: 0}, + name: 'funcC', + }); + + const consumer = (await new sourceMap.SourceMapConsumer( + gen.toJSON() as unknown as sourceMap.RawSourceMap, + )) as unknown as sourceMap.RawSourceMap; + + const mapper = new SourceMapper(); + mapper.infoMap.set(BUNDLE_PATH, {mapFileDir: MAP_DIR, mapConsumer: consumer}); + return mapper; + } + + // Helper: look up a location in bundle.js. + function lookup( + mapper: SourceMapper, + line: number, + column: number, + ) { + return mapper.mappingInfo({file: BUNDLE_PATH, line, column, name: 'unknown'}); + } + + it('resolves functions correctly when real column numbers are available (Node.js ≥ 25 behaviour)', async () => { + // When V8 supplies real 1-based columns (11, 31, 51 for cols 10, 30, 50) + // GREATEST_LOWER_BOUND is used and each function maps to its own source. + const mapper = await buildMapper(); + + const a = lookup(mapper, 1, 11); // col 11 → adjusted 10 → funcA + assert.strictEqual(a.name, 'funcA', 'funcA column'); + assert.ok(a.file!.endsWith('a.ts'), `funcA file: ${a.file}`); + + const b = lookup(mapper, 1, 31); // col 31 → adjusted 30 → funcB + assert.strictEqual(b.name, 'funcB', 'funcB column'); + assert.ok(b.file!.endsWith('b.ts'), `funcB file: ${b.file}`); + + const c = lookup(mapper, 1, 51); // col 51 → adjusted 50 → funcC + assert.strictEqual(c.name, 'funcC', 'funcC column'); + assert.ok(c.file!.endsWith('c.ts'), `funcC file: ${c.file}`); + }); + + it('resolves column=0 to the first mapping on the line (Node.js < 25 behaviour — known limitation)', async () => { + // On Node.js < 25, LineTick has no column field so the C++ layer emits + // column=0 for every sample. LEAST_UPPER_BOUND is therefore used and all + // three functions resolve to the *first* mapping on the line (funcA at + // column 10). This is a known limitation: distinct functions in a + // webpack bundle cannot be differentiated on pre-25 Node.js. + const mapper = await buildMapper(); + + // All three functions are reported with column=0 (no real column info). + const a = lookup(mapper, 1, 0); + const b = lookup(mapper, 1, 0); + const c = lookup(mapper, 1, 0); + + // They all resolve to the first mapped function on the line. + assert.strictEqual(a.name, 'funcA', 'funcA with column=0'); + assert.strictEqual(b.name, 'funcA', 'funcB with column=0 maps to funcA — known limitation'); + assert.strictEqual(c.name, 'funcA', 'funcC with column=0 maps to funcA — known limitation'); + }); +}); From 25ef7db69b832900b9ade82b076735f4c488964e Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 13 Mar 2026 13:15:14 +0100 Subject: [PATCH 2/4] fix: apply prettier formatting to test-sourcemapper.ts --- ts/test/test-sourcemapper.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 487c279a..3b505f06 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -325,17 +325,21 @@ describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => )) as unknown as sourceMap.RawSourceMap; const mapper = new SourceMapper(); - mapper.infoMap.set(BUNDLE_PATH, {mapFileDir: MAP_DIR, mapConsumer: consumer}); + mapper.infoMap.set(BUNDLE_PATH, { + mapFileDir: MAP_DIR, + mapConsumer: consumer, + }); return mapper; } // Helper: look up a location in bundle.js. - function lookup( - mapper: SourceMapper, - line: number, - column: number, - ) { - return mapper.mappingInfo({file: BUNDLE_PATH, line, column, name: 'unknown'}); + function lookup(mapper: SourceMapper, line: number, column: number) { + return mapper.mappingInfo({ + file: BUNDLE_PATH, + line, + column, + name: 'unknown', + }); } it('resolves functions correctly when real column numbers are available (Node.js ≥ 25 behaviour)', async () => { @@ -343,15 +347,15 @@ describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => // GREATEST_LOWER_BOUND is used and each function maps to its own source. const mapper = await buildMapper(); - const a = lookup(mapper, 1, 11); // col 11 → adjusted 10 → funcA + const a = lookup(mapper, 1, 11); // col 11 → adjusted 10 → funcA assert.strictEqual(a.name, 'funcA', 'funcA column'); assert.ok(a.file!.endsWith('a.ts'), `funcA file: ${a.file}`); - const b = lookup(mapper, 1, 31); // col 31 → adjusted 30 → funcB + const b = lookup(mapper, 1, 31); // col 31 → adjusted 30 → funcB assert.strictEqual(b.name, 'funcB', 'funcB column'); assert.ok(b.file!.endsWith('b.ts'), `funcB file: ${b.file}`); - const c = lookup(mapper, 1, 51); // col 51 → adjusted 50 → funcC + const c = lookup(mapper, 1, 51); // col 51 → adjusted 50 → funcC assert.strictEqual(c.name, 'funcC', 'funcC column'); assert.ok(c.file!.endsWith('c.ts'), `funcC file: ${c.file}`); }); @@ -371,7 +375,15 @@ describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => // They all resolve to the first mapped function on the line. assert.strictEqual(a.name, 'funcA', 'funcA with column=0'); - assert.strictEqual(b.name, 'funcA', 'funcB with column=0 maps to funcA — known limitation'); - assert.strictEqual(c.name, 'funcA', 'funcC with column=0 maps to funcA — known limitation'); + assert.strictEqual( + b.name, + 'funcA', + 'funcB with column=0 maps to funcA — known limitation', + ); + assert.strictEqual( + c.name, + 'funcA', + 'funcC with column=0 maps to funcA — known limitation', + ); }); }); From c540f975b7bc482332008f5ba1797de3700ac8fa Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 15 Dec 2025 18:43:37 +0800 Subject: [PATCH 3/4] Fix source mapping of zero-column locations (#248) When lineNumbers is enabled, the column is always zero. If only one occurence of a call occurs on one line then that is correctly selected. However, in cases where the same function is called multiple times in the same line it will be unable to differentiate them and would use the unmapped value. This now makes it select the first call in the line as a best-guess for the match, and in Node.js v25 will use the new column field in LineTick to select the correct column where possible. --- ts/test/oom.ts | 1 - ts/test/test-worker-threads.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/ts/test/oom.ts b/ts/test/oom.ts index f5a25cac..d028afa7 100644 --- a/ts/test/oom.ts +++ b/ts/test/oom.ts @@ -1,6 +1,5 @@ 'use strict'; -/* eslint-disable no-console */ import {Worker, isMainThread, threadId} from 'worker_threads'; import {heap} from '../src/index'; import path from 'path'; diff --git a/ts/test/test-worker-threads.ts b/ts/test/test-worker-threads.ts index efb8cfaf..93d8e04b 100644 --- a/ts/test/test-worker-threads.ts +++ b/ts/test/test-worker-threads.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line n/no-unsupported-features/node-builtins import {execFile} from 'child_process'; import {promisify} from 'util'; import {Worker} from 'worker_threads'; From d6f79f23e51cea39b189390124d5dad916ef126f Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 13 Mar 2026 13:40:34 +0100 Subject: [PATCH 4/4] fix: use path.resolve/join for platform-portable bundle path in test --- ts/test/test-sourcemapper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 3b505f06..70693bab 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -290,8 +290,8 @@ describe('SourceMapper.loadDirectory', () => { // This test documents both behaviours so a regression would be immediately // visible. describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => { - const BUNDLE_PATH = '/app/dist/bundle.js'; - const MAP_DIR = '/app/dist'; + const MAP_DIR = path.resolve('app', 'dist'); + const BUNDLE_PATH = path.join(MAP_DIR, 'bundle.js'); // Build a source map that places three functions on line 1 of bundle.js at // columns 10, 30 and 50, each originating from a different source file.