feat: calculate incremental JaCoCo coverage

This commit is contained in:
张家训
2026-08-04 16:44:53 +08:00
parent 84c618199b
commit e344082643
10 changed files with 426 additions and 0 deletions
@@ -0,0 +1,8 @@
package com.example.workutils.biz.aitest.coverage;
import java.math.BigDecimal;
public record IncrementalCoverage(int executableLines, int coveredLines,
BigDecimal linePercent, BigDecimal branchPercent,
int targetPercent, boolean applicable, boolean passed) {
}
@@ -0,0 +1,54 @@
package com.example.workutils.biz.aitest.coverage;
import com.example.workutils.biz.aitest.git.IncrementAnalysis;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class IncrementalCoverageCalculator {
public IncrementalCoverage calculate(IncrementAnalysis analysis, List<JacocoLine> report,
int targetPercent) {
Map<Path, Set<Integer>> changed = new HashMap<>();
analysis.files().forEach(file -> changed.put(file.sourcePath().normalize(), file.lines()));
int executable = 0;
int covered = 0;
int missedBranches = 0;
int coveredBranches = 0;
for (JacocoLine line : report) {
Set<Integer> lines = changed.get(line.sourcePath().normalize());
if (lines == null || !lines.contains(line.lineNumber()) || !line.executable()) {
continue;
}
executable++;
if (line.covered()) {
covered++;
}
missedBranches += line.missedBranches();
coveredBranches += line.coveredBranches();
}
if (executable == 0) {
return new IncrementalCoverage(0, 0, BigDecimal.ZERO.setScale(2),
BigDecimal.ZERO.setScale(2), targetPercent, false, true);
}
BigDecimal linePercent = percent(covered, executable);
BigDecimal branchPercent = percent(coveredBranches, coveredBranches + missedBranches);
return new IncrementalCoverage(executable, covered, linePercent, branchPercent,
targetPercent, true, linePercent.compareTo(BigDecimal.valueOf(targetPercent)) >= 0);
}
private static BigDecimal percent(int numerator, int denominator) {
if (denominator == 0) {
return BigDecimal.ZERO.setScale(2);
}
return BigDecimal.valueOf(numerator).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(denominator), 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,15 @@
package com.example.workutils.biz.aitest.coverage;
import java.nio.file.Path;
public record JacocoLine(Path sourcePath, int lineNumber,
int missedInstructions, int coveredInstructions,
int missedBranches, int coveredBranches) {
public boolean executable() {
return missedInstructions + coveredInstructions > 0;
}
public boolean covered() {
return coveredInstructions > 0;
}
}
@@ -0,0 +1,56 @@
package com.example.workutils.biz.aitest.coverage;
import com.example.workutils.biz.aitest.project.MavenModule;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@Component
public class JacocoXmlParser {
public List<JacocoLine> parse(MavenModule module, Path report) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
Document document = factory.newDocumentBuilder().parse(report.toFile());
List<JacocoLine> result = new ArrayList<>();
NodeList packages = document.getElementsByTagName("package");
for (int p = 0; p < packages.getLength(); p++) {
Element packageElement = (Element) packages.item(p);
String packagePath = packageElement.getAttribute("name");
NodeList sources = packageElement.getElementsByTagName("sourcefile");
for (int s = 0; s < sources.getLength(); s++) {
Element source = (Element) sources.item(s);
Path sourcePath = module.relativePath().resolve("src/main/java")
.resolve(packagePath).resolve(source.getAttribute("name")).normalize();
NodeList lines = source.getElementsByTagName("line");
for (int l = 0; l < lines.getLength(); l++) {
Element line = (Element) lines.item(l);
result.add(new JacocoLine(sourcePath,
integer(line, "nr"), integer(line, "mi"), integer(line, "ci"),
integer(line, "mb"), integer(line, "cb")));
}
}
}
return List.copyOf(result);
} catch (Exception e) {
throw new IllegalArgumentException("Unable to parse JaCoCo report: " + report, e);
}
}
private static int integer(Element element, String attribute) {
String value = element.getAttribute(attribute);
return value.isBlank() ? 0 : Integer.parseInt(value);
}
}
@@ -0,0 +1,15 @@
package com.example.workutils.biz.aitest.git;
import java.nio.file.Path;
import java.util.Set;
import java.util.TreeSet;
public record ChangedLines(Path sourcePath, Set<Integer> lines) {
public ChangedLines {
sourcePath = sourcePath.normalize();
if (sourcePath.isAbsolute() || sourcePath.startsWith("..")) {
throw new IllegalArgumentException("Changed source path must be project-relative: " + sourcePath);
}
lines = Set.copyOf(new TreeSet<>(lines));
}
}
@@ -0,0 +1,120 @@
package com.example.workutils.biz.aitest.git;
import com.example.workutils.biz.aitest.process.CommandResult;
import com.example.workutils.biz.aitest.process.CommandSpec;
import com.example.workutils.biz.aitest.process.ProcessExecutor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
public class GitIncrementAnalyzer {
private static final Pattern HUNK = Pattern.compile("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@.*$");
private final ProcessExecutor processExecutor;
public IncrementAnalysis analyze(Path project, String baseline, boolean includeWorkingTree) {
Path root = project.toAbsolutePath().normalize();
String baselineCommit = git(root, "rev-parse", "--verify", baseline + "^{commit}").trim();
String mergeBase = git(root, "merge-base", baselineCommit, "HEAD").trim();
Map<Path, Set<Integer>> changed = new LinkedHashMap<>();
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
"--unified=0", "--no-color", mergeBase + "...HEAD")));
if (includeWorkingTree) {
merge(changed, parsePatch(git(root, "-c", "core.quotepath=false", "diff",
"--unified=0", "--no-color", "HEAD")));
addUntrackedJavaFiles(root, changed);
}
List<ChangedLines> files = changed.entrySet().stream()
.filter(entry -> entry.getKey().toString().replace('\\', '/').contains("/src/main/java/"))
.filter(entry -> entry.getKey().toString().endsWith(".java"))
.map(entry -> new ChangedLines(entry.getKey(), entry.getValue()))
.toList();
return new IncrementAnalysis(baseline, mergeBase, files, includeWorkingTree);
}
static Map<Path, Set<Integer>> parsePatch(String patch) {
Map<Path, Set<Integer>> result = new LinkedHashMap<>();
Path currentFile = null;
int newLine = 0;
for (String line : patch.split("\\R", -1)) {
if (line.startsWith("+++ ")) {
String value = line.substring(4).trim();
currentFile = "/dev/null".equals(value) ? null : Path.of(stripPrefix(value, "b/"));
continue;
}
Matcher hunk = HUNK.matcher(line);
if (hunk.matches()) {
newLine = Integer.parseInt(hunk.group(1));
continue;
}
if (currentFile == null || line.startsWith("\\ No newline")) {
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
result.computeIfAbsent(currentFile, ignored -> new TreeSet<>()).add(newLine++);
} else if (line.startsWith(" ")) {
newLine++;
}
}
return result;
}
private void addUntrackedJavaFiles(Path root, Map<Path, Set<Integer>> changed) {
String output = git(root, "-c", "core.quotepath=false", "ls-files", "--others",
"--exclude-standard", "--", "*.java");
for (String line : output.split("\\R")) {
if (line.isBlank()) {
continue;
}
Path relative = Path.of(line.trim()).normalize();
Path file = root.resolve(relative).normalize();
if (!file.startsWith(root) || Files.notExists(file)) {
continue;
}
try {
int count = Files.readAllLines(file, StandardCharsets.UTF_8).size();
Set<Integer> lines = changed.computeIfAbsent(relative, ignored -> new TreeSet<>());
for (int i = 1; i <= count; i++) {
lines.add(i);
}
} catch (Exception e) {
throw new IllegalStateException("Unable to read untracked Java file: " + file, e);
}
}
}
private String git(Path root, String... arguments) {
List<String> command = new ArrayList<>();
command.add("git");
command.addAll(List.of(arguments));
CommandResult result = processExecutor.execute(
new CommandSpec(root, command, Map.of(), Duration.ofSeconds(60)), ignored -> { });
if (!result.successful()) {
throw new IllegalArgumentException("Git command failed: " + String.join(" ", command)
+ System.lineSeparator() + result.stderr() + result.stdout());
}
return result.stdout();
}
private static void merge(Map<Path, Set<Integer>> target, Map<Path, Set<Integer>> source) {
source.forEach((path, lines) -> target.computeIfAbsent(path, ignored -> new TreeSet<>()).addAll(lines));
}
private static String stripPrefix(String value, String prefix) {
return value.startsWith(prefix) ? value.substring(prefix.length()) : value;
}
}
@@ -0,0 +1,17 @@
package com.example.workutils.biz.aitest.git;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
public record IncrementAnalysis(String baseline, String mergeBase,
List<ChangedLines> files, boolean includesWorkingTree) {
public IncrementAnalysis {
files = List.copyOf(files);
}
public Optional<ChangedLines> file(Path projectRelativePath) {
Path normalized = projectRelativePath.normalize();
return files.stream().filter(file -> file.sourcePath().equals(normalized)).findFirst();
}
}
@@ -0,0 +1,46 @@
package com.example.workutils.biz.aitest.coverage;
import com.example.workutils.biz.aitest.git.ChangedLines;
import com.example.workutils.biz.aitest.git.IncrementAnalysis;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class IncrementalCoverageCalculatorTest {
private final IncrementalCoverageCalculator calculator = new IncrementalCoverageCalculator();
@Test
void countsOnlyChangedExecutableCoveredLines() {
Path source = Path.of("core/src/main/java/a/A.java");
IncrementAnalysis analysis = new IncrementAnalysis("develop", "abc",
List.of(new ChangedLines(source, Set.of(10, 11, 12))), false);
List<JacocoLine> report = List.of(
new JacocoLine(source, 10, 0, 1, 1, 1),
new JacocoLine(source, 11, 1, 0, 2, 0));
IncrementalCoverage result = calculator.calculate(analysis, report, 85);
assertEquals(2, result.executableLines());
assertEquals(1, result.coveredLines());
assertEquals(new BigDecimal("50.00"), result.linePercent());
assertEquals(new BigDecimal("25.00"), result.branchPercent());
assertFalse(result.passed());
}
@Test
void noExecutableChangedLinesIsNotApplicableAndDoesNotFail() {
IncrementAnalysis analysis = new IncrementAnalysis("develop", "abc",
List.of(new ChangedLines(Path.of("README.md"), Set.of(1))), false);
IncrementalCoverage result = calculator.calculate(analysis, List.of(), 85);
assertFalse(result.applicable());
assertTrue(result.passed());
}
}
@@ -0,0 +1,37 @@
package com.example.workutils.biz.aitest.coverage;
import com.example.workutils.biz.aitest.project.MavenModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class JacocoXmlParserTest {
@Test
void mapsSourceFilesToProjectRelativeModulePaths(@TempDir Path root) throws Exception {
Path xml = root.resolve("jacoco.xml");
Files.writeString(xml, """
<?xml version="1.0" encoding="UTF-8"?>
<report name="core"><package name="com/example">
<sourcefile name="OrderService.java">
<line nr="12" mi="0" ci="3" mb="1" cb="1"/>
</sourcefile>
</package></report>
""", StandardCharsets.UTF_8);
MavenModule module = new MavenModule(root.resolve("core"), Path.of("core"),
"com.example", "core", "1", Set.of());
List<JacocoLine> lines = new JacocoXmlParser().parse(module, xml);
assertEquals(1, lines.size());
assertEquals(Path.of("core/src/main/java/com/example/OrderService.java"), lines.get(0).sourcePath());
assertEquals(12, lines.get(0).lineNumber());
}
}
@@ -0,0 +1,58 @@
package com.example.workutils.biz.aitest.git;
import com.example.workutils.biz.aitest.process.DefaultProcessExecutor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GitIncrementAnalyzerTest {
@Test
void extractsCommittedAndOptionalWorkingTreeLines(@TempDir Path repository) throws Exception {
git(repository, "init");
git(repository, "config", "user.email", "workutils@example.com");
git(repository, "config", "user.name", "WorkUtils Test");
Path source = repository.resolve("core/src/main/java/a/A.java");
write(source, "class A {\n int value() { return 1; }\n}\n");
git(repository, "add", ".");
git(repository, "commit", "-m", "base");
String baseline = git(repository, "rev-parse", "HEAD").trim();
write(source, "class A {\n int value() { return 2; }\n int next() { return 3; }\n}\n");
git(repository, "add", ".");
git(repository, "commit", "-m", "change");
write(source, "class A {\n int value() { return 2; }\n int next() { return 4; }\n}\n");
GitIncrementAnalyzer analyzer = new GitIncrementAnalyzer(new DefaultProcessExecutor());
IncrementAnalysis committed = analyzer.analyze(repository, baseline, false);
IncrementAnalysis withWorkingTree = analyzer.analyze(repository, baseline, true);
assertEquals(Set.of(2, 3), committed.files().get(0).lines());
assertTrue(withWorkingTree.files().get(0).lines().containsAll(Set.of(2, 3)));
assertTrue(withWorkingTree.includesWorkingTree());
}
private static void write(Path file, String content) throws Exception {
Files.createDirectories(file.getParent());
Files.writeString(file, content, StandardCharsets.UTF_8);
}
private static String git(Path directory, String... arguments) throws Exception {
List<String> command = new java.util.ArrayList<>();
command.add("git");
command.addAll(List.of(arguments));
Process process = new ProcessBuilder(command).directory(directory.toFile())
.redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertEquals(0, process.waitFor(), output);
return output;
}
}