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
30 changes: 30 additions & 0 deletions .github/scripts/parse-jacoco.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,39 @@ function parseJacocoXml(jacocoFile) {
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
else if (cntMatch[1] === 'METHOD') counters.method = entry;
}
// Extract per-method counters within this class.
// JaCoCo XML contains <method name="..." desc="..." line="..."> elements
// each with their own <counter> children.
const methods = [];
const methodRe = /<method\s+name="([^"]+)"\s+desc="([^"]+)"(?:\s+line="(\d+)")?[^>]*>([\s\S]*?)<\/method>/g;
let methodMatch;
while ((methodMatch = methodRe.exec(classBody)) !== null) {
const mCounters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
const mCntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
let mCntMatch;
while ((mCntMatch = mCntRe.exec(methodMatch[4])) !== null) {
const entry = { covered: parseInt(mCntMatch[3]), missed: parseInt(mCntMatch[2]) };
if (mCntMatch[1] === 'LINE') mCounters.line = entry;
else if (mCntMatch[1] === 'BRANCH') mCounters.branch = entry;
else if (mCntMatch[1] === 'METHOD') mCounters.method = entry;
}
const totalLines = mCounters.line.covered + mCounters.line.missed;
if (totalLines > 0) {
methods.push({
name: methodMatch[1],
desc: methodMatch[2],
line: methodMatch[3] ? parseInt(methodMatch[3]) : null,
counters: mCounters,
});
}
}

// Skip classes with 0 total lines (empty interfaces, annotations)
if (counters.line.covered + counters.line.missed > 0) {
result.classes[className] = counters;
if (methods.length > 0) {
result.classes[className].methods = methods;
}
}
}
}
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/pr-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ jobs:
linePct: currLinePct, lineDelta: currLinePct - baseLinePct,
branchPct: currBranchPct, branchDelta: currBranchPct - baseBranchPct,
methodPct: currMethodPct, methodDelta: currMethodPct - baseMethodPct,
currMissed: { line: curr.line.missed, branch: curr.branch.missed, method: curr.method.missed },
baseMissed: { line: base.line.missed, branch: base.branch.missed, method: base.method.missed },
currMethods: curr.methods || [],
baseMethods: base.methods || [],
});
}
}
Expand Down Expand Up @@ -232,6 +236,90 @@ jobs:
return fmtPctDelta(delta, 0);
}

// Build a method-level detail block for classes with coverage decreases.
// Uses method name+desc as a stable key to match between baseline and current.
function buildMethodDetails(c) {
if (c.removed) return '';
// Use the same hybrid check as the coverage gate: percentage
// dropped AND absolute missed count increased. This avoids
// showing details for classes where code was merely extracted/moved.
const lineRegressed = c.lineDelta < -0.05 && c.currMissed.line > c.baseMissed.line;
const branchRegressed = c.branchDelta < -0.05 && c.currMissed.branch > c.baseMissed.branch;
const methodRegressed = c.methodDelta < -0.05 && c.currMissed.method > c.baseMissed.method;
if (!lineRegressed && !branchRegressed && !methodRegressed) return '';

const currMethods = c.currMethods || [];
const baseMethods = c.baseMethods || [];
if (currMethods.length === 0 && baseMethods.length === 0) return '';

// Index baseline methods by name+desc
const baseByKey = {};
for (const m of baseMethods) {
baseByKey[m.name + m.desc] = m;
}

const rows = [];
const seen = new Set();
for (const m of currMethods) {
const key = m.name + m.desc;
seen.add(key);
const bm = baseByKey[key];
const currLinePct = pct(m.counters.line.covered, m.counters.line.missed);
const baseLinePct = bm ? pct(bm.counters.line.covered, bm.counters.line.missed) : null;
const currBranchTotal = m.counters.branch.covered + m.counters.branch.missed;
const currBranchPct = currBranchTotal > 0 ? pct(m.counters.branch.covered, m.counters.branch.missed) : null;
const baseBranchPct = bm ? (() => {
const t = bm.counters.branch.covered + bm.counters.branch.missed;
return t > 0 ? pct(bm.counters.branch.covered, bm.counters.branch.missed) : null;
})() : null;

// Show methods that actually changed coverage or are new/removed.
// When baseline method data exists, only show methods whose coverage
// moved — this avoids noise from methods that were already partially
// covered. Fall back to showing all uncovered methods when no baseline
// method data is available yet.
const hasMissed = m.counters.line.missed > 0 || m.counters.branch.missed > 0;
const lineChanged = baseLinePct !== null && Math.abs(currLinePct - baseLinePct) >= 0.05;
const branchChanged = currBranchPct !== null && baseBranchPct !== null && Math.abs(currBranchPct - baseBranchPct) >= 0.05;
const isNew = !bm;
const show = baseMethods.length > 0
? (lineChanged || branchChanged || isNew)
: (hasMissed || isNew);

if (show) {
let lineStr = fmtPct(currLinePct);
if (baseLinePct !== null && Math.abs(currLinePct - baseLinePct) >= 0.05) {
lineStr += ` (${fmtPctDelta(currLinePct, baseLinePct)})`;
}
let branchStr = currBranchPct !== null ? fmtPct(currBranchPct) : '—';
if (currBranchPct !== null && baseBranchPct !== null && Math.abs(currBranchPct - baseBranchPct) >= 0.05) {
branchStr += ` (${fmtPctDelta(currBranchPct, baseBranchPct)})`;
}
const tag = isNew ? ' **new**' : '';
const displayName = m.name === '<init>' ? '*constructor*' : m.name;
rows.push(`| ${displayName}${tag} | ${lineStr} | ${branchStr} |`);
}
}
// Methods removed since baseline
for (const bm of baseMethods) {
const key = bm.name + bm.desc;
if (!seen.has(key)) {
const displayName = bm.name === '<init>' ? '*constructor*' : bm.name;
rows.push(`| ~~${displayName}~~ | *removed* | *removed* |`);
}
}

if (rows.length === 0) return '';

const shortName = c.name.split('.').pop().replace(/\$/g, '.');
let detail = `\n<details>\n<summary>${shortName} — method details</summary>\n\n`;
detail += '| Method | Line | Branch |\n';
detail += '|:-------|-----:|-------:|\n';
detail += rows.join('\n') + '\n';
detail += '\n</details>\n';
return detail;
}

if (changedClasses.length > 0) {
body += `**Changed Class Coverage** (${changedClasses.length} ${changedClasses.length === 1 ? 'class' : 'classes'})\n\n`;
body += '| Class | Line | Branch | Method |\n';
Expand All @@ -244,6 +332,11 @@ jobs:
}
}
body += '\n';

// Add collapsible method-level details for classes with coverage decreases
for (const c of changedClasses) {
body += buildMethodDetails(c);
}
} else {
body += '> No per-class coverage changes detected.\n';
}
Expand Down
51 changes: 49 additions & 2 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,61 @@ jobs:
const classCounters = parsed.classes;

// --- Coverage gate: fail if any class regresses on any metric ---
// A regression requires BOTH a percentage drop AND an increase in the
// absolute number of missed items. This avoids false positives when
// well-covered code is extracted/moved out of a class (which lowers the
// percentage without actually losing any test coverage).
const regressions = [];
for (const [cls, curr] of Object.entries(classCounters)) {
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
const classRegressions = [];
for (const [metric, key] of [['Line', 'line'], ['Branch', 'branch'], ['Method', 'method']]) {
const currPct = pct(curr[key].covered, curr[key].missed);
const basePct = pct(base[key].covered, base[key].missed);
if (currPct < basePct - 0.05) {
regressions.push(` ${cls} ${metric}: ${currPct.toFixed(1)}% (was ${basePct.toFixed(1)}%, delta ${(currPct - basePct).toFixed(1)}%)`);
const currMissed = curr[key].missed;
const baseMissed = base[key].missed;
if (currPct < basePct - 0.05 && currMissed > baseMissed) {
classRegressions.push(` ${cls} ${metric}: ${currPct.toFixed(1)}% (was ${basePct.toFixed(1)}%, delta ${(currPct - basePct).toFixed(1)}%, missed: ${currMissed} was ${baseMissed})`);
}
}
if (classRegressions.length > 0) {
regressions.push(...classRegressions);
// Add method-level details for this regression
const currMethods = curr.methods || [];
const baseMethods = (base.methods || []);
if (currMethods.length > 0 || baseMethods.length > 0) {
const baseByKey = {};
for (const m of baseMethods) baseByKey[m.name + m.desc] = m;
const seen = new Set();
for (const m of currMethods) {
const key = m.name + m.desc;
seen.add(key);
const bm = baseByKey[key];
const currLinePct = pct(m.counters.line.covered, m.counters.line.missed);
const baseLinePct = bm ? pct(bm.counters.line.covered, bm.counters.line.missed) : null;
const lineChanged = baseLinePct !== null && Math.abs(currLinePct - baseLinePct) >= 0.05;
const isNew = !bm;
// When baseline method data exists, only show methods that actually
// changed or are new. Fall back to showing all uncovered methods
// when no baseline method data is available yet.
const hasMissed = m.counters.line.missed > 0 || m.counters.branch.missed > 0;
const show = baseMethods.length > 0
? (lineChanged || isNew)
: (hasMissed || isNew);
if (show) {
const displayName = m.name === '<init>' ? 'constructor' : m.name;
let detail = ` ${displayName} — Line: ${currLinePct.toFixed(1)}%`;
if (baseLinePct !== null) detail += ` (was ${baseLinePct.toFixed(1)}%)`;
else detail += ' (new)';
regressions.push(detail);
}
}
for (const bm of baseMethods) {
if (!seen.has(bm.name + bm.desc)) {
const displayName = bm.name === '<init>' ? 'constructor' : bm.name;
regressions.push(` ${displayName} — removed`);
}
}
}
}
}
Expand Down