From e34408264356e0de898d18282dd9347c8af4dcd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 4 Aug 2026 16:44:53 +0800 Subject: [PATCH] feat: calculate incremental JaCoCo coverage --- .../aitest/coverage/IncrementalCoverage.java | 8 ++ .../IncrementalCoverageCalculator.java | 54 ++++++++ .../biz/aitest/coverage/JacocoLine.java | 15 +++ .../biz/aitest/coverage/JacocoXmlParser.java | 56 ++++++++ .../biz/aitest/git/ChangedLines.java | 15 +++ .../biz/aitest/git/GitIncrementAnalyzer.java | 120 ++++++++++++++++++ .../biz/aitest/git/IncrementAnalysis.java | 17 +++ .../IncrementalCoverageCalculatorTest.java | 46 +++++++ .../aitest/coverage/JacocoXmlParserTest.java | 37 ++++++ .../aitest/git/GitIncrementAnalyzerTest.java | 58 +++++++++ 10 files changed, 426 insertions(+) create mode 100644 src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverage.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculator.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/coverage/JacocoLine.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParser.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/ChangedLines.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzer.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/IncrementAnalysis.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculatorTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParserTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzerTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverage.java b/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverage.java new file mode 100644 index 0000000..3d433c1 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverage.java @@ -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) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculator.java b/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculator.java new file mode 100644 index 0000000..1cc6022 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculator.java @@ -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 report, + int targetPercent) { + Map> 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 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); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoLine.java b/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoLine.java new file mode 100644 index 0000000..0584741 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoLine.java @@ -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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParser.java b/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParser.java new file mode 100644 index 0000000..b294b87 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParser.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/ChangedLines.java b/src/main/java/com/example/workutils/biz/aitest/git/ChangedLines.java new file mode 100644 index 0000000..ef72fa8 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/ChangedLines.java @@ -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 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)); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzer.java b/src/main/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzer.java new file mode 100644 index 0000000..90844d0 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzer.java @@ -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> 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 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> parsePatch(String patch) { + Map> 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> 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 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 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> target, Map> 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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/IncrementAnalysis.java b/src/main/java/com/example/workutils/biz/aitest/git/IncrementAnalysis.java new file mode 100644 index 0000000..86d524f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/IncrementAnalysis.java @@ -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 files, boolean includesWorkingTree) { + public IncrementAnalysis { + files = List.copyOf(files); + } + + public Optional file(Path projectRelativePath) { + Path normalized = projectRelativePath.normalize(); + return files.stream().filter(file -> file.sourcePath().equals(normalized)).findFirst(); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculatorTest.java b/src/test/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculatorTest.java new file mode 100644 index 0000000..d0ccd4c --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/coverage/IncrementalCoverageCalculatorTest.java @@ -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 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()); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParserTest.java b/src/test/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParserTest.java new file mode 100644 index 0000000..471ca48 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/coverage/JacocoXmlParserTest.java @@ -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, """ + + + + + + + """, StandardCharsets.UTF_8); + MavenModule module = new MavenModule(root.resolve("core"), Path.of("core"), + "com.example", "core", "1", Set.of()); + + List 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()); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzerTest.java b/src/test/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzerTest.java new file mode 100644 index 0000000..ff2453b --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/git/GitIncrementAnalyzerTest.java @@ -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 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; + } +}