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