feat: calculate incremental JaCoCo coverage
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user