xref: /aosp_15_r20/external/toolchain-utils/llvm_tools/check_clang_diags.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
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