blob: 7beb958fdae3dbb6085ee9668010afe76ac24d4c [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2022 The ChromiumOS Authors
# 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:])