• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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