Skip to content

Commit 8093b45

Browse files
andimarekclaude
andcommitted
Extract shared JaCoCo XML parser and fix self-closing tag bug
The JaCoCo parsing logic was duplicated across 3 workflow files (pull_request.yml, master.yml, pr-report.yml). Extract it into .github/scripts/parse-jacoco.js. Also fixes a regex bug where self-closing <class .../> tags (interfaces, annotations — 141 of them) would match and steal the next class's coverage counters. This caused phantom coverage entries, e.g. QueryDirectives$Builder showing QueryDirectivesImpl's coverage. The fix uses a negative lookbehind (?<!\/) to skip self-closing tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent beab3ac commit 8093b45

4 files changed

Lines changed: 78 additions & 118 deletions

File tree

‎.github/scripts/parse-jacoco.js‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Shared JaCoCo XML parser used by CI workflows.
2+
// Extracts overall and per-class coverage counters from a JaCoCo XML report.
3+
4+
const fs = require('fs');
5+
6+
const zeroCov = { covered: 0, missed: 0 };
7+
8+
function parseJacocoXml(jacocoFile) {
9+
const result = { overall: {}, classes: {} };
10+
11+
if (!fs.existsSync(jacocoFile)) {
12+
return null;
13+
}
14+
15+
const xml = fs.readFileSync(jacocoFile, 'utf8');
16+
17+
// Overall counters (outside <package> tags)
18+
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
19+
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
20+
let m;
21+
while ((m = re.exec(stripped)) !== null) {
22+
const entry = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
23+
if (m[1] === 'LINE') result.overall.line = entry;
24+
else if (m[1] === 'BRANCH') result.overall.branch = entry;
25+
else if (m[1] === 'METHOD') result.overall.method = entry;
26+
}
27+
28+
// Per-class counters from <package>/<class> elements.
29+
// The negative lookbehind (?<!\/) prevents matching self-closing <class .../> tags
30+
// (interfaces, annotations) which have no body and would otherwise steal the next
31+
// class's counters.
32+
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
33+
let pkgMatch;
34+
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
35+
const pkgBody = pkgMatch[2];
36+
const classRe = /<class\s+name="([^"]+)"[^>]*(?<!\/)>([\s\S]*?)<\/class>/g;
37+
let classMatch;
38+
while ((classMatch = classRe.exec(pkgBody)) !== null) {
39+
const className = classMatch[1].replace(/\//g, '.');
40+
const classBody = classMatch[2];
41+
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
42+
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
43+
let cntMatch;
44+
while ((cntMatch = cntRe.exec(classBody)) !== null) {
45+
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
46+
if (cntMatch[1] === 'LINE') counters.line = entry;
47+
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
48+
else if (cntMatch[1] === 'METHOD') counters.method = entry;
49+
}
50+
// Skip classes with 0 total lines (empty interfaces, annotations)
51+
if (counters.line.covered + counters.line.missed > 0) {
52+
result.classes[className] = counters;
53+
}
54+
}
55+
}
56+
57+
return result;
58+
}
59+
60+
function pct(covered, missed) {
61+
const total = covered + missed;
62+
return total === 0 ? 0 : (covered / total * 100);
63+
}
64+
65+
module.exports = { parseJacocoXml, pct, zeroCov };

‎.github/workflows/master.yml‎

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ jobs:
123123
script: |
124124
const fs = require('fs');
125125
const path = require('path');
126+
const { parseJacocoXml } = require('./.github/scripts/parse-jacoco.js');
126127
127128
const versions = ['java11', 'java17', 'java21', 'java25', 'jcstress'];
128129
const zeroTest = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0 };
129-
const zeroCov = { covered: 0, missed: 0 };
130130
131131
// Read current baseline
132132
const baselineFile = 'test-baseline.json';
@@ -147,48 +147,8 @@ jobs:
147147
}
148148
149149
// Update coverage from JaCoCo XML
150-
let coverage = { overall: {}, classes: {} };
151150
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
152-
if (fs.existsSync(jacocoFile)) {
153-
const xml = fs.readFileSync(jacocoFile, 'utf8');
154-
155-
// Overall counters (outside <package> tags)
156-
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
157-
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
158-
let m;
159-
while ((m = re.exec(stripped)) !== null) {
160-
if (m[1] === 'LINE') coverage.overall.line = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
161-
else if (m[1] === 'BRANCH') coverage.overall.branch = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
162-
else if (m[1] === 'METHOD') coverage.overall.method = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
163-
}
164-
165-
// Per-class counters from <package>/<class> elements
166-
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
167-
let pkgMatch;
168-
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
169-
const pkgName = pkgMatch[1].replace(/\//g, '.');
170-
const pkgBody = pkgMatch[2];
171-
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
172-
let classMatch;
173-
while ((classMatch = classRe.exec(pkgBody)) !== null) {
174-
const className = classMatch[1].replace(/\//g, '.');
175-
const classBody = classMatch[2];
176-
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
177-
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
178-
let cntMatch;
179-
while ((cntMatch = cntRe.exec(classBody)) !== null) {
180-
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
181-
if (cntMatch[1] === 'LINE') counters.line = entry;
182-
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
183-
else if (cntMatch[1] === 'METHOD') counters.method = entry;
184-
}
185-
// Skip classes with 0 total lines (interfaces, annotations, abstract classes)
186-
if (counters.line.covered + counters.line.missed > 0) {
187-
coverage.classes[className] = counters;
188-
}
189-
}
190-
}
191-
}
151+
const coverage = parseJacocoXml(jacocoFile) || { overall: {}, classes: {} };
192152
193153
const updated = { tests, coverage };
194154
fs.writeFileSync(baselineFile, JSON.stringify(updated, null, 2) + '\n');

‎.github/workflows/pr-report.yml‎

Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ jobs:
6464
}
6565
console.log(`Posting report for PR #${prNumber}`);
6666
67+
const { parseJacocoXml, pct, zeroCov } = require('./.github/scripts/parse-jacoco.js');
68+
6769
const versions = ['java11', 'java17', 'java21', 'java25', 'jcstress'];
6870
const zeroTest = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0 };
69-
const zeroCov = { covered: 0, missed: 0 };
7071
7172
// --- Read current test stats from artifacts ---
7273
const current = {};
@@ -90,44 +91,12 @@ jobs:
9091
const baseClasses = (baseline.coverage || {}).classes || {};
9192
9293
// --- Parse JaCoCo XML for coverage ---
93-
let covLine = null, covBranch = null, covMethod = null;
94-
const classCounters = {};
9594
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
96-
if (fs.existsSync(jacocoFile)) {
97-
const xml = fs.readFileSync(jacocoFile, 'utf8');
98-
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
99-
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
100-
let m;
101-
while ((m = re.exec(stripped)) !== null) {
102-
const entry = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
103-
if (m[1] === 'LINE') covLine = entry;
104-
else if (m[1] === 'BRANCH') covBranch = entry;
105-
else if (m[1] === 'METHOD') covMethod = entry;
106-
}
107-
108-
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
109-
let pkgMatch;
110-
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
111-
const pkgName = pkgMatch[1].replace(/\//g, '.');
112-
const pkgBody = pkgMatch[2];
113-
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
114-
let classMatch;
115-
while ((classMatch = classRe.exec(pkgBody)) !== null) {
116-
const className = classMatch[1].replace(/\//g, '.');
117-
const classBody = classMatch[2];
118-
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
119-
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
120-
let cntMatch;
121-
while ((cntMatch = cntRe.exec(classBody)) !== null) {
122-
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
123-
if (cntMatch[1] === 'LINE') counters.line = entry;
124-
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
125-
else if (cntMatch[1] === 'METHOD') counters.method = entry;
126-
}
127-
classCounters[className] = counters;
128-
}
129-
}
130-
}
95+
const parsed = parseJacocoXml(jacocoFile);
96+
const covLine = parsed?.overall?.line || null;
97+
const covBranch = parsed?.overall?.branch || null;
98+
const covMethod = parsed?.overall?.method || null;
99+
const classCounters = parsed?.classes || {};
131100
132101
// --- Helpers ---
133102
function delta(curr, prev, positiveIsGood) {
@@ -143,11 +112,6 @@ jobs:
143112
return `${curr} (${delta(curr, prev, positiveIsGood)})`;
144113
}
145114
146-
function pct(covered, missed) {
147-
const total = covered + missed;
148-
return total === 0 ? 0 : (covered / total * 100);
149-
}
150-
151115
function fmtPct(value) {
152116
return value.toFixed(1) + '%';
153117
}

‎.github/workflows/pull_request.yml‎

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,7 @@ jobs:
132132
script: |
133133
const fs = require('fs');
134134
const path = require('path');
135-
136-
const zeroCov = { covered: 0, missed: 0 };
135+
const { parseJacocoXml, pct, zeroCov } = require('./.github/scripts/parse-jacoco.js');
137136
138137
// --- Read baseline from repo ---
139138
const baselineFile = 'test-baseline.json';
@@ -144,45 +143,17 @@ jobs:
144143
const baseClasses = (baseline.coverage || {}).classes || {};
145144
146145
// --- Parse JaCoCo XML for per-class coverage ---
147-
const classCounters = {};
148146
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
149-
if (!fs.existsSync(jacocoFile)) {
147+
const parsed = parseJacocoXml(jacocoFile);
148+
if (!parsed) {
150149
console.log('No JaCoCo report found, skipping coverage gate.');
151150
return;
152151
}
153-
const xml = fs.readFileSync(jacocoFile, 'utf8');
154-
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
155-
let pkgMatch;
156-
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
157-
const pkgBody = pkgMatch[2];
158-
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
159-
let classMatch;
160-
while ((classMatch = classRe.exec(pkgBody)) !== null) {
161-
const className = classMatch[1].replace(/\//g, '.');
162-
const classBody = classMatch[2];
163-
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
164-
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
165-
let cntMatch;
166-
while ((cntMatch = cntRe.exec(classBody)) !== null) {
167-
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
168-
if (cntMatch[1] === 'LINE') counters.line = entry;
169-
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
170-
else if (cntMatch[1] === 'METHOD') counters.method = entry;
171-
}
172-
classCounters[className] = counters;
173-
}
174-
}
175-
176-
function pct(covered, missed) {
177-
const total = covered + missed;
178-
return total === 0 ? 0 : (covered / total * 100);
179-
}
152+
const classCounters = parsed.classes;
180153
181154
// --- Coverage gate: fail if any class regresses on any metric ---
182155
const regressions = [];
183156
for (const [cls, curr] of Object.entries(classCounters)) {
184-
const totalLines = curr.line.covered + curr.line.missed;
185-
if (totalLines === 0) continue;
186157
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
187158
for (const [metric, key] of [['Line', 'line'], ['Branch', 'branch'], ['Method', 'method']]) {
188159
const currPct = pct(curr[key].covered, curr[key].missed);

0 commit comments

Comments
 (0)