From 4fee934e22a2c97701c9017dcdda01d53f65b266 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 11 Mar 2026 12:35:40 +0100 Subject: [PATCH 1/4] feat: track declared-but-missing source maps and report them in profile comments When a JS file has a sourceMappingURL annotation but no map file is found after both phases of directory scanning, the file path is tracked in SourceMapper.declaredMissingMap. mappingInfo() for such files returns a SourceLocation with missingMapFile: true. In serialize(), unique paths with missingMapFile: true are collected and emitted into profile.comment as pairs of string IDs: [dedup("dd:missing-map-file-for"), dedup(filePath)] This uses the otherwise-unused comment section and avoids bloating the string table since paths are already present via Function.filename. Co-Authored-By: Claude Sonnet 4.6 --- ts/src/profile-serializer.ts | 17 ++++++++- ts/src/sourcemapper/sourcemapper.ts | 19 ++++++++++ ts/test/test-sourcemapper.ts | 54 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 7b3f0ec9..1c267643 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -101,6 +101,8 @@ function serialize( const functionIdMap = new Map(); const locationIdMap = new Map(); + const missingMapFiles = new Set(); + const entries: Array> = (root.children as T[]).map((n: T) => ({ node: n, stack: [], @@ -118,7 +120,7 @@ function serialize( continue; } const stack = entry.stack; - const location = getLocation(node, scriptName, sourceMapper); + const location = getLocation(node, scriptName, sourceMapper, missingMapFiles); stack.unshift(location.id as number); appendToSamples(entry, samples); for (const child of node.children as T[]) { @@ -131,10 +133,20 @@ function serialize( profile.function = functions; profile.stringTable = stringTable; + if (missingMapFiles.size > 0) { + const missingMapFileForId = stringTable.dedup('dd:missing-map-file-for'); + const comments: number[] = []; + for (const filePath of missingMapFiles) { + comments.push(missingMapFileForId, stringTable.dedup(filePath)); + } + profile.comment = comments; + } + function getLocation( node: ProfileNode, scriptName: string, sourceMapper?: SourceMapper, + missingMapFiles?: Set, ): Location { let profLoc: SourceLocation = { file: scriptName || '', @@ -146,6 +158,9 @@ function serialize( if (profLoc.line) { if (sourceMapper && isGeneratedLocation(profLoc)) { profLoc = sourceMapper.mappingInfo(profLoc); + if (profLoc.missingMapFile && missingMapFiles && scriptName) { + missingMapFiles.add(scriptName); + } } } const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index 91342a57..5ccb476b 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -161,6 +161,8 @@ export interface SourceLocation { name?: string; line?: number; column?: number; + /** True when the file declares a sourceMappingURL but the map could not be found. */ + missingMapFile?: boolean; } /** @@ -287,6 +289,8 @@ async function processSourceMap( export class SourceMapper { infoMap: Map; + /** JS files that declared a sourceMappingURL but no map was ultimately found. */ + private declaredMissingMap = new Set(); debug: boolean; static async create( @@ -351,6 +355,9 @@ export class SourceMapper { const limit = createLimiter(CONCURRENCY); + // JS files that declared a sourceMappingURL but Phase 1 couldn't load the map. + const annotatedNotLoaded = new Set(); + // Phase 1: Check sourceMappingURL annotations in JS files (higher priority). await Promise.all( jsFiles.map(jsPath => @@ -383,6 +390,7 @@ export class SourceMapper { ); } catch { // Map file doesn't exist or is unreadable; fall through to Phase 2. + annotatedNotLoaded.add(jsPath); } } }), @@ -395,6 +403,14 @@ export class SourceMapper { limit(() => processSourceMap(this.infoMap, mapPath, this.debug)), ), ); + + // Any file whose annotation pointed to a missing map and that still has no + // entry after Phase 2 is tracked as "declared but missing". + for (const jsPath of annotatedNotLoaded) { + if (!this.infoMap.has(jsPath)) { + this.declaredMissingMap.add(jsPath); + } + } } private async loadMapContent( @@ -480,6 +496,9 @@ export class SourceMapper { `Source map lookup failed: no map found for ${location.file} (normalized: ${inputPath})`, ); } + if (this.declaredMissingMap.has(inputPath)) { + return {...location, missingMapFile: true}; + } return location; } diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 350df364..6febeca4 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -271,4 +271,58 @@ describe('SourceMapper.loadDirectory', () => { 'expected no mapping to be loaded', ); }); + + it('sets missingMapFile=true when sourceMappingURL declares a missing map', async () => { + write('declared-missing.js', '//# sourceMappingURL=nonexistent.js.map\n'); + // No declared-missing.js.map written — nothing to fall back to. + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'declared-missing.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.strictEqual( + loc.missingMapFile, + true, + 'expected missingMapFile to be true for a file with a declared but missing map', + ); + }); + + it('does not set missingMapFile when file has no sourceMappingURL', async () => { + write('plain.js', 'console.log("hello");\n'); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'plain.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy for a file with no sourceMappingURL', + ); + }); + + it('does not set missingMapFile when map was found via .map fallback', async () => { + // JS with annotation pointing to nonexistent path, but a .map file exists + // alongside it (Phase 2 fallback). + const {SourceMapGenerator} = await import('source-map'); + const gen = new SourceMapGenerator({file: 'fallback.js'}); + gen.addMapping({ + source: path.join(tmpDir, 'source.ts'), + generated: {line: 1, column: 0}, + original: {line: 10, column: 0}, + }); + write('fallback.js', '//# sourceMappingURL=nowhere.js.map\n'); + write('fallback.js.map', gen.toString()); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'fallback.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy when map was found via Phase 2 fallback', + ); + }); }); From 1745e979bd5a501c63e31b443442563052fb0648 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 11 Mar 2026 12:38:21 +0100 Subject: [PATCH 2/4] test: add serialize() tests for missing-map-file comment reporting Covers serializeTimeProfile and serializeHeapProfile emitting dd:missing-map-file-for comment pairs, no comments without a source mapper or when all maps are resolved, and deduplication of paths. Co-Authored-By: Claude Sonnet 4.6 --- ts/test/test-profile-serializer.ts | 175 +++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 8f87b0f3..c5e712aa 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -238,6 +238,181 @@ describe('profile-serializer', () => { }); }); + describe('missing source map file reporting', () => { + let sourceMapper: SourceMapper; + let missingJsPath: string; + + before(async () => { + const fs = await import('fs'); + const path = await import('path'); + const testDir = tmp.dirSync().name; + missingJsPath = path.join(testDir, 'missing-map.js'); + // JS file that declares a sourceMappingURL but the map file doesn't exist. + fs.writeFileSync( + missingJsPath, + '//# sourceMappingURL=nonexistent.js.map\n', + ); + sourceMapper = await SourceMapper.create([testDir]); + }); + + function makeSingleNodeTimeProfile(scriptName: string) { + return { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [ + { + name: 'foo', + scriptName, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + hitCount: 1, + children: [], + }, + ], + }, + }; + } + + it('serializeTimeProfile emits comment pairs for missing map files', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + ); + const st = profile.stringTable; + const missingMapFileForId = st.dedup('dd:missing-map-file-for'); + const pathId = st.dedup(missingJsPath); + + assert.ok( + Array.isArray(profile.comment) && profile.comment.length > 0, + 'expected comment to be non-empty', + ); + // Comments should contain the sentinel + path pair. + const comments = profile.comment as number[]; + const idx = comments.indexOf(missingMapFileForId); + assert.notStrictEqual(idx, -1, 'expected dd:missing-map-file-for in comments'); + assert.strictEqual( + comments[idx + 1], + pathId, + 'expected path ID to follow the sentinel', + ); + }); + + it('serializeHeapProfile emits comment pairs for missing map files', () => { + // serialize() iterates root.children, so the node with allocations must + // be a child of the root passed to serializeHeapProfile. + const heapNode = { + name: '(root)', + scriptName: '', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'foo', + scriptName: missingJsPath, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [{sizeBytes: 100, count: 1}], + children: [], + }, + ], + }; + const profile = serializeHeapProfile(heapNode, 0, 512 * 1024, undefined, sourceMapper); + const st = profile.stringTable; + const missingMapFileForId = st.dedup('dd:missing-map-file-for'); + const pathId = st.dedup(missingJsPath); + + assert.ok( + Array.isArray(profile.comment) && profile.comment.length > 0, + 'expected comment to be non-empty', + ); + const comments = profile.comment as number[]; + const idx = comments.indexOf(missingMapFileForId); + assert.notStrictEqual(idx, -1, 'expected dd:missing-map-file-for in comments'); + assert.strictEqual(comments[idx + 1], pathId); + }); + + it('does not emit comments when no source mapper is used', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + ); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when no source mapper is provided', + ); + }); + + it('does not emit comments when all maps are found', () => { + const {mapDirPath, v8TimeGeneratedProfile} = require('./profiles-for-tests'); + return SourceMapper.create([mapDirPath]).then(sm => { + const profile = serializeTimeProfile(v8TimeGeneratedProfile, 1000, sm); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when all maps are resolved', + ); + }); + }); + + it('each missing file appears exactly once in comments', () => { + // Profile with two nodes in the same missing-map file. + const v8Profile = { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [ + { + name: 'foo', + scriptName: missingJsPath, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + hitCount: 1, + children: [ + { + name: 'bar', + scriptName: missingJsPath, + scriptId: 1, + lineNumber: 2, + columnNumber: 1, + hitCount: 1, + children: [], + }, + ], + }, + ], + }, + }; + const profile = serializeTimeProfile(v8Profile, 1000, sourceMapper); + const st = profile.stringTable; + const missingMapFileForId = st.dedup('dd:missing-map-file-for'); + const comments = profile.comment as number[]; + const occurrences = comments.filter(c => c === missingMapFileForId).length; + assert.strictEqual(occurrences, 1, 'each missing file should appear only once'); + }); + + after(() => { + tmp.setGracefulCleanup(); + }); + }); + describe('source map with column 0 (LineTick simulation)', () => { // This tests the LEAST_UPPER_BOUND fallback for when V8's LineTick // doesn't provide column information (column=0) From 0bbb05720a9f473e27ba32d98a35b18706cd1a2a Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 11 Mar 2026 14:15:00 +0100 Subject: [PATCH 3/4] simplify: emit single dd:has-missing-map-files token instead of per-path pairs Replace the per-path comment pairs with a single well-known token in the profile comment section. This is sufficient to signal that at least one file referenced in the profile locations declared a sourceMappingURL but no map was found, without bloating the profile with individual paths. Co-Authored-By: Claude Sonnet 4.6 --- ts/src/profile-serializer.ts | 18 ++---- ts/test/test-profile-serializer.ts | 92 ++++++------------------------ 2 files changed, 22 insertions(+), 88 deletions(-) diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 1c267643..c104ddf7 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -101,7 +101,7 @@ function serialize( const functionIdMap = new Map(); const locationIdMap = new Map(); - const missingMapFiles = new Set(); + let hasMissingMapFiles = false; const entries: Array> = (root.children as T[]).map((n: T) => ({ node: n, @@ -120,7 +120,7 @@ function serialize( continue; } const stack = entry.stack; - const location = getLocation(node, scriptName, sourceMapper, missingMapFiles); + const location = getLocation(node, scriptName, sourceMapper); stack.unshift(location.id as number); appendToSamples(entry, samples); for (const child of node.children as T[]) { @@ -133,20 +133,14 @@ function serialize( profile.function = functions; profile.stringTable = stringTable; - if (missingMapFiles.size > 0) { - const missingMapFileForId = stringTable.dedup('dd:missing-map-file-for'); - const comments: number[] = []; - for (const filePath of missingMapFiles) { - comments.push(missingMapFileForId, stringTable.dedup(filePath)); - } - profile.comment = comments; + if (hasMissingMapFiles) { + profile.comment = [stringTable.dedup('dd:has-missing-map-files')]; } function getLocation( node: ProfileNode, scriptName: string, sourceMapper?: SourceMapper, - missingMapFiles?: Set, ): Location { let profLoc: SourceLocation = { file: scriptName || '', @@ -158,8 +152,8 @@ function serialize( if (profLoc.line) { if (sourceMapper && isGeneratedLocation(profLoc)) { profLoc = sourceMapper.mappingInfo(profLoc); - if (profLoc.missingMapFile && missingMapFiles && scriptName) { - missingMapFiles.add(scriptName); + if (profLoc.missingMapFile) { + hasMissingMapFiles = true; } } } diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index c5e712aa..ca7120c6 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -281,32 +281,26 @@ describe('profile-serializer', () => { }; } - it('serializeTimeProfile emits comment pairs for missing map files', () => { + function assertHasMissingMapToken(profile: Profile) { + const st = profile.stringTable; + const tokenId = st.dedup('dd:has-missing-map-files'); + const comments = profile.comment as number[]; + assert.ok( + Array.isArray(comments) && comments.includes(tokenId), + 'expected dd:has-missing-map-files token in profile comments', + ); + } + + it('serializeTimeProfile emits missing-map token when a map is declared but absent', () => { const profile = serializeTimeProfile( makeSingleNodeTimeProfile(missingJsPath), 1000, sourceMapper, ); - const st = profile.stringTable; - const missingMapFileForId = st.dedup('dd:missing-map-file-for'); - const pathId = st.dedup(missingJsPath); - - assert.ok( - Array.isArray(profile.comment) && profile.comment.length > 0, - 'expected comment to be non-empty', - ); - // Comments should contain the sentinel + path pair. - const comments = profile.comment as number[]; - const idx = comments.indexOf(missingMapFileForId); - assert.notStrictEqual(idx, -1, 'expected dd:missing-map-file-for in comments'); - assert.strictEqual( - comments[idx + 1], - pathId, - 'expected path ID to follow the sentinel', - ); + assertHasMissingMapToken(profile); }); - it('serializeHeapProfile emits comment pairs for missing map files', () => { + it('serializeHeapProfile emits missing-map token when a map is declared but absent', () => { // serialize() iterates root.children, so the node with allocations must // be a child of the root passed to serializeHeapProfile. const heapNode = { @@ -329,21 +323,10 @@ describe('profile-serializer', () => { ], }; const profile = serializeHeapProfile(heapNode, 0, 512 * 1024, undefined, sourceMapper); - const st = profile.stringTable; - const missingMapFileForId = st.dedup('dd:missing-map-file-for'); - const pathId = st.dedup(missingJsPath); - - assert.ok( - Array.isArray(profile.comment) && profile.comment.length > 0, - 'expected comment to be non-empty', - ); - const comments = profile.comment as number[]; - const idx = comments.indexOf(missingMapFileForId); - assert.notStrictEqual(idx, -1, 'expected dd:missing-map-file-for in comments'); - assert.strictEqual(comments[idx + 1], pathId); + assertHasMissingMapToken(profile); }); - it('does not emit comments when no source mapper is used', () => { + it('does not emit missing-map token when no source mapper is used', () => { const profile = serializeTimeProfile( makeSingleNodeTimeProfile(missingJsPath), 1000, @@ -354,7 +337,7 @@ describe('profile-serializer', () => { ); }); - it('does not emit comments when all maps are found', () => { + it('does not emit missing-map token when all maps are found', () => { const {mapDirPath, v8TimeGeneratedProfile} = require('./profiles-for-tests'); return SourceMapper.create([mapDirPath]).then(sm => { const profile = serializeTimeProfile(v8TimeGeneratedProfile, 1000, sm); @@ -365,49 +348,6 @@ describe('profile-serializer', () => { }); }); - it('each missing file appears exactly once in comments', () => { - // Profile with two nodes in the same missing-map file. - const v8Profile = { - startTime: 0, - endTime: 1000000, - topDownRoot: { - name: '(root)', - scriptName: 'root', - scriptId: 0, - lineNumber: 0, - columnNumber: 0, - hitCount: 0, - children: [ - { - name: 'foo', - scriptName: missingJsPath, - scriptId: 1, - lineNumber: 1, - columnNumber: 1, - hitCount: 1, - children: [ - { - name: 'bar', - scriptName: missingJsPath, - scriptId: 1, - lineNumber: 2, - columnNumber: 1, - hitCount: 1, - children: [], - }, - ], - }, - ], - }, - }; - const profile = serializeTimeProfile(v8Profile, 1000, sourceMapper); - const st = profile.stringTable; - const missingMapFileForId = st.dedup('dd:missing-map-file-for'); - const comments = profile.comment as number[]; - const occurrences = comments.filter(c => c === missingMapFileForId).length; - assert.strictEqual(occurrences, 1, 'each missing file should appear only once'); - }); - after(() => { tmp.setGracefulCleanup(); }); From 69692ee7e6a8b3d102b6e266cd88c893da254196 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 11 Mar 2026 15:43:37 +0100 Subject: [PATCH 4/4] linting --- ts/test/oom.ts | 1 - ts/test/test-profile-serializer.ts | 13 +++++++++++-- ts/test/test-worker-threads.ts | 1 - 3 files changed, 11 insertions(+), 4 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-profile-serializer.ts b/ts/test/test-profile-serializer.ts index ca7120c6..7450e118 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -322,7 +322,13 @@ describe('profile-serializer', () => { }, ], }; - const profile = serializeHeapProfile(heapNode, 0, 512 * 1024, undefined, sourceMapper); + const profile = serializeHeapProfile( + heapNode, + 0, + 512 * 1024, + undefined, + sourceMapper, + ); assertHasMissingMapToken(profile); }); @@ -338,7 +344,10 @@ describe('profile-serializer', () => { }); it('does not emit missing-map token when all maps are found', () => { - const {mapDirPath, v8TimeGeneratedProfile} = require('./profiles-for-tests'); + const { + mapDirPath, + v8TimeGeneratedProfile, + } = require('./profiles-for-tests'); return SourceMapper.create([mapDirPath]).then(sm => { const profile = serializeTimeProfile(v8TimeGeneratedProfile, 1000, sm); assert.ok( 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';