Skip to content

Commit e79d6d6

Browse files
Fix LCOV report generation to include uncovered executable lines
1 parent 97b264b commit e79d6d6

File tree

5 files changed

+125
-77
lines changed

5 files changed

+125
-77
lines changed

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/JacocoCoverageProcessor.java

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.jacoco.core.analysis.CoverageBuilder;
3939
import org.jacoco.core.analysis.IBundleCoverage;
4040
import org.jacoco.core.analysis.ICounter;
41+
import org.jacoco.core.analysis.ILine;
4142
import org.jacoco.core.analysis.IPackageCoverage;
4243
import org.jacoco.core.analysis.ISourceFileCoverage;
4344
import org.jacoco.core.analysis.ISourceNode;
@@ -427,7 +428,7 @@ private static long getLocalCoveragePercentage(IBundleCoverage coverageBundle) {
427428
private long mergeAndUploadCoverageReport(IBundleCoverage coverageBundle) {
428429
RepoIndex repoIndex = repoIndexProvider.getIndex();
429430

430-
Map<String, BitSet> mergedCoverageData = new TreeMap<>();
431+
Map<String, LinesCoverage> mergedCoverageData = new TreeMap<>();
431432

432433
int totalLines = 0, coveredLines = 0;
433434
for (IPackageCoverage packageCoverage : coverageBundle.getPackages()) {
@@ -444,16 +445,16 @@ private long mergeAndUploadCoverageReport(IBundleCoverage coverageBundle) {
444445
continue;
445446
}
446447

447-
BitSet sourceFileCoveredLines = getCoveredLines(sourceFile);
448+
LinesCoverage linesCoverage = getLinesCoverage(sourceFile);
448449
// backendCoverageData contains data for all modules in the repo,
449450
// but coverageBundle bundle only has source files that are relevant for the given module,
450451
// so we are not taking into account any backend coverage that is not relevant
451-
sourceFileCoveredLines.or(
452+
linesCoverage.coveredLines.or(
452453
backendCoverageData.getOrDefault(pathRelativeToIndexRoot, EMPTY_BIT_SET));
453454

454-
mergedCoverageData.put(pathRelativeToIndexRoot, sourceFileCoveredLines);
455+
mergedCoverageData.put(pathRelativeToIndexRoot, linesCoverage);
455456

456-
coveredLines += sourceFileCoveredLines.cardinality();
457+
coveredLines += linesCoverage.coveredLines.cardinality();
457458
totalLines += sourceFile.getLineCounter().getTotalCount();
458459
}
459460
}
@@ -463,7 +464,7 @@ private long mergeAndUploadCoverageReport(IBundleCoverage coverageBundle) {
463464
return Math.round((100d * coveredLines) / totalLines);
464465
}
465466

466-
private void uploadMergedCoverageReport(Map<String, BitSet> mergedCoverageData) {
467+
private void uploadMergedCoverageReport(Map<String, LinesCoverage> mergedCoverageData) {
467468
if (coverageReportUploader == null) {
468469
return;
469470
}
@@ -477,21 +478,25 @@ private void uploadMergedCoverageReport(Map<String, BitSet> mergedCoverageData)
477478
}
478479
}
479480

480-
private static BitSet getCoveredLines(ISourceNode coverage) {
481-
BitSet bitSet = new BitSet();
481+
private static LinesCoverage getLinesCoverage(ISourceNode coverage) {
482+
LinesCoverage linesCoverage = new LinesCoverage();
482483

483484
int firstLine = coverage.getFirstLine();
484485
if (firstLine == -1) {
485-
return bitSet;
486+
return linesCoverage;
486487
}
487488

488489
int lastLine = coverage.getLastLine();
489-
for (int line = firstLine; line <= lastLine; line++) {
490-
if (coverage.getLine(line).getStatus() >= ICounter.FULLY_COVERED) {
491-
bitSet.set(line);
490+
for (int lineIdx = firstLine; lineIdx <= lastLine; lineIdx++) {
491+
ILine line = coverage.getLine(lineIdx);
492+
if (line.getStatus() > ICounter.EMPTY) {
493+
linesCoverage.executableLines.set(lineIdx);
494+
if (line.getStatus() >= ICounter.FULLY_COVERED) {
495+
linesCoverage.coveredLines.set(lineIdx);
496+
}
492497
}
493498
}
494499

495-
return bitSet;
500+
return linesCoverage;
496501
}
497502
}

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/LcovReportWriter.java

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,60 @@
11
package datadog.trace.civisibility.coverage.report;
22

3+
// LcovReportWriter.java
4+
35
import java.io.IOException;
46
import java.io.StringWriter;
57
import java.io.UncheckedIOException;
68
import java.io.Writer;
7-
import java.util.BitSet;
89
import java.util.Map;
910
import java.util.Objects;
1011
import java.util.TreeMap;
11-
import org.jetbrains.annotations.NotNull;
12+
import javax.annotation.Nonnull;
1213

13-
/** Serializes coverage data into LCOV format. */
1414
public final class LcovReportWriter {
15+
private LcovReportWriter() {}
1516

16-
public static void write(Map<String, BitSet> coverage, Writer out) throws IOException {
17+
public static void write(Map<String, LinesCoverage> coverage, Writer out) throws IOException {
1718
Objects.requireNonNull(coverage, "coverage");
1819
Objects.requireNonNull(out, "out");
1920

20-
Map<String, BitSet> sorted = (coverage instanceof TreeMap) ? coverage : toTreeMap(coverage);
21+
Map<String, LinesCoverage> sorted =
22+
(coverage instanceof TreeMap) ? coverage : toTreeMap(coverage);
2123

22-
for (Map.Entry<String, BitSet> e : sorted.entrySet()) {
24+
for (Map.Entry<String, LinesCoverage> e : sorted.entrySet()) {
2325
String path = e.getKey();
24-
BitSet bits = e.getValue();
26+
LinesCoverage lc = e.getValue();
2527
if (path == null || path.isEmpty()) {
2628
continue;
2729
}
28-
if (bits == null) {
29-
bits = new BitSet();
30+
if (lc == null) {
31+
lc = new LinesCoverage();
3032
}
3133

3234
out.write("SF:" + path + "\n");
3335

34-
int hits = 0;
35-
for (int i = bits.nextSetBit(1); i >= 0; i = bits.nextSetBit(i + 1)) {
36-
out.write("DA:" + i + ",1\n");
37-
hits++;
36+
int lf = 0; // lines found (instrumented)
37+
int lh = 0; // lines hit (executed at least once)
38+
39+
for (int line = lc.executableLines.nextSetBit(1);
40+
line >= 0;
41+
line = lc.executableLines.nextSetBit(line + 1)) { // skip bit 0
42+
lf++;
43+
int count = lc.coveredLines.get(line) ? 1 : 0;
44+
lh += count;
45+
out.write("DA:" + line + "," + count + "\n");
3846
}
3947

40-
int lf = Math.max(0, bits.length() - 1); // exclude bit 0
41-
out.write("LH:" + hits + "\n");
48+
out.write("LH:" + lh + "\n");
4249
out.write("LF:" + lf + "\n");
4350
out.write("end_of_record\n");
4451
}
4552
}
4653

47-
@NotNull
48-
private static TreeMap<String, BitSet> toTreeMap(Map<String, BitSet> coverage) {
49-
TreeMap<String, BitSet> treeMap = new TreeMap<>();
50-
for (Map.Entry<String, BitSet> e : coverage.entrySet()) {
54+
@Nonnull
55+
private static TreeMap<String, LinesCoverage> toTreeMap(Map<String, LinesCoverage> coverage) {
56+
TreeMap<String, LinesCoverage> treeMap = new TreeMap<>();
57+
for (Map.Entry<String, LinesCoverage> e : coverage.entrySet()) {
5158
if (e.getKey() == null) {
5259
continue;
5360
}
@@ -56,7 +63,7 @@ private static TreeMap<String, BitSet> toTreeMap(Map<String, BitSet> coverage) {
5663
return treeMap;
5764
}
5865

59-
public static String toString(Map<String, BitSet> coverage) {
66+
public static String toString(Map<String, LinesCoverage> coverage) {
6067
try {
6168
StringWriter sw = new StringWriter();
6269
write(coverage, sw);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package datadog.trace.civisibility.coverage.report;
2+
3+
import java.util.BitSet;
4+
5+
public final class LinesCoverage {
6+
public final BitSet coveredLines = new BitSet();
7+
public final BitSet executableLines = new BitSet();
8+
}

dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/coverage/report/LcovReportWriterTest.groovy

Lines changed: 70 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,34 @@ import spock.lang.Specification
55
class LcovReportWriterTest extends Specification {
66

77
def "empty map produces empty output"() {
8-
given:
9-
Map<String, BitSet> m = Collections.emptyMap()
10-
118
expect:
12-
LcovReportWriter.toString(m) == ""
9+
LcovReportWriter.toString(Collections.emptyMap()) == ""
1310
}
1411

15-
def "single file simple lines"() {
12+
def "single file with executable and covered lines"() {
1613
given:
17-
def bs = new BitSet()
18-
bs.set(1)
19-
bs.set(3)
20-
Map<String, BitSet> m = Collections.singletonMap("src/Foo.java", bs)
14+
def lc = new LinesCoverage()
15+
lc.executableLines.set(1); lc.executableLines.set(2); lc.executableLines.set(3)
16+
lc.coveredLines.set(1); lc.coveredLines.set(3)
17+
def m = Collections.singletonMap("src/Foo.java", lc)
2118

22-
when:
23-
def out = LcovReportWriter.toString(m)
24-
25-
then:
26-
out == """SF:src/Foo.java
19+
expect:
20+
LcovReportWriter.toString(m) == """SF:src/Foo.java
2721
DA:1,1
22+
DA:2,0
2823
DA:3,1
2924
LH:2
3025
LF:3
3126
end_of_record
3227
"""
3328
}
3429

35-
def "ignores bit 0 and handles large lines"() {
30+
def "ignores bit 0 in both sets and handles very large lines"() {
3631
given:
37-
def bs = new BitSet()
38-
bs.set(0) // ignored
39-
bs.set(2)
40-
bs.set(10000)
41-
Map<String, BitSet> m = Collections.singletonMap("a.java", bs)
32+
def lc = new LinesCoverage()
33+
lc.executableLines.set(0); lc.executableLines.set(2); lc.executableLines.set(10000)
34+
lc.coveredLines.set(0); lc.coveredLines.set(2); lc.coveredLines.set(10000)
35+
def m = Collections.singletonMap("a.java", lc)
4236

4337
when:
4438
def out = LcovReportWriter.toString(m)
@@ -47,18 +41,24 @@ end_of_record
4741
out.contains("SF:a.java\n")
4842
out.contains("DA:2,1\n")
4943
out.contains("DA:10000,1\n")
50-
!out.contains("DA:0,1\n")
44+
!out.contains("DA:0,")
5145
out.contains("LH:2\n")
52-
out.contains("LF:10000\n") // length()=10001 -> LF=10000
46+
out.contains("LF:2\n")
5347
}
5448

5549
def "multiple files are sorted and lines within a file are sorted"() {
5650
given:
57-
def b1 = new BitSet(); b1.set(5); b1.set(2)
58-
def b2 = new BitSet(); b2.set(1)
59-
def m = new LinkedHashMap<String, BitSet>()
60-
m.put("z/FileZ.java", b2)
61-
m.put("a/FileA.java", b1)
51+
def a = new LinesCoverage()
52+
a.executableLines.set(2); a.executableLines.set(5)
53+
a.coveredLines.set(5)
54+
55+
def z = new LinesCoverage()
56+
z.executableLines.set(1)
57+
z.coveredLines.set(1)
58+
59+
def m = new LinkedHashMap<String, LinesCoverage>()
60+
m.put("z/FileZ.java", z)
61+
m.put("a/FileA.java", a)
6262

6363
when:
6464
def out = LcovReportWriter.toString(m)
@@ -68,22 +68,19 @@ end_of_record
6868
def idxZ = out.indexOf("SF:z/FileZ.java\n")
6969
idxA >= 0 && idxZ > idxA
7070

71-
and: "lines sorted within a/FileA.java (2 then 5)"
71+
and: "lines sorted within a/FileA.java (2 then 5) with correct hit counts"
7272
def blockA = out.substring(idxA, idxZ)
73-
blockA.indexOf("DA:2,1\n") >= 0 &&
74-
blockA.indexOf("DA:5,1\n") > blockA.indexOf("DA:2,1\n")
73+
blockA.indexOf("DA:2,0\n") >= 0 &&
74+
blockA.indexOf("DA:5,1\n") > blockA.indexOf("DA:2,0\n")
7575
}
7676

77-
def "null BitSet is treated as empty"() {
77+
def "null LinesCoverage is treated as empty"() {
7878
given:
79-
def m = new LinkedHashMap<String, BitSet>()
79+
def m = new LinkedHashMap<String, LinesCoverage>()
8080
m.put("empty.java", null)
8181

82-
when:
83-
def out = LcovReportWriter.toString(m)
84-
85-
then:
86-
out == """SF:empty.java
82+
expect:
83+
LcovReportWriter.toString(m) == """SF:empty.java
8784
LH:0
8885
LF:0
8986
end_of_record
@@ -92,14 +89,44 @@ end_of_record
9289

9390
def "skips empty or null path entries"() {
9491
given:
95-
def bs = new BitSet()
96-
bs.set(1)
97-
98-
def m = new LinkedHashMap<String, BitSet>()
99-
m.put("", bs)
100-
m.put(null, bs)
92+
def lc = new LinesCoverage()
93+
lc.executableLines.set(1); lc.coveredLines.set(1)
94+
def m = new LinkedHashMap<String, LinesCoverage>()
95+
m.put("", lc)
96+
m.put(null, lc)
10197

10298
expect:
10399
LcovReportWriter.toString(m) == ""
104100
}
101+
102+
def "covered lines outside executable set are ignored in DA and LH"() {
103+
given:
104+
def lc = new LinesCoverage()
105+
lc.executableLines.set(1); lc.executableLines.set(2)
106+
lc.coveredLines.set(3) // not executable -> ignored
107+
def m = Collections.singletonMap("X.java", lc)
108+
109+
expect:
110+
LcovReportWriter.toString(m) == """SF:X.java
111+
DA:1,0
112+
DA:2,0
113+
LH:0
114+
LF:2
115+
end_of_record
116+
"""
117+
}
118+
119+
def "no executable lines even if covered bits exist -> no DA, LF=LH=0"() {
120+
given:
121+
def lc = new LinesCoverage()
122+
lc.coveredLines.set(1); lc.coveredLines.set(2)
123+
def m = Collections.singletonMap("Y.java", lc)
124+
125+
expect:
126+
LcovReportWriter.toString(m) == """SF:Y.java
127+
LH:0
128+
LF:0
129+
end_of_record
130+
"""
131+
}
105132
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
SF:src/main/java/datadog/smoke/Calculator.java
2+
DA:3,0
23
DA:5,1
34
DA:9,1
45
LH:2
5-
LF:9
6+
LF:3
67
end_of_record

0 commit comments

Comments
 (0)