Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/scripts/parse-jacoco.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Shared JaCoCo XML parser used by CI workflows.
// Extracts overall and per-class coverage counters from a JaCoCo XML report.

const fs = require('fs');

const zeroCov = { covered: 0, missed: 0 };

function parseJacocoXml(jacocoFile) {
const result = { overall: {}, classes: {} };

if (!fs.existsSync(jacocoFile)) {
return null;
}

const xml = fs.readFileSync(jacocoFile, 'utf8');

// Overall counters (outside <package> tags)
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let m;
while ((m = re.exec(stripped)) !== null) {
const entry = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
if (m[1] === 'LINE') result.overall.line = entry;
else if (m[1] === 'BRANCH') result.overall.branch = entry;
else if (m[1] === 'METHOD') result.overall.method = entry;
}

// Per-class counters from <package>/<class> elements.
// The negative lookbehind (?<!\/) prevents matching self-closing <class .../> tags
// (interfaces, annotations) which have no body and would otherwise steal the next
// class's counters.
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
let pkgMatch;
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
const pkgBody = pkgMatch[2];
const classRe = /<class\s+name="([^"]+)"[^>]*(?<!\/)>([\s\S]*?)<\/class>/g;
let classMatch;
while ((classMatch = classRe.exec(pkgBody)) !== null) {
const className = classMatch[1].replace(/\//g, '.');
const classBody = classMatch[2];
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let cntMatch;
while ((cntMatch = cntRe.exec(classBody)) !== null) {
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
if (cntMatch[1] === 'LINE') counters.line = entry;
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
// Skip classes with 0 total lines (empty interfaces, annotations)
if (counters.line.covered + counters.line.missed > 0) {
result.classes[className] = counters;
}
}
}

return result;
}

function pct(covered, missed) {
const total = covered + missed;
return total === 0 ? 0 : (covered / total * 100);
}

module.exports = { parseJacocoXml, pct, zeroCov };
44 changes: 2 additions & 42 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ jobs:
script: |
const fs = require('fs');
const path = require('path');
const { parseJacocoXml } = require('./.github/scripts/parse-jacoco.js');

const versions = ['java11', 'java17', 'java21', 'java25', 'jcstress'];
const zeroTest = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0 };
const zeroCov = { covered: 0, missed: 0 };

// Read current baseline
const baselineFile = 'test-baseline.json';
Expand All @@ -147,48 +147,8 @@ jobs:
}

// Update coverage from JaCoCo XML
let coverage = { overall: {}, classes: {} };
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
if (fs.existsSync(jacocoFile)) {
const xml = fs.readFileSync(jacocoFile, 'utf8');

// Overall counters (outside <package> tags)
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let m;
while ((m = re.exec(stripped)) !== null) {
if (m[1] === 'LINE') coverage.overall.line = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
else if (m[1] === 'BRANCH') coverage.overall.branch = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
else if (m[1] === 'METHOD') coverage.overall.method = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
}

// Per-class counters from <package>/<class> elements
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
let pkgMatch;
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
const pkgName = pkgMatch[1].replace(/\//g, '.');
const pkgBody = pkgMatch[2];
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
let classMatch;
while ((classMatch = classRe.exec(pkgBody)) !== null) {
const className = classMatch[1].replace(/\//g, '.');
const classBody = classMatch[2];
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let cntMatch;
while ((cntMatch = cntRe.exec(classBody)) !== null) {
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
if (cntMatch[1] === 'LINE') counters.line = entry;
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
// Skip classes with 0 total lines (interfaces, annotations, abstract classes)
if (counters.line.covered + counters.line.missed > 0) {
coverage.classes[className] = counters;
}
}
}
}
const coverage = parseJacocoXml(jacocoFile) || { overall: {}, classes: {} };

const updated = { tests, coverage };
fs.writeFileSync(baselineFile, JSON.stringify(updated, null, 2) + '\n');
Expand Down
50 changes: 7 additions & 43 deletions .github/workflows/pr-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ jobs:
}
console.log(`Posting report for PR #${prNumber}`);

const { parseJacocoXml, pct, zeroCov } = require('./.github/scripts/parse-jacoco.js');

const versions = ['java11', 'java17', 'java21', 'java25', 'jcstress'];
const zeroTest = { total: 0, passed: 0, failed: 0, errors: 0, skipped: 0 };
const zeroCov = { covered: 0, missed: 0 };

// --- Read current test stats from artifacts ---
const current = {};
Expand All @@ -90,44 +91,12 @@ jobs:
const baseClasses = (baseline.coverage || {}).classes || {};

// --- Parse JaCoCo XML for coverage ---
let covLine = null, covBranch = null, covMethod = null;
const classCounters = {};
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
if (fs.existsSync(jacocoFile)) {
const xml = fs.readFileSync(jacocoFile, 'utf8');
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let m;
while ((m = re.exec(stripped)) !== null) {
const entry = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
if (m[1] === 'LINE') covLine = entry;
else if (m[1] === 'BRANCH') covBranch = entry;
else if (m[1] === 'METHOD') covMethod = entry;
}

const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
let pkgMatch;
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
const pkgName = pkgMatch[1].replace(/\//g, '.');
const pkgBody = pkgMatch[2];
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
let classMatch;
while ((classMatch = classRe.exec(pkgBody)) !== null) {
const className = classMatch[1].replace(/\//g, '.');
const classBody = classMatch[2];
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let cntMatch;
while ((cntMatch = cntRe.exec(classBody)) !== null) {
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
if (cntMatch[1] === 'LINE') counters.line = entry;
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
classCounters[className] = counters;
}
}
}
const parsed = parseJacocoXml(jacocoFile);
const covLine = parsed?.overall?.line || null;
const covBranch = parsed?.overall?.branch || null;
const covMethod = parsed?.overall?.method || null;
const classCounters = parsed?.classes || {};

// --- Helpers ---
function delta(curr, prev, positiveIsGood) {
Expand All @@ -143,11 +112,6 @@ jobs:
return `${curr} (${delta(curr, prev, positiveIsGood)})`;
}

function pct(covered, missed) {
const total = covered + missed;
return total === 0 ? 0 : (covered / total * 100);
}

function fmtPct(value) {
return value.toFixed(1) + '%';
}
Expand Down
37 changes: 4 additions & 33 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,7 @@ jobs:
script: |
const fs = require('fs');
const path = require('path');

const zeroCov = { covered: 0, missed: 0 };
const { parseJacocoXml, pct, zeroCov } = require('./.github/scripts/parse-jacoco.js');

// --- Read baseline from repo ---
const baselineFile = 'test-baseline.json';
Expand All @@ -144,45 +143,17 @@ jobs:
const baseClasses = (baseline.coverage || {}).classes || {};

// --- Parse JaCoCo XML for per-class coverage ---
const classCounters = {};
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
if (!fs.existsSync(jacocoFile)) {
const parsed = parseJacocoXml(jacocoFile);
if (!parsed) {
console.log('No JaCoCo report found, skipping coverage gate.');
return;
}
const xml = fs.readFileSync(jacocoFile, 'utf8');
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
let pkgMatch;
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
const pkgBody = pkgMatch[2];
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
let classMatch;
while ((classMatch = classRe.exec(pkgBody)) !== null) {
const className = classMatch[1].replace(/\//g, '.');
const classBody = classMatch[2];
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let cntMatch;
while ((cntMatch = cntRe.exec(classBody)) !== null) {
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
if (cntMatch[1] === 'LINE') counters.line = entry;
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
classCounters[className] = counters;
}
}

function pct(covered, missed) {
const total = covered + missed;
return total === 0 ? 0 : (covered / total * 100);
}
const classCounters = parsed.classes;

// --- Coverage gate: fail if any class regresses on any metric ---
const regressions = [];
for (const [cls, curr] of Object.entries(classCounters)) {
const totalLines = curr.line.covered + curr.line.missed;
if (totalLines === 0) continue;
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
for (const [metric, key] of [['Line', 'line'], ['Branch', 'branch'], ['Method', 'method']]) {
const currPct = pct(curr[key].covered, curr[key].missed);
Expand Down
Loading