1#!/usr/bin/env python3 2# Copyright 2021 Google LLC 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15# 16################################################################################ 17"""Helper script for creating an llvm-cov style JSON summary from a JaCoCo XML 18report.""" 19import json 20import os 21import sys 22import xml.etree.ElementTree as ET 23 24 25def convert(xml): 26 """Turns a JaCoCo XML report into an llvm-cov JSON summary.""" 27 summary = { 28 "type": "oss-fuzz.java.coverage.json.export", 29 "version": "1.0.0", 30 "data": [{ 31 "totals": {}, 32 "files": [], 33 }], 34 } 35 36 report = ET.fromstring(xml) 37 totals = make_element_summary(report) 38 summary["data"][0]["totals"] = totals 39 40 # Since Java compilation does not track source file location, we match 41 # coverage info to source files via the full class name, e.g. we search for 42 # a path in /out/src ending in foo/bar/Baz.java for the class foo.bar.Baz. 43 # Under the assumptions that a given project only ever contains a single 44 # version of a class and that no class name appears as a suffix of another 45 # class name, we can assign coverage info to every source file matched in that 46 # way. 47 src_files = list_src_files() 48 49 for class_element in report.findall("./package/class"): 50 class_name = class_element.attrib["name"] 51 package_name = os.path.dirname(class_name) 52 if "sourcefilename" not in class_element.attrib: 53 continue 54 basename = class_element.attrib["sourcefilename"] 55 # This path is "foo/Bar.java" for the class element 56 # <class name="foo/Bar" sourcefilename="Bar.java">. 57 canonical_path = os.path.join(package_name, basename) 58 59 class_summary = make_element_summary(class_element) 60 summary["data"][0]["files"].append({ 61 "filename": relative_to_src_path(src_files, canonical_path), 62 "summary": class_summary, 63 }) 64 65 return json.dumps(summary) 66 67 68def list_src_files(): 69 """Returns a map from basename to full path for all files in $OUT/$SRC.""" 70 filename_to_paths = {} 71 out_path = os.environ["OUT"] + "/" 72 src_path = os.environ["SRC"] 73 src_in_out = out_path + src_path 74 for dirpath, _, filenames in os.walk(src_in_out): 75 for filename in filenames: 76 full_path = dirpath + "/" + filename 77 # Map /out//src/... to /src/... 78 src_path = full_path[len(out_path):] 79 filename_to_paths.setdefault(filename, []).append(src_path) 80 return filename_to_paths 81 82 83def relative_to_src_path(src_files, canonical_path): 84 """Returns all paths in src_files ending in canonical_path.""" 85 basename = os.path.basename(canonical_path) 86 if basename not in src_files: 87 return [] 88 candidate_paths = src_files[basename] 89 return [ 90 path for path in candidate_paths if path.endswith("/" + canonical_path) 91 ] 92 93 94def make_element_summary(element): 95 """Returns a coverage summary for an element in the XML report.""" 96 summary = {} 97 98 function_counter = element.find("./counter[@type='METHOD']") 99 summary["functions"] = make_counter_summary(function_counter) 100 101 line_counter = element.find("./counter[@type='LINE']") 102 summary["lines"] = make_counter_summary(line_counter) 103 104 # JaCoCo tracks branch coverage, which counts the covered control-flow edges 105 # between llvm-cov's regions instead of the covered regions themselves. For 106 # non-trivial code parts, the difference is usually negligible. However, if 107 # all methods of a class consist of a single region only (no branches), 108 # JaCoCo does not report any branch coverage even if there is instruction 109 # coverage. Since this would give incorrect results for CI Fuzz purposes, we 110 # increase the regions counter by 1 if there is any amount of instruction 111 # coverage. 112 instruction_counter = element.find("./counter[@type='INSTRUCTION']") 113 has_some_coverage = instruction_counter is not None and int( 114 instruction_counter.attrib["covered"]) > 0 115 branch_covered_adjustment = 1 if has_some_coverage else 0 116 region_counter = element.find("./counter[@type='BRANCH']") 117 summary["regions"] = make_counter_summary( 118 region_counter, covered_adjustment=branch_covered_adjustment) 119 120 return summary 121 122 123def make_counter_summary(counter_element, covered_adjustment=0): 124 """Turns a JaCoCo <counter> element into an llvm-cov totals entry.""" 125 summary = {} 126 covered = covered_adjustment 127 missed = 0 128 if counter_element is not None: 129 covered += int(counter_element.attrib["covered"]) 130 missed += int(counter_element.attrib["missed"]) 131 summary["covered"] = covered 132 summary["notcovered"] = missed 133 summary["count"] = summary["covered"] + summary["notcovered"] 134 if summary["count"] != 0: 135 summary["percent"] = (100.0 * summary["covered"]) / summary["count"] 136 else: 137 summary["percent"] = 0 138 return summary 139 140 141def main(): 142 """Produces an llvm-cov style JSON summary from a JaCoCo XML report.""" 143 if len(sys.argv) != 3: 144 sys.stderr.write('Usage: %s <path_to_jacoco_xml> <out_path_json>\n' % 145 sys.argv[0]) 146 return 1 147 148 with open(sys.argv[1], 'r') as xml_file: 149 xml_report = xml_file.read() 150 json_summary = convert(xml_report) 151 with open(sys.argv[2], 'w') as json_file: 152 json_file.write(json_summary) 153 154 return 0 155 156 157if __name__ == "__main__": 158 sys.exit(main()) 159