@@ -133,10 +133,12 @@ jobs:
133133 baseline = JSON.parse(fs.readFileSync(baselineFile, 'utf8'));
134134 }
135135 const baseTests = baseline.tests || {};
136- const baseCov = baseline.coverage || {};
136+ const baseCov = (baseline.coverage || {}).overall || {};
137+ const baseClasses = (baseline.coverage || {}).classes || {};
137138
138139 // --- Parse JaCoCo XML for coverage ---
139140 let covLine = null, covBranch = null, covMethod = null;
141+ const classCounters = {};
140142 const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
141143 if (fs.existsSync(jacocoFile)) {
142144 const xml = fs.readFileSync(jacocoFile, 'utf8');
@@ -150,6 +152,30 @@ jobs:
150152 else if (m[1] === 'BRANCH') covBranch = entry;
151153 else if (m[1] === 'METHOD') covMethod = entry;
152154 }
155+
156+ // Extract per-class counters from <package>/<class> elements
157+ const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
158+ let pkgMatch;
159+ while ((pkgMatch = pkgRe.exec(xml)) !== null) {
160+ const pkgName = pkgMatch[1].replace(/\//g, '.');
161+ const pkgBody = pkgMatch[2];
162+ const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
163+ let classMatch;
164+ while ((classMatch = classRe.exec(pkgBody)) !== null) {
165+ const className = classMatch[1].replace(/\//g, '.');
166+ const classBody = classMatch[2];
167+ const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
168+ const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
169+ let cntMatch;
170+ while ((cntMatch = cntRe.exec(classBody)) !== null) {
171+ const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
172+ if (cntMatch[1] === 'LINE') counters.line = entry;
173+ else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
174+ else if (cntMatch[1] === 'METHOD') counters.method = entry;
175+ }
176+ classCounters[className] = counters;
177+ }
178+ }
153179 }
154180
155181 // --- Helpers ---
@@ -232,6 +258,66 @@ jobs:
232258 body += `| ${name} | ${curr.covered} | ${curr.missed} | ${fmtPct(currPct)} | ${fmtPctDelta(currPct, basePct)} |\n`;
233259 }
234260
261+ body += '\n';
262+
263+ // --- Per-class coverage deltas ---
264+ const changedClasses = [];
265+ for (const [cls, curr] of Object.entries(classCounters)) {
266+ const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
267+ const currLinePct = pct(curr.line.covered, curr.line.missed);
268+ const baseLinePct = pct(base.line.covered, base.line.missed);
269+ const currBranchPct = pct(curr.branch.covered, curr.branch.missed);
270+ const baseBranchPct = pct(base.branch.covered, base.branch.missed);
271+ const currMethodPct = pct(curr.method.covered, curr.method.missed);
272+ const baseMethodPct = pct(base.method.covered, base.method.missed);
273+ if (Math.abs(currLinePct - baseLinePct) >= 0.05 ||
274+ Math.abs(currBranchPct - baseBranchPct) >= 0.05 ||
275+ Math.abs(currMethodPct - baseMethodPct) >= 0.05) {
276+ changedClasses.push({
277+ name: cls,
278+ linePct: currLinePct, lineDelta: currLinePct - baseLinePct,
279+ branchPct: currBranchPct, branchDelta: currBranchPct - baseBranchPct,
280+ methodPct: currMethodPct, methodDelta: currMethodPct - baseMethodPct,
281+ });
282+ }
283+ }
284+ // Also detect classes removed (in baseline but not in current)
285+ for (const cls of Object.keys(baseClasses)) {
286+ if (!classCounters[cls]) {
287+ changedClasses.push({
288+ name: cls,
289+ linePct: 0, lineDelta: -pct(baseClasses[cls].line.covered, baseClasses[cls].line.missed),
290+ branchPct: 0, branchDelta: -pct(baseClasses[cls].branch.covered, baseClasses[cls].branch.missed),
291+ methodPct: 0, methodDelta: -pct(baseClasses[cls].method.covered, baseClasses[cls].method.missed),
292+ removed: true,
293+ });
294+ }
295+ }
296+
297+ changedClasses.sort((a, b) => a.name.localeCompare(b.name));
298+
299+ function fmtClassPct(val, delta) {
300+ const pctStr = fmtPct(val);
301+ const deltaStr = fmtPctDelta(val, val - delta);
302+ return `${pctStr} (${deltaStr})`;
303+ }
304+
305+ if (changedClasses.length > 0) {
306+ body += `<details><summary>Changed Class Coverage (${changedClasses.length} ${changedClasses.length === 1 ? 'class' : 'classes'})</summary>\n\n`;
307+ body += '| Class | Line | Branch | Method |\n';
308+ body += '|:------|-----:|-------:|-------:|\n';
309+ for (const c of changedClasses) {
310+ if (c.removed) {
311+ body += `| \`${c.name}\` | *removed* | *removed* | *removed* |\n`;
312+ } else {
313+ body += `| \`${c.name}\` | ${fmtClassPct(c.linePct, c.lineDelta)} | ${fmtClassPct(c.branchPct, c.branchDelta)} | ${fmtClassPct(c.methodPct, c.methodDelta)} |\n`;
314+ }
315+ }
316+ body += '\n</details>\n';
317+ } else {
318+ body += '> No per-class coverage changes detected.\n';
319+ }
320+
235321 body += '\n> Full HTML report: build artifact `jacoco-html-report`\n';
236322 }
237323
0 commit comments