Skip to content

Commit 113c1c0

Browse files
committed
feat(instrumentor): ESM code coverage plugin
Extract the shared coverage visitor logic (which AST nodes to instrument, how to wrap branches) out of codeCoverage into coverageVisitor. Both CJS and ESM plugins now build on this. The new esmCodeCoverage plugin generates direct array writes to a module-local __jazzer_cov buffer instead of calling the global incrementCounter function. Edge IDs are module-scoped (starting at 0 per file), so no cross-thread coordination is needed between the CJS instrumentor and the ESM loader. The NeverZero increment uses (x % 255) + 1 rather than the more obvious (x + 1) & 255 || 1, because Babel re-visits generated AST and a LogicalExpression in the counter statement would trigger infinite recursion in the coverage visitor.
1 parent 0b32ac3 commit 113c1c0

4 files changed

Lines changed: 333 additions & 103 deletions

File tree

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
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
* Shared coverage instrumentation visitor.
19+
*
20+
* Both the CJS instrumentor (incrementCounter calls) and the ESM
21+
* instrumentor (direct array writes) inject counters at the same
22+
* AST locations. This module captures that shared visitor shape
23+
* and lets each variant supply its own expression generator.
24+
*/
25+
26+
import { NodePath, types, Visitor } from "@babel/core";
27+
import {
28+
BlockStatement,
29+
ConditionalExpression,
30+
Expression,
31+
ExpressionStatement,
32+
Function,
33+
IfStatement,
34+
isBlockStatement,
35+
isLogicalExpression,
36+
LogicalExpression,
37+
Loop,
38+
Statement,
39+
SwitchStatement,
40+
TryStatement,
41+
} from "@babel/types";
42+
43+
/**
44+
* Build a Babel visitor that inserts a counter expression at every
45+
* branch point. The caller decides what that expression looks like.
46+
*/
47+
export function makeCoverageVisitor(
48+
makeCounterExpr: () => Expression,
49+
): Visitor {
50+
function makeStmt(): ExpressionStatement {
51+
return types.expressionStatement(makeCounterExpr());
52+
}
53+
54+
function wrapWithCounter(stmt: Statement): BlockStatement {
55+
const counter = makeStmt();
56+
if (isBlockStatement(stmt)) {
57+
stmt.body.unshift(counter);
58+
return stmt;
59+
}
60+
return types.blockStatement([counter, stmt]);
61+
}
62+
63+
return {
64+
Function(path: NodePath<Function>) {
65+
if (isBlockStatement(path.node.body)) {
66+
path.node.body.body.unshift(makeStmt());
67+
}
68+
},
69+
IfStatement(path: NodePath<IfStatement>) {
70+
path.node.consequent = wrapWithCounter(path.node.consequent);
71+
if (path.node.alternate) {
72+
path.node.alternate = wrapWithCounter(path.node.alternate);
73+
}
74+
path.insertAfter(makeStmt());
75+
},
76+
SwitchStatement(path: NodePath<SwitchStatement>) {
77+
for (const caseClause of path.node.cases) {
78+
caseClause.consequent.unshift(makeStmt());
79+
}
80+
path.insertAfter(makeStmt());
81+
},
82+
Loop(path: NodePath<Loop>) {
83+
path.node.body = wrapWithCounter(path.node.body);
84+
path.insertAfter(makeStmt());
85+
},
86+
TryStatement(path: NodePath<TryStatement>) {
87+
if (path.node.handler) {
88+
path.node.handler.body.body.unshift(makeStmt());
89+
}
90+
path.insertAfter(makeStmt());
91+
},
92+
LogicalExpression(path: NodePath<LogicalExpression>) {
93+
if (!isLogicalExpression(path.node.left)) {
94+
path.node.left = types.sequenceExpression([
95+
makeCounterExpr(),
96+
path.node.left,
97+
]);
98+
}
99+
if (!isLogicalExpression(path.node.right)) {
100+
path.node.right = types.sequenceExpression([
101+
makeCounterExpr(),
102+
path.node.right,
103+
]);
104+
}
105+
},
106+
ConditionalExpression(path: NodePath<ConditionalExpression>) {
107+
path.node.consequent = types.sequenceExpression([
108+
makeCounterExpr(),
109+
path.node.consequent,
110+
]);
111+
path.node.alternate = types.sequenceExpression([
112+
makeCounterExpr(),
113+
path.node.alternate,
114+
]);
115+
if (isBlockStatement(path.parent)) {
116+
path.insertAfter(makeStmt());
117+
}
118+
},
119+
};
120+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 { transformSync } from "@babel/core";
18+
19+
import { esmCodeCoverage } from "./esmCodeCoverage";
20+
import { removeIndentation } from "./testhelpers";
21+
22+
function transform(code: string): { code: string; edgeCount: number } {
23+
const coverage = esmCodeCoverage();
24+
const result = transformSync(removeIndentation(code), {
25+
filename: "test-module.mjs",
26+
plugins: [coverage.plugin],
27+
});
28+
return {
29+
code: removeIndentation(result?.code),
30+
edgeCount: coverage.edgeCount(),
31+
};
32+
}
33+
34+
describe("ESM code coverage instrumentation", () => {
35+
it("should emit direct array writes, not incrementCounter", () => {
36+
const { code, edgeCount } = transform(`
37+
|function foo() {
38+
| return 1;
39+
|}`);
40+
41+
expect(code).toContain("__jazzer_cov[0]");
42+
expect(code).not.toContain("incrementCounter");
43+
expect(edgeCount).toBe(1);
44+
});
45+
46+
it("should implement NeverZero via % 255 + 1", () => {
47+
const { code } = transform(`
48+
|function foo() {
49+
| return 1;
50+
|}`);
51+
52+
expect(code).toContain("% 255");
53+
expect(code).toContain("+ 1");
54+
// Must NOT contain || or ?: to avoid infinite visitor recursion.
55+
expect(code).not.toMatch(/\|\||[?:]/);
56+
});
57+
58+
it("should assign sequential module-local IDs", () => {
59+
const { code, edgeCount } = transform(`
60+
|function foo() {
61+
| if (a) {
62+
| return 1;
63+
| } else {
64+
| return 2;
65+
| }
66+
|}`);
67+
68+
// Function body, if-consequent, if-alternate, after-if
69+
expect(edgeCount).toBe(4);
70+
expect(code).toContain("__jazzer_cov[0]");
71+
expect(code).toContain("__jazzer_cov[1]");
72+
expect(code).toContain("__jazzer_cov[2]");
73+
expect(code).toContain("__jazzer_cov[3]");
74+
});
75+
76+
it("should instrument all branch types", () => {
77+
const { edgeCount } = transform(`
78+
|function foo(x) {
79+
| if (x > 0) { return 1; }
80+
| switch (x) {
81+
| case -1: return -1;
82+
| default: return 0;
83+
| }
84+
| for (let i = 0; i < x; i++) { sum += i; }
85+
| try { bar(); } catch (e) { log(e); }
86+
| const y = x > 0 ? 1 : 0;
87+
| const z = a || b;
88+
|}`);
89+
90+
// This is a smoke test -- the exact count depends on how
91+
// many edges each construct produces. We just verify the
92+
// number is reasonable (> 10 for this code) and non-zero.
93+
expect(edgeCount).toBeGreaterThan(10);
94+
});
95+
96+
it("should start edge IDs at 0 for each new module", () => {
97+
const first = transform(`|function a() { return 1; }`);
98+
const second = transform(`|function b() { return 2; }`);
99+
100+
// Both modules should use __jazzer_cov[0] since IDs are
101+
// module-local, not global.
102+
expect(first.code).toContain("__jazzer_cov[0]");
103+
expect(second.code).toContain("__jazzer_cov[0]");
104+
expect(first.edgeCount).toBe(1);
105+
expect(second.edgeCount).toBe(1);
106+
});
107+
108+
it("should return 0 edges for code with no branches", () => {
109+
const { edgeCount } = transform(`|const x = 42;`);
110+
expect(edgeCount).toBe(0);
111+
});
112+
});

0 commit comments

Comments
 (0)