Skip to content

Commit 3ae9403

Browse files
committed
feat(instrumentor): register ESM source maps for stack trace rewriting
The ESM loader now generates separate source map objects (instead of inline ones) and registers them with the main-thread SourceMapRegistry via a preamble call. This lets source-map-support remap ESM stack traces to original source positions. The preamble line-shift is handled by prepending VLQ semicolons to the mappings string — each semicolon represents an unmapped generated line, pushing all real mappings down by the correct offset.
1 parent 6a3247d commit 3ae9403

3 files changed

Lines changed: 214 additions & 7 deletions

File tree

packages/instrumentor/esm-loader.mts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ function instrumentModule(code: string, filename: string): string | null {
126126
transformed = transformSync(code, {
127127
filename,
128128
sourceFileName: filename,
129-
sourceMaps: "inline",
129+
sourceMaps: true,
130130
plugins,
131131
sourceType: "module",
132132
});
@@ -141,13 +141,31 @@ function instrumentModule(code: string, filename: string): string | null {
141141
return null;
142142
}
143143

144-
// Prepend a one-liner that allocates this module's counter buffer
145-
// and registers it with libFuzzer via the main-thread Fuzzer global.
146-
const preamble =
147-
`const ${COUNTER_ARRAY} = ` +
148-
`Fuzzer.coverageTracker.createModuleCounters(${edges});\n`;
144+
// Build a preamble that runs on the main thread before the module
145+
// body. It allocates the per-module coverage counter buffer and,
146+
// when a source map is available, registers it with the main-thread
147+
// SourceMapRegistry so that source-map-support can remap stack
148+
// traces back to the original source.
149+
const preambleLines = [
150+
`const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`,
151+
];
152+
153+
if (transformed.map) {
154+
// Shift the source map to account for the preamble lines we are
155+
// about to prepend. In VLQ-encoded mappings each semicolon
156+
// represents one generated line; prepending them pushes all real
157+
// mappings down by the right amount.
158+
const preambleOffset = preambleLines.length + 1; // +1 for the registration line itself
159+
const shifted = {
160+
...transformed.map,
161+
mappings: ";".repeat(preambleOffset) + transformed.map.mappings,
162+
};
163+
preambleLines.push(
164+
`__jazzer_registerSourceMap(${JSON.stringify(filename)}, ${JSON.stringify(shifted)});`,
165+
);
166+
}
149167

150-
return preamble + transformed.code;
168+
return preambleLines.join("\n") + "\n" + transformed.code;
151169
}
152170

153171
// ── Include / exclude filtering ──────────────────────────────────
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright 2026 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { PluginItem, transformSync } from "@babel/core";
18+
19+
import { compareHooks } from "./plugins/compareHooks";
20+
import { esmCodeCoverage } from "./plugins/esmCodeCoverage";
21+
import { SourceMap, SourceMapRegistry } from "./SourceMapRegistry";
22+
23+
const COUNTER_ARRAY = "__jazzer_cov";
24+
25+
/**
26+
* Replicate the ESM loader's instrumentModule logic so we can test
27+
* the source map handling without running a real loader thread.
28+
*/
29+
function instrumentModule(
30+
code: string,
31+
filename: string,
32+
extraPlugins: PluginItem[] = [],
33+
): { source: string; map: SourceMap | null } | null {
34+
const fuzzerCoverage = esmCodeCoverage();
35+
const plugins: PluginItem[] = [
36+
fuzzerCoverage.plugin,
37+
compareHooks,
38+
...extraPlugins,
39+
];
40+
41+
const transformed = transformSync(code, {
42+
filename,
43+
sourceFileName: filename,
44+
sourceMaps: true,
45+
plugins,
46+
sourceType: "module",
47+
});
48+
49+
const edges = fuzzerCoverage.edgeCount();
50+
if (edges === 0 || !transformed?.code) {
51+
return null;
52+
}
53+
54+
const preambleLines = [
55+
`const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`,
56+
];
57+
58+
let shiftedMap: SourceMap | null = null;
59+
if (transformed.map) {
60+
const preambleOffset = preambleLines.length + 1;
61+
shiftedMap = {
62+
...transformed.map,
63+
mappings: ";".repeat(preambleOffset) + transformed.map.mappings,
64+
} as SourceMap;
65+
preambleLines.push(
66+
`__jazzer_registerSourceMap(${JSON.stringify(filename)}, ${JSON.stringify(shiftedMap)});`,
67+
);
68+
}
69+
70+
return {
71+
source: preambleLines.join("\n") + "\n" + transformed.code,
72+
map: shiftedMap,
73+
};
74+
}
75+
76+
describe("ESM source map handling", () => {
77+
it("should produce a separate source map, not an inline one", () => {
78+
const result = instrumentModule(
79+
"export function greet() { return 'hi'; }",
80+
"/app/greet.mjs",
81+
);
82+
83+
expect(result).not.toBeNull();
84+
expect(result!.source).not.toContain("sourceMappingURL=data:");
85+
expect(result!.map).not.toBeNull();
86+
expect(result!.map!.version).toBe(3);
87+
});
88+
89+
it("should shift mappings by the number of preamble lines", () => {
90+
const result = instrumentModule(
91+
"export function greet() { return 'hi'; }",
92+
"/app/greet.mjs",
93+
);
94+
95+
expect(result!.map).not.toBeNull();
96+
const mappings = result!.map!.mappings;
97+
98+
// The preamble has 2 lines (counter allocation + source map registration).
99+
// Each prepended ";" represents an unmapped generated line.
100+
expect(mappings.startsWith(";;")).toBe(true);
101+
102+
// The real mappings follow — they should not be empty.
103+
const realMappings = mappings.replace(/^;+/, "");
104+
expect(realMappings.length).toBeGreaterThan(0);
105+
});
106+
107+
it("should embed a registration call in the preamble", () => {
108+
const filename = "/app/target.mjs";
109+
const result = instrumentModule(
110+
"export function check(s) { if (s === 'x') throw new Error(); }",
111+
filename,
112+
);
113+
114+
const lines = result!.source.split("\n");
115+
116+
// Line 1: counter allocation
117+
expect(lines[0]).toContain("Fuzzer.coverageTracker.createModuleCounters");
118+
119+
// Line 2: source map registration with the correct filename
120+
expect(lines[1]).toContain("__jazzer_registerSourceMap");
121+
expect(lines[1]).toContain(JSON.stringify(filename));
122+
123+
// The registration call should contain valid JSON for the source map
124+
const match = lines[1].match(/__jazzer_registerSourceMap\([^,]+, (.+)\);$/);
125+
expect(match).not.toBeNull();
126+
const embeddedMap = JSON.parse(match![1]);
127+
expect(embeddedMap.version).toBe(3);
128+
expect(embeddedMap.sources).toContain(filename);
129+
});
130+
131+
it("should register maps with SourceMapRegistry via the global", () => {
132+
const registry = new SourceMapRegistry();
133+
const filename = "/app/module.mjs";
134+
const fakeMap: SourceMap = {
135+
version: 3,
136+
sources: [filename],
137+
names: [],
138+
mappings: "AAAA",
139+
file: filename,
140+
};
141+
142+
// Simulate what Instrumentor.init() installs
143+
(globalThis as Record<string, unknown>).__jazzer_registerSourceMap = (
144+
f: string,
145+
m: SourceMap,
146+
) => registry.registerSourceMap(f, m);
147+
148+
// Simulate what the preamble does at module evaluation time
149+
const register = (globalThis as Record<string, unknown>)
150+
.__jazzer_registerSourceMap as (f: string, m: SourceMap) => void;
151+
register(filename, fakeMap);
152+
153+
expect(registry.getSourceMap(filename)).toEqual(fakeMap);
154+
155+
// Cleanup
156+
delete (globalThis as Record<string, unknown>).__jazzer_registerSourceMap;
157+
});
158+
159+
it("should preserve original source file in the map", () => {
160+
const filename = "/project/src/lib.mjs";
161+
const result = instrumentModule(
162+
[
163+
"export function add(a, b) {",
164+
" return a + b;",
165+
"}",
166+
"export function sub(a, b) {",
167+
" return a - b;",
168+
"}",
169+
].join("\n"),
170+
filename,
171+
);
172+
173+
expect(result!.map!.sources).toContain(filename);
174+
expect(result!.map!.mappings.split(";").length).toBeGreaterThan(2);
175+
});
176+
});

packages/instrumentor/instrument.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ export class Instrumentor {
7272
if (this.includes.includes("jazzer.js")) {
7373
this.unloadInternalModules();
7474
}
75+
76+
// Expose a registration function so ESM modules can feed their
77+
// source maps back to the main-thread registry. The ESM loader
78+
// thread cannot access this registry directly, but the preamble
79+
// code it emits runs on the main thread during module evaluation
80+
// — before the module body, and therefore before any error could
81+
// need the map for stack-trace rewriting.
82+
const registry = this.sourceMapRegistry;
83+
(globalThis as Record<string, unknown>).__jazzer_registerSourceMap = (
84+
filename: string,
85+
map: SourceMap,
86+
) => registry.registerSourceMap(filename, map);
87+
7588
return this.sourceMapRegistry.installSourceMapSupport();
7689
}
7790

0 commit comments

Comments
 (0)