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