1#!/usr/bin/env python3 2# Copyright 2022 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""check_clang_diags monitors for new diagnostics in LLVM 7 8This looks at projects we care about (currently only clang-tidy, though 9hopefully clang in the future, too?) and files bugs whenever a new check or 10warning appears. These bugs are intended to keep us up-to-date with new 11diagnostics, so we can enable them as they land. 12""" 13 14import argparse 15import json 16import logging 17import os 18import shutil 19import subprocess 20import sys 21from typing import Dict, List, Tuple 22 23from cros_utils import bugs 24 25 26_DEFAULT_ASSIGNEE = "mage" 27_DEFAULT_CCS = ["cjdb@google.com"] 28 29 30# FIXME: clang would be cool to check, too? Doesn't seem to have a super stable 31# way of listing all warnings, unfortunately. 32def _build_llvm(llvm_dir: str, build_dir: str): 33 """Builds everything that _collect_available_diagnostics depends on.""" 34 targets = ["clang-tidy"] 35 # use `-C $llvm_dir` so the failure is easier to handle if llvm_dir DNE. 36 ninja_result = subprocess.run( 37 ["ninja", "-C", build_dir] + targets, 38 check=False, 39 ) 40 if not ninja_result.returncode: 41 return 42 43 # Sometimes the directory doesn't exist, sometimes incremental cmake 44 # breaks, sometimes something random happens. Start fresh since that fixes 45 # the issue most of the time. 46 logging.warning("Initial build failed; trying to build from scratch.") 47 shutil.rmtree(build_dir, ignore_errors=True) 48 os.makedirs(build_dir) 49 subprocess.run( 50 [ 51 "cmake", 52 "-G", 53 "Ninja", 54 "-DCMAKE_BUILD_TYPE=MinSizeRel", 55 "-DLLVM_USE_LINKER=lld", 56 "-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra", 57 "-DLLVM_TARGETS_TO_BUILD=X86", 58 f"{os.path.abspath(llvm_dir)}/llvm", 59 ], 60 cwd=build_dir, 61 check=True, 62 ) 63 subprocess.run(["ninja"] + targets, check=True, cwd=build_dir) 64 65 66def _collect_available_diagnostics( 67 llvm_dir: str, build_dir: str 68) -> Dict[str, List[str]]: 69 _build_llvm(llvm_dir, build_dir) 70 71 clang_tidy = os.path.join(os.path.abspath(build_dir), "bin", "clang-tidy") 72 clang_tidy_checks = subprocess.run( 73 [clang_tidy, "-checks=*", "-list-checks"], 74 # Use cwd='/' to ensure no .clang-tidy files are picked up. It 75 # _shouldn't_ matter, but it's also ~free, so... 76 check=True, 77 cwd="/", 78 stdout=subprocess.PIPE, 79 encoding="utf-8", 80 ) 81 clang_tidy_checks_stdout = [ 82 x.strip() for x in clang_tidy_checks.stdout.strip().splitlines() 83 ] 84 85 # The first line should always be this, then each line thereafter is a check 86 # name. 87 assert ( 88 clang_tidy_checks_stdout[0] == "Enabled checks:" 89 ), clang_tidy_checks_stdout 90 clang_tidy_checks = clang_tidy_checks_stdout[1:] 91 assert not any( 92 check.isspace() for check in clang_tidy_checks 93 ), clang_tidy_checks 94 return {"clang-tidy": clang_tidy_checks} 95 96 97def _process_new_diagnostics( 98 old: Dict[str, List[str]], new: Dict[str, List[str]] 99) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]: 100 """Determines the set of new diagnostics that we should file bugs for. 101 102 old: The previous state that this function returned as `new_state_file`, or 103 `{}` 104 new: The diagnostics that we've most recently found. This is a dict in the 105 form {tool: [diag]} 106 107 Returns a `new_state_file` to pass into this function as `old` in the 108 future, and a dict of diags to file bugs about. 109 """ 110 new_diagnostics = {} 111 new_state_file = {} 112 for tool, diags in new.items(): 113 if tool not in old: 114 logging.info( 115 "New tool with diagnostics: %s; pretending none are new", tool 116 ) 117 new_state_file[tool] = diags 118 else: 119 old_diags = set(old[tool]) 120 newly_added_diags = [x for x in diags if x not in old_diags] 121 if newly_added_diags: 122 new_diagnostics[tool] = newly_added_diags 123 # This specifically tries to make diags sticky: if one is landed, then 124 # reverted, then relanded, we ignore the reland. This might not be 125 # desirable? I don't know. 126 new_state_file[tool] = old[tool] + newly_added_diags 127 128 # Sort things so we have more predictable output. 129 for v in new_diagnostics.values(): 130 v.sort() 131 132 return new_state_file, new_diagnostics 133 134 135def _file_bugs_for_new_diags(new_diags: Dict[str, List[str]]): 136 for tool, diags in sorted(new_diags.items()): 137 for diag in diags: 138 bugs.CreateNewBug( 139 component_id=bugs.WellKnownComponents.CrOSToolchainPublic, 140 title=f"Investigate {tool} check `{diag}`", 141 body="\n".join( 142 ( 143 f"It seems that the `{diag}` check was recently added to {tool}.", 144 "It's probably good to TAL at whether this check would be good", 145 "for us to enable in e.g., platform2, or across ChromeOS.", 146 ) 147 ), 148 assignee=_DEFAULT_ASSIGNEE, 149 cc=_DEFAULT_CCS, 150 ) 151 152 153def main(argv: List[str]): 154 logging.basicConfig( 155 format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: " 156 "%(message)s", 157 level=logging.INFO, 158 ) 159 160 parser = argparse.ArgumentParser( 161 description=__doc__, 162 formatter_class=argparse.RawDescriptionHelpFormatter, 163 ) 164 parser.add_argument( 165 "--llvm_dir", required=True, help="LLVM directory to check. Required." 166 ) 167 parser.add_argument( 168 "--llvm_build_dir", 169 required=True, 170 help="Build directory for LLVM. Required & autocreated.", 171 ) 172 parser.add_argument( 173 "--state_file", 174 required=True, 175 help="State file to use to suppress duplicate complaints. Required.", 176 ) 177 parser.add_argument( 178 "--dry_run", 179 action="store_true", 180 help="Skip filing bugs & writing to the state file; just log " 181 "differences.", 182 ) 183 opts = parser.parse_args(argv) 184 185 build_dir = opts.llvm_build_dir 186 dry_run = opts.dry_run 187 llvm_dir = opts.llvm_dir 188 state_file = opts.state_file 189 190 try: 191 with open(state_file, encoding="utf-8") as f: 192 prior_diagnostics = json.load(f) 193 except FileNotFoundError: 194 # If the state file didn't exist, just create it without complaining this 195 # time. 196 prior_diagnostics = {} 197 198 available_diagnostics = _collect_available_diagnostics(llvm_dir, build_dir) 199 logging.info("Available diagnostics are %s", available_diagnostics) 200 if available_diagnostics == prior_diagnostics: 201 logging.info("Current diagnostics are identical to previous ones; quit") 202 return 203 204 new_state_file, new_diagnostics = _process_new_diagnostics( 205 prior_diagnostics, available_diagnostics 206 ) 207 logging.info("New diagnostics in existing tool(s): %s", new_diagnostics) 208 209 if dry_run: 210 logging.info( 211 "Skipping new state file writing and bug filing; dry-run " 212 "mode wins" 213 ) 214 else: 215 _file_bugs_for_new_diags(new_diagnostics) 216 new_state_file_path = state_file + ".new" 217 with open(new_state_file_path, "w", encoding="utf-8") as f: 218 json.dump(new_state_file, f) 219 os.rename(new_state_file_path, state_file) 220 221 222if __name__ == "__main__": 223 main(sys.argv[1:]) 224