| #!/usr/bin/env python3 |
| # Copyright 2022 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """check_clang_diags monitors for new diagnostics in LLVM |
| |
| This looks at projects we care about (currently only clang-tidy, though |
| hopefully clang in the future, too?) and files bugs whenever a new check or |
| warning appears. These bugs are intended to keep us up-to-date with new |
| diagnostics, so we can enable them as they land. |
| """ |
| |
| import argparse |
| import json |
| import logging |
| import os |
| import shutil |
| import subprocess |
| import sys |
| from typing import Dict, List, Tuple |
| |
| from cros_utils import bugs |
| |
| _DEFAULT_ASSIGNEE = 'mage' |
| _DEFAULT_CCS = ['cjdb@google.com'] |
| |
| |
| # FIXME: clang would be cool to check, too? Doesn't seem to have a super stable |
| # way of listing all warnings, unfortunately. |
| def _build_llvm(llvm_dir: str, build_dir: str): |
| """Builds everything that _collect_available_diagnostics depends on.""" |
| targets = ['clang-tidy'] |
| # use `-C $llvm_dir` so the failure is easier to handle if llvm_dir DNE. |
| ninja_result = subprocess.run( |
| ['ninja', '-C', build_dir] + targets, |
| check=False, |
| ) |
| if not ninja_result.returncode: |
| return |
| |
| # Sometimes the directory doesn't exist, sometimes incremental cmake |
| # breaks, sometimes something random happens. Start fresh since that fixes |
| # the issue most of the time. |
| logging.warning('Initial build failed; trying to build from scratch.') |
| shutil.rmtree(build_dir, ignore_errors=True) |
| os.makedirs(build_dir) |
| subprocess.run( |
| [ |
| 'cmake', |
| '-G', |
| 'Ninja', |
| '-DCMAKE_BUILD_TYPE=MinSizeRel', |
| '-DLLVM_USE_LINKER=lld', |
| '-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra', |
| '-DLLVM_TARGETS_TO_BUILD=X86', |
| f'{os.path.abspath(llvm_dir)}/llvm', |
| ], |
| cwd=build_dir, |
| check=True, |
| ) |
| subprocess.run(['ninja'] + targets, check=True, cwd=build_dir) |
| |
| |
| def _collect_available_diagnostics(llvm_dir: str, |
| build_dir: str) -> Dict[str, List[str]]: |
| _build_llvm(llvm_dir, build_dir) |
| |
| clang_tidy = os.path.join(os.path.abspath(build_dir), 'bin', 'clang-tidy') |
| clang_tidy_checks = subprocess.run( |
| [clang_tidy, '-checks=*', '-list-checks'], |
| # Use cwd='/' to ensure no .clang-tidy files are picked up. It |
| # _shouldn't_ matter, but it's also ~free, so... |
| check=True, |
| cwd='/', |
| stdout=subprocess.PIPE, |
| encoding='utf-8', |
| ) |
| clang_tidy_checks_stdout = [ |
| x.strip() for x in clang_tidy_checks.stdout.strip().splitlines() |
| ] |
| |
| # The first line should always be this, then each line thereafter is a check |
| # name. |
| assert clang_tidy_checks_stdout[0] == 'Enabled checks:', ( |
| clang_tidy_checks_stdout) |
| clang_tidy_checks = clang_tidy_checks_stdout[1:] |
| assert not any(check.isspace() |
| for check in clang_tidy_checks), (clang_tidy_checks) |
| return {'clang-tidy': clang_tidy_checks} |
| |
| |
| def _process_new_diagnostics( |
| old: Dict[str, List[str]], new: Dict[str, List[str]] |
| ) -> Tuple[Dict[str, List[str]], Dict[str, List[str]]]: |
| """Determines the set of new diagnostics that we should file bugs for. |
| |
| old: The previous state that this function returned as `new_state_file`, or |
| `{}` |
| new: The diagnostics that we've most recently found. This is a dict in the |
| form {tool: [diag]} |
| |
| Returns a `new_state_file` to pass into this function as `old` in the |
| future, and a dict of diags to file bugs about. |
| """ |
| new_diagnostics = {} |
| new_state_file = {} |
| for tool, diags in new.items(): |
| if tool not in old: |
| logging.info('New tool with diagnostics: %s; pretending none are new', |
| tool) |
| new_state_file[tool] = diags |
| else: |
| old_diags = set(old[tool]) |
| newly_added_diags = [x for x in diags if x not in old_diags] |
| if newly_added_diags: |
| new_diagnostics[tool] = newly_added_diags |
| # This specifically tries to make diags sticky: if one is landed, then |
| # reverted, then relanded, we ignore the reland. This might not be |
| # desirable? I don't know. |
| new_state_file[tool] = old[tool] + newly_added_diags |
| |
| # Sort things so we have more predictable output. |
| for v in new_diagnostics.values(): |
| v.sort() |
| |
| return new_state_file, new_diagnostics |
| |
| |
| def _file_bugs_for_new_diags(new_diags: Dict[str, List[str]]): |
| for tool, diags in sorted(new_diags.items()): |
| for diag in diags: |
| bugs.CreateNewBug( |
| component_id=bugs.WellKnownComponents.CrOSToolchainPublic, |
| title=f'Investigate {tool} check `{diag}`', |
| body='\n'.join(( |
| f'It seems that the `{diag}` check was recently added to {tool}.', |
| "It's probably good to TAL at whether this check would be good", |
| 'for us to enable in e.g., platform2, or across ChromeOS.', |
| )), |
| assignee=_DEFAULT_ASSIGNEE, |
| cc=_DEFAULT_CCS, |
| ) |
| |
| |
| def main(argv: List[str]): |
| logging.basicConfig( |
| format='>> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: ' |
| '%(message)s', |
| level=logging.INFO, |
| ) |
| |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| ) |
| parser.add_argument('--llvm_dir', |
| required=True, |
| help='LLVM directory to check. Required.') |
| parser.add_argument('--llvm_build_dir', |
| required=True, |
| help='Build directory for LLVM. Required & autocreated.') |
| parser.add_argument( |
| '--state_file', |
| required=True, |
| help='State file to use to suppress duplicate complaints. Required.') |
| parser.add_argument( |
| '--dry_run', |
| action='store_true', |
| help='Skip filing bugs & writing to the state file; just log ' |
| 'differences.') |
| opts = parser.parse_args(argv) |
| |
| build_dir = opts.llvm_build_dir |
| dry_run = opts.dry_run |
| llvm_dir = opts.llvm_dir |
| state_file = opts.state_file |
| |
| try: |
| with open(state_file, encoding='utf-8') as f: |
| prior_diagnostics = json.load(f) |
| except FileNotFoundError: |
| # If the state file didn't exist, just create it without complaining this |
| # time. |
| prior_diagnostics = {} |
| |
| available_diagnostics = _collect_available_diagnostics(llvm_dir, build_dir) |
| logging.info('Available diagnostics are %s', available_diagnostics) |
| if available_diagnostics == prior_diagnostics: |
| logging.info('Current diagnostics are identical to previous ones; quit') |
| return |
| |
| new_state_file, new_diagnostics = _process_new_diagnostics( |
| prior_diagnostics, available_diagnostics) |
| logging.info('New diagnostics in existing tool(s): %s', new_diagnostics) |
| |
| if dry_run: |
| logging.info('Skipping new state file writing and bug filing; dry-run ' |
| 'mode wins') |
| else: |
| _file_bugs_for_new_diags(new_diagnostics) |
| new_state_file_path = state_file + '.new' |
| with open(new_state_file_path, 'w', encoding='utf-8') as f: |
| json.dump(new_state_file, f) |
| os.rename(new_state_file_path, state_file) |
| |
| |
| if __name__ == '__main__': |
| main(sys.argv[1:]) |