|
| 1 | +package io.keploy.dedup; |
| 2 | + |
| 3 | +import com.google.gson.Gson; |
| 4 | +import com.google.gson.reflect.TypeToken; |
| 5 | +import org.jacoco.core.analysis.Analyzer; |
| 6 | +import org.jacoco.core.analysis.CoverageBuilder; |
| 7 | +import org.jacoco.core.analysis.IClassCoverage; |
| 8 | +import org.jacoco.core.analysis.ICounter; |
| 9 | +import org.jacoco.core.data.ExecutionData; |
| 10 | +import org.jacoco.core.data.ExecutionDataStore; |
| 11 | + |
| 12 | +import java.io.File; |
| 13 | +import java.io.IOException; |
| 14 | +import java.lang.reflect.Type; |
| 15 | +import java.nio.charset.StandardCharsets; |
| 16 | +import java.nio.file.Files; |
| 17 | +import java.nio.file.Path; |
| 18 | +import java.nio.file.Paths; |
| 19 | +import java.util.List; |
| 20 | +import java.util.Map; |
| 21 | + |
| 22 | +/** |
| 23 | + * Offline, one-shot line + branch coverage computation for the dynamic-dedup |
| 24 | + * "Option B" flow. Runs OUTSIDE the app JVM (as a short-lived job invoked by |
| 25 | + * k8s-proxy at recording stop), so it needs no live JaCoCo agent — only: |
| 26 | + * |
| 27 | + * <ul> |
| 28 | + * <li><b>classes</b> — the app's compiled {@code .class} files (or a jar), |
| 29 | + * i.e. the bytecode blob stored once per build tag;</li> |
| 30 | + * <li><b>union</b> — {@code {vmClassName -> [firedProbeIdx]}}: the union of |
| 31 | + * every kept test-case fingerprint for the test set (across all |
| 32 | + * auto-replay cycles). Reuses the exact {@code dedupFingerprints} probe |
| 33 | + * sets already persisted for cross-cycle dedup;</li> |
| 34 | + * <li><b>manifest</b> — {@code {vmClassName -> {id, probeCount}}}: the JaCoCo |
| 35 | + * class id (CRC64, as an unsigned-hex string) and total probe count per |
| 36 | + * class. These are build-constant and captured by the SDK from the live |
| 37 | + * {@code ExecutionData} at record time ({@code getId()} / |
| 38 | + * {@code getProbes().length}), so the reporter never has to touch JaCoCo |
| 39 | + * internal APIs to derive them.</li> |
| 40 | + * </ul> |
| 41 | + * |
| 42 | + * <p>Reconstruction: for each class in the union we build an |
| 43 | + * {@link ExecutionData} with the manifest's id + a {@code boolean[probeCount]} |
| 44 | + * whose fired indices are set, put it in an {@link ExecutionDataStore}, then run |
| 45 | + * the JaCoCo {@link Analyzer} over the classes. The analyzer computes each |
| 46 | + * class's CRC64 id from its bytecode and matches it against the store; a class |
| 47 | + * with no entry (never hit) is analyzed with all-missed probes so it still |
| 48 | + * contributes to the DENOMINATOR. Summing {@code getLineCounter()} / |
| 49 | + * {@code getBranchCounter()} across all classes yields the whole test set's |
| 50 | + * line + branch coverage. |
| 51 | + * |
| 52 | + * <p>Because dedup is coverage-preserving (it only drops exact/subset |
| 53 | + * duplicates), the union of the KEPT fingerprints equals the union of every |
| 54 | + * replayed test, so this number is the true whole-test-set coverage. |
| 55 | + * |
| 56 | + * <p>Output ({@code --out}) is a JSON object: |
| 57 | + * {@code {"lineCovered":N,"lineTotal":N,"branchCovered":N,"branchTotal":N, |
| 58 | + * "instructionCovered":N,"instructionTotal":N,"methodCovered":N, |
| 59 | + * "methodTotal":N,"classCount":N,"hitClassCount":N}}. |
| 60 | + */ |
| 61 | +public final class CoverageReporter { |
| 62 | + |
| 63 | + private static final Gson GSON = new Gson(); |
| 64 | + |
| 65 | + private CoverageReporter() { |
| 66 | + } |
| 67 | + |
| 68 | + /** JaCoCo class id + total probe count for one class (build-constant). */ |
| 69 | + static final class ClassMeta { |
| 70 | + String id; // CRC64 class id, unsigned-hex (from ExecutionData.getId()) |
| 71 | + int probeCount; // ExecutionData.getProbes().length |
| 72 | + } |
| 73 | + |
| 74 | + /** Computed coverage counters, serialized to the --out file. */ |
| 75 | + static final class CoverageResult { |
| 76 | + long lineCovered, lineTotal; |
| 77 | + long branchCovered, branchTotal; |
| 78 | + long instructionCovered, instructionTotal; |
| 79 | + long methodCovered, methodTotal; |
| 80 | + int classCount; // classes analyzed (denominator basis) |
| 81 | + int hitClassCount; // classes with at least one fired probe |
| 82 | + } |
| 83 | + |
| 84 | + public static void main(String[] args) { |
| 85 | + try { |
| 86 | + Args parsed = Args.parse(args); |
| 87 | + CoverageResult result = compute(parsed.classesPath, parsed.unionPath, parsed.manifestPath); |
| 88 | + String json = GSON.toJson(result); |
| 89 | + if (parsed.outPath != null) { |
| 90 | + Files.write(parsed.outPath, json.getBytes(StandardCharsets.UTF_8)); |
| 91 | + } else { |
| 92 | + System.out.println(json); |
| 93 | + } |
| 94 | + } catch (Exception e) { |
| 95 | + System.err.println("CoverageReporter failed: " + e.getMessage()); |
| 96 | + e.printStackTrace(); |
| 97 | + System.exit(1); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + /** |
| 102 | + * Computes coverage from a classes dir/jar, a fired-probe union, and the |
| 103 | + * per-class id/probe-count manifest. Package-visible for unit testing. |
| 104 | + */ |
| 105 | + static CoverageResult compute(Path classesPath, Path unionPath, Path manifestPath) throws IOException { |
| 106 | + Map<String, List<Integer>> union = readUnion(unionPath); |
| 107 | + Map<String, ClassMeta> manifest = readManifest(manifestPath); |
| 108 | + |
| 109 | + ExecutionDataStore store = new ExecutionDataStore(); |
| 110 | + int hitClasses = 0; |
| 111 | + for (Map.Entry<String, ClassMeta> entry : manifest.entrySet()) { |
| 112 | + String className = entry.getKey(); |
| 113 | + ClassMeta meta = entry.getValue(); |
| 114 | + if (meta == null || meta.id == null || meta.probeCount < 0) { |
| 115 | + continue; |
| 116 | + } |
| 117 | + boolean[] probes = new boolean[meta.probeCount]; |
| 118 | + List<Integer> fired = union.get(className); |
| 119 | + if (fired != null) { |
| 120 | + for (Integer idx : fired) { |
| 121 | + if (idx != null && idx >= 0 && idx < probes.length) { |
| 122 | + probes[idx] = true; |
| 123 | + } |
| 124 | + } |
| 125 | + if (!fired.isEmpty()) { |
| 126 | + hitClasses++; |
| 127 | + } |
| 128 | + } |
| 129 | + long id = Long.parseUnsignedLong(meta.id, 16); |
| 130 | + store.put(new ExecutionData(id, className, probes)); |
| 131 | + } |
| 132 | + |
| 133 | + CoverageBuilder builder = new CoverageBuilder(); |
| 134 | + Analyzer analyzer = new Analyzer(store, builder); |
| 135 | + File classesFile = classesPath.toFile(); |
| 136 | + if (!classesFile.exists()) { |
| 137 | + throw new IOException("classes path does not exist: " + classesPath); |
| 138 | + } |
| 139 | + // analyzeAll walks a dir tree (or a jar) and analyzes every .class, |
| 140 | + // computing each class's CRC64 id and matching it against the store. |
| 141 | + // |
| 142 | + // LIMITATION — load-time-transformed classes may undercount: the store id |
| 143 | + // comes from the LIVE ExecutionData at record time, but analyzeAll hashes |
| 144 | + // the ON-DISK .class bytes. If a class was transformed before JaCoCo saw it |
| 145 | + // (another -javaagent, CGLIB, load-time weaving — common in Spring apps), |
| 146 | + // the two CRC64 ids differ, so the analyzed class won't match its store |
| 147 | + // entry: its fired probes are dropped from the numerator while it still |
| 148 | + // counts toward the denominator, deflating the reported coverage. Dedup is |
| 149 | + // UNAFFECTED (probe sets are compared like-for-like, never against on-disk |
| 150 | + // bytes) — only the reported line/branch numbers can be low. To fully fix, |
| 151 | + // export the exact instrumented bytes (or detect+log id mismatches here). |
| 152 | + analyzer.analyzeAll(classesFile); |
| 153 | + |
| 154 | + CoverageResult result = new CoverageResult(); |
| 155 | + int classCount = 0; |
| 156 | + for (IClassCoverage cc : builder.getClasses()) { |
| 157 | + classCount++; |
| 158 | + result.lineCovered += cc.getLineCounter().getCoveredCount(); |
| 159 | + result.lineTotal += cc.getLineCounter().getTotalCount(); |
| 160 | + result.branchCovered += cc.getBranchCounter().getCoveredCount(); |
| 161 | + result.branchTotal += cc.getBranchCounter().getTotalCount(); |
| 162 | + result.instructionCovered += cc.getInstructionCounter().getCoveredCount(); |
| 163 | + result.instructionTotal += cc.getInstructionCounter().getTotalCount(); |
| 164 | + result.methodCovered += cc.getMethodCounter().getCoveredCount(); |
| 165 | + result.methodTotal += cc.getMethodCounter().getTotalCount(); |
| 166 | + } |
| 167 | + result.classCount = classCount; |
| 168 | + result.hitClassCount = hitClasses; |
| 169 | + return result; |
| 170 | + } |
| 171 | + |
| 172 | + private static Map<String, List<Integer>> readUnion(Path path) throws IOException { |
| 173 | + Type type = new TypeToken<Map<String, List<Integer>>>() { |
| 174 | + }.getType(); |
| 175 | + Map<String, List<Integer>> union = GSON.fromJson(readString(path), type); |
| 176 | + return union == null ? java.util.Collections.<String, List<Integer>>emptyMap() : union; |
| 177 | + } |
| 178 | + |
| 179 | + private static Map<String, ClassMeta> readManifest(Path path) throws IOException { |
| 180 | + Type type = new TypeToken<Map<String, ClassMeta>>() { |
| 181 | + }.getType(); |
| 182 | + Map<String, ClassMeta> manifest = GSON.fromJson(readString(path), type); |
| 183 | + if (manifest == null) { |
| 184 | + throw new IOException("manifest is empty or invalid: " + path); |
| 185 | + } |
| 186 | + return manifest; |
| 187 | + } |
| 188 | + |
| 189 | + private static String readString(Path path) throws IOException { |
| 190 | + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); |
| 191 | + } |
| 192 | + |
| 193 | + /** Minimal --flag value arg parser. */ |
| 194 | + private static final class Args { |
| 195 | + Path classesPath; |
| 196 | + Path unionPath; |
| 197 | + Path manifestPath; |
| 198 | + Path outPath; |
| 199 | + |
| 200 | + static Args parse(String[] argv) { |
| 201 | + Args a = new Args(); |
| 202 | + for (int i = 0; i + 1 < argv.length; i += 2) { |
| 203 | + String flag = argv[i]; |
| 204 | + String val = argv[i + 1]; |
| 205 | + switch (flag) { |
| 206 | + case "--classes": |
| 207 | + a.classesPath = Paths.get(val); |
| 208 | + break; |
| 209 | + case "--union": |
| 210 | + a.unionPath = Paths.get(val); |
| 211 | + break; |
| 212 | + case "--manifest": |
| 213 | + a.manifestPath = Paths.get(val); |
| 214 | + break; |
| 215 | + case "--out": |
| 216 | + a.outPath = Paths.get(val); |
| 217 | + break; |
| 218 | + default: |
| 219 | + throw new IllegalArgumentException("unknown flag: " + flag); |
| 220 | + } |
| 221 | + } |
| 222 | + if (a.classesPath == null || a.unionPath == null || a.manifestPath == null) { |
| 223 | + throw new IllegalArgumentException( |
| 224 | + "usage: CoverageReporter --classes <dir|jar> --union <union.json> " |
| 225 | + + "--manifest <manifest.json> [--out <cov.json>]"); |
| 226 | + } |
| 227 | + return a; |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + // Referenced to keep the ICounter import meaningful for readers scanning |
| 232 | + // deps; getLineCounter()/getBranchCounter() return ICounter instances. |
| 233 | + @SuppressWarnings("unused") |
| 234 | + private static long coveredOf(ICounter counter) { |
| 235 | + return counter.getCoveredCount(); |
| 236 | + } |
| 237 | +} |
0 commit comments