Skip to content

Commit 6a3247d

Browse files
committed
feat(instrumentor): instrument ESM modules via loader hooks
Add an ESM loader that intercepts import() and static imports on Node >= 20.6, applying Babel coverage and compare-hook transforms. Each module gets its own counter buffer via a preamble that runs on the main thread. The shared coverage visitor is extracted from codeCoverage.ts so both the CJS path (global IDs via incrementCounter) and the new ESM path (module-local IDs via direct array writes) reuse the same branch/loop/ternary instrumentation logic. The ESM counter uses % 255 + 1 for NeverZero instead of || 1 to avoid infinite Babel visitor recursion on the generated expression. Istanbul source coverage is included from the start so that ESM modules appear in --coverage reports alongside CJS ones. On older Node versions the loader is silently skipped.
1 parent fcc4398 commit 6a3247d

16 files changed

Lines changed: 933 additions & 103 deletions

.npmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ example
2626

2727
# Exclude all TypeScript source files
2828
*.ts
29+
*.mts
2930
!*.d.ts
31+
!*.d.mts
3032
*test*.d.ts
33+
*test*.d.mts
3134

3235

3336
# Exclude native fuzzer sources
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
/**
18+
* Node.js module-loader hook for ESM instrumentation.
19+
*
20+
* Registered via module.register() from registerInstrumentor().
21+
* Runs in a dedicated loader thread — it has no access to the
22+
* native fuzzer addon or to globalThis.Fuzzer. All it does is
23+
* transform source code and hand it back. The transformed code
24+
* executes in the main thread, where the Fuzzer global exists.
25+
*/
26+
27+
import type { PluginItem } from "@babel/core";
28+
import { createRequire } from "node:module";
29+
import { fileURLToPath } from "node:url";
30+
31+
// Load CJS-compiled Babel plugins via createRequire so we don't
32+
// depend on Node.js CJS-named-export detection (varies by version).
33+
const require = createRequire(import.meta.url);
34+
const { transformSync } =
35+
require("@babel/core") as typeof import("@babel/core");
36+
const { esmCodeCoverage } =
37+
require("./plugins/esmCodeCoverage.js") as typeof import("./plugins/esmCodeCoverage.js");
38+
const { compareHooks } =
39+
require("./plugins/compareHooks.js") as typeof import("./plugins/compareHooks.js");
40+
const { sourceCodeCoverage } =
41+
require("./plugins/sourceCodeCoverage.js") as typeof import("./plugins/sourceCodeCoverage.js");
42+
43+
// Already-instrumented code contains this marker.
44+
const INSTRUMENTATION_MARKER = "Fuzzer.coverageTracker.incrementCounter";
45+
46+
// Counter buffer variable injected into each instrumented module.
47+
const COUNTER_ARRAY = "__jazzer_cov";
48+
49+
interface LoaderConfig {
50+
includes: string[];
51+
excludes: string[];
52+
coverage: boolean;
53+
}
54+
55+
let config: LoaderConfig;
56+
57+
export function initialize(data: LoaderConfig): void {
58+
config = data;
59+
}
60+
61+
interface LoadResult {
62+
format?: string;
63+
source?: string | ArrayBuffer | SharedArrayBuffer | Uint8Array;
64+
shortCircuit?: boolean;
65+
}
66+
67+
type LoadFn = (
68+
url: string,
69+
context: { format?: string | null },
70+
nextLoad: (
71+
url: string,
72+
context: { format?: string | null },
73+
) => Promise<LoadResult>,
74+
) => Promise<LoadResult>;
75+
76+
export const load: LoadFn = async function load(url, context, nextLoad) {
77+
const result = await nextLoad(url, context);
78+
79+
if (result.format !== "module" || !result.source) {
80+
return result;
81+
}
82+
83+
// Only instrument file:// URLs (skip builtins, data:, https:, etc.)
84+
if (!url.startsWith("file://")) {
85+
return result;
86+
}
87+
88+
const filename = fileURLToPath(url);
89+
if (!shouldInstrument(filename)) {
90+
return result;
91+
}
92+
93+
const code = result.source.toString();
94+
95+
// Avoid double-instrumenting code already processed by the CJS path
96+
// or by the Jest transformer.
97+
if (code.includes(INSTRUMENTATION_MARKER)) {
98+
return result;
99+
}
100+
101+
const instrumented = instrumentModule(code, filename);
102+
if (!instrumented) {
103+
return result;
104+
}
105+
106+
return { ...result, source: instrumented };
107+
};
108+
109+
// ── Instrumentation ──────────────────────────────────────────────
110+
111+
function instrumentModule(code: string, filename: string): string | null {
112+
const fuzzerCoverage = esmCodeCoverage();
113+
114+
const plugins: PluginItem[] = [fuzzerCoverage.plugin, compareHooks];
115+
116+
// When --coverage is active, also apply Istanbul instrumentation so
117+
// that ESM modules appear in the human-readable coverage report.
118+
// The plugin writes to globalThis.__coverage__ at runtime (on the
119+
// main thread), just like the CJS path does.
120+
if (config.coverage) {
121+
plugins.push(sourceCodeCoverage(filename));
122+
}
123+
124+
let transformed: ReturnType<typeof transformSync>;
125+
try {
126+
transformed = transformSync(code, {
127+
filename,
128+
sourceFileName: filename,
129+
sourceMaps: "inline",
130+
plugins,
131+
sourceType: "module",
132+
});
133+
} catch {
134+
// Babel parse failures on non-JS assets should not crash the
135+
// loader — fall through and return the original source.
136+
return null;
137+
}
138+
139+
const edges = fuzzerCoverage.edgeCount();
140+
if (edges === 0 || !transformed?.code) {
141+
return null;
142+
}
143+
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`;
149+
150+
return preamble + transformed.code;
151+
}
152+
153+
// ── Include / exclude filtering ──────────────────────────────────
154+
155+
function shouldInstrument(filepath: string): boolean {
156+
const { includes, excludes } = config;
157+
const included = includes.some((p) => filepath.includes(p));
158+
const excluded = excludes.some((p) => filepath.includes(p));
159+
return included && !excluded;
160+
}

packages/instrumentor/instrument.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17+
import * as path from "path";
18+
import { pathToFileURL } from "url";
19+
1720
import {
1821
BabelFileResult,
1922
PluginItem,
@@ -183,6 +186,22 @@ export class Instrumentor {
183186
);
184187
}
185188

189+
get dryRun(): boolean {
190+
return this.isDryRun;
191+
}
192+
193+
get includePatterns(): string[] {
194+
return this.includes;
195+
}
196+
197+
get excludePatterns(): string[] {
198+
return this.excludes;
199+
}
200+
201+
get coverageEnabled(): boolean {
202+
return this.shouldCollectSourceCodeCoverage;
203+
}
204+
186205
private shouldCollectCodeCoverage(filepath: string): boolean {
187206
return (
188207
this.shouldCollectSourceCodeCoverage &&
@@ -223,4 +242,47 @@ export function registerInstrumentor(instrumentor: Instrumentor) {
223242
// instrumentor but the filename will still have a .ts extension
224243
{ extensions: [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] },
225244
);
245+
246+
registerEsmHooks(instrumentor);
247+
}
248+
249+
/**
250+
* On Node.js >= 20.6 register an ESM loader hook so that
251+
* import() and static imports are instrumented too.
252+
*/
253+
function registerEsmHooks(instrumentor: Instrumentor): void {
254+
if (instrumentor.dryRun) {
255+
return;
256+
}
257+
258+
const [major, minor] = process.versions.node.split(".").map(Number);
259+
if (major < 20 || (major === 20 && minor < 6)) {
260+
return;
261+
}
262+
263+
try {
264+
// Dynamic require — the node:module API may not expose
265+
// `register` on older versions even if the check above
266+
// passed (e.g. unusual builds).
267+
const { register } = require("node:module") as {
268+
register: (
269+
specifier: string,
270+
options: { parentURL: string; data: unknown },
271+
) => void;
272+
};
273+
274+
const loaderUrl = pathToFileURL(
275+
path.join(__dirname, "esm-loader.mjs"),
276+
).href;
277+
register(loaderUrl, {
278+
parentURL: pathToFileURL(__filename).href,
279+
data: {
280+
includes: instrumentor.includePatterns,
281+
excludes: instrumentor.excludePatterns,
282+
coverage: instrumentor.coverageEnabled,
283+
},
284+
});
285+
} catch {
286+
// Silently fall back to CJS-only instrumentation.
287+
}
226288
}

packages/instrumentor/plugins/codeCoverage.ts

Lines changed: 11 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -14,111 +14,19 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { NodePath, PluginTarget, types } from "@babel/core";
18-
import {
19-
BlockStatement,
20-
ConditionalExpression,
21-
Expression,
22-
ExpressionStatement,
23-
Function,
24-
IfStatement,
25-
isBlockStatement,
26-
isLogicalExpression,
27-
LogicalExpression,
28-
Loop,
29-
Statement,
30-
SwitchStatement,
31-
TryStatement,
32-
} from "@babel/types";
17+
import { PluginTarget, types } from "@babel/core";
3318

3419
import { EdgeIdStrategy } from "../edgeIdStrategy";
3520

36-
export function codeCoverage(idStrategy: EdgeIdStrategy): () => PluginTarget {
37-
function addCounterToStmt(stmt: Statement): BlockStatement {
38-
const counterStmt = makeCounterIncStmt();
39-
if (isBlockStatement(stmt)) {
40-
const br = stmt as BlockStatement;
41-
br.body.unshift(counterStmt);
42-
return br;
43-
} else {
44-
return types.blockStatement([counterStmt, stmt]);
45-
}
46-
}
47-
48-
function makeCounterIncStmt(): ExpressionStatement {
49-
return types.expressionStatement(makeCounterIncExpr());
50-
}
21+
import { makeCoverageVisitor } from "./coverageVisitor";
5122

52-
function makeCounterIncExpr(): Expression {
53-
return types.callExpression(
54-
types.identifier("Fuzzer.coverageTracker.incrementCounter"),
55-
[types.numericLiteral(idStrategy.nextEdgeId())],
56-
);
57-
}
58-
59-
return () => {
60-
return {
61-
visitor: {
62-
Function(path: NodePath<Function>) {
63-
if (isBlockStatement(path.node.body)) {
64-
const bodyStmt = path.node.body as BlockStatement;
65-
if (bodyStmt) {
66-
bodyStmt.body.unshift(makeCounterIncStmt());
67-
}
68-
}
69-
},
70-
IfStatement(path: NodePath<IfStatement>) {
71-
path.node.consequent = addCounterToStmt(path.node.consequent);
72-
if (path.node.alternate) {
73-
path.node.alternate = addCounterToStmt(path.node.alternate);
74-
}
75-
path.insertAfter(makeCounterIncStmt());
76-
},
77-
SwitchStatement(path: NodePath<SwitchStatement>) {
78-
path.node.cases.forEach((caseStmt) =>
79-
caseStmt.consequent.unshift(makeCounterIncStmt()),
80-
);
81-
path.insertAfter(makeCounterIncStmt());
82-
},
83-
Loop(path: NodePath<Loop>) {
84-
path.node.body = addCounterToStmt(path.node.body);
85-
path.insertAfter(makeCounterIncStmt());
86-
},
87-
TryStatement(path: NodePath<TryStatement>) {
88-
const catchStmt = path.node.handler;
89-
if (catchStmt) {
90-
catchStmt.body.body.unshift(makeCounterIncStmt());
91-
}
92-
path.insertAfter(makeCounterIncStmt());
93-
},
94-
LogicalExpression(path: NodePath<LogicalExpression>) {
95-
if (!isLogicalExpression(path.node.left)) {
96-
path.node.left = types.sequenceExpression([
97-
makeCounterIncExpr(),
98-
path.node.left,
99-
]);
100-
}
101-
if (!isLogicalExpression(path.node.right)) {
102-
path.node.right = types.sequenceExpression([
103-
makeCounterIncExpr(),
104-
path.node.right,
105-
]);
106-
}
107-
},
108-
ConditionalExpression(path: NodePath<ConditionalExpression>) {
109-
path.node.consequent = types.sequenceExpression([
110-
makeCounterIncExpr(),
111-
path.node.consequent,
112-
]);
113-
path.node.alternate = types.sequenceExpression([
114-
makeCounterIncExpr(),
115-
path.node.alternate,
116-
]);
117-
if (isBlockStatement(path.parent)) {
118-
path.insertAfter(makeCounterIncStmt());
119-
}
120-
},
121-
},
122-
};
123-
};
23+
export function codeCoverage(idStrategy: EdgeIdStrategy): () => PluginTarget {
24+
return () => ({
25+
visitor: makeCoverageVisitor(() =>
26+
types.callExpression(
27+
types.identifier("Fuzzer.coverageTracker.incrementCounter"),
28+
[types.numericLiteral(idStrategy.nextEdgeId())],
29+
),
30+
),
31+
});
12432
}

0 commit comments

Comments
 (0)