|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Eggs.Stacktrace |
| 3 | +# |
| 4 | +# Copyright (c) 2026 Agustin Berge |
| 5 | +# |
| 6 | +# Distributed under the Boost Software License, Version 1.0. |
| 7 | +# See accompanying file LICENSE.txt or copy at |
| 8 | +# http://www.boost.org/LICENSE_1_0.txt |
| 9 | + |
| 10 | +# Fail if clang-include-cleaner finds a missing or unused #include. |
| 11 | +# |
| 12 | +# Runs in two passes: once per translation unit in the compilation database, |
| 13 | +# and once per public header (using the VERIFY_INTERFACE_HEADER_SETS TUs). |
| 14 | + |
| 15 | +from collections.abc import Callable |
| 16 | +import json |
| 17 | +import os |
| 18 | +import pathlib |
| 19 | +import shlex |
| 20 | +import shutil |
| 21 | +import subprocess |
| 22 | +import sys |
| 23 | + |
| 24 | +CLANG_INCLUDE_CLEANER = "clang-include-cleaner" |
| 25 | + |
| 26 | +# Directory component suffixes CMake uses for the <target>_verify_interface_ |
| 27 | +# header_sets / <target>_verify_private_header_sets object libraries. |
| 28 | +VERIFY_HEADER_SETS_DIRS = ( |
| 29 | + "_verify_interface_header_sets", |
| 30 | + "_verify_private_header_sets", |
| 31 | +) |
| 32 | + |
| 33 | +EXCLUDED_DIRS = ( |
| 34 | + "test/cmake-fetch_content/", |
| 35 | + "test/cmake-find_package/", |
| 36 | +) |
| 37 | + |
| 38 | +# Flags stripped from a compile_commands.json entry before reuse: the |
| 39 | +# compiler executable and output/mode flags are build artifacts. |
| 40 | +DROPPED_FLAGS = {"-c"} |
| 41 | +DROPPED_FLAG_PAIRS = {"-o"} |
| 42 | + |
| 43 | +Target = tuple[pathlib.Path, str, list[str]] |
| 44 | + |
| 45 | + |
| 46 | +def parse_command(command: str, file: str) -> list[str]: |
| 47 | + tokens = shlex.split(command) |
| 48 | + args = [] |
| 49 | + skip_next = False |
| 50 | + for i, token in enumerate(tokens): |
| 51 | + if skip_next: |
| 52 | + skip_next = False |
| 53 | + continue |
| 54 | + if i == 0 or token == file: |
| 55 | + continue |
| 56 | + if token in DROPPED_FLAG_PAIRS: |
| 57 | + skip_next = True |
| 58 | + continue |
| 59 | + if token in DROPPED_FLAGS: |
| 60 | + continue |
| 61 | + args.append(token) |
| 62 | + return args |
| 63 | + |
| 64 | + |
| 65 | +def gh_escape_property(value: str) -> str: |
| 66 | + return ( |
| 67 | + value.replace("%", "%25") |
| 68 | + .replace("\r", "%0D") |
| 69 | + .replace("\n", "%0A") |
| 70 | + .replace(":", "%3A") |
| 71 | + .replace(",", "%2C") |
| 72 | + ) |
| 73 | + |
| 74 | + |
| 75 | +def run_include_cleaner(args: list[str]) -> list[str]: |
| 76 | + result = subprocess.run( |
| 77 | + [CLANG_INCLUDE_CLEANER, "--print=changes", *args], |
| 78 | + capture_output=True, |
| 79 | + text=True, |
| 80 | + ) |
| 81 | + if result.returncode != 0: |
| 82 | + sys.exit( |
| 83 | + f"error: {CLANG_INCLUDE_CLEANER} failed on {args}:\n{result.stderr}" |
| 84 | + ) |
| 85 | + return [line for line in result.stdout.splitlines() if line] |
| 86 | + |
| 87 | + |
| 88 | +def tracked_files(repo_root: pathlib.Path) -> set[str]: |
| 89 | + out = subprocess.run( |
| 90 | + ["git", "ls-files", "-z"], |
| 91 | + cwd=repo_root, |
| 92 | + check=True, |
| 93 | + capture_output=True, |
| 94 | + text=True, |
| 95 | + ).stdout |
| 96 | + return {line for line in out.split("\0") if line} |
| 97 | + |
| 98 | + |
| 99 | +def run_checks( |
| 100 | + compile_commands: list[dict], target: Callable[[dict], Target | None] |
| 101 | +) -> tuple[dict[str, list[str]], set[str]]: |
| 102 | + findings = {} |
| 103 | + checked = set() |
| 104 | + for entry in compile_commands: |
| 105 | + resolved = target(entry) |
| 106 | + if resolved is None: |
| 107 | + continue |
| 108 | + file, rel, args = resolved |
| 109 | + if rel in checked: |
| 110 | + continue |
| 111 | + checked.add(rel) |
| 112 | + |
| 113 | + lines = run_include_cleaner([str(file), "--", *args]) |
| 114 | + if lines: |
| 115 | + findings[rel] = lines |
| 116 | + return findings, checked |
| 117 | + |
| 118 | + |
| 119 | +def translation_unit_target( |
| 120 | + entry: dict, repo_root: pathlib.Path, tracked: set[str] |
| 121 | +) -> Target | None: |
| 122 | + file = pathlib.Path(entry["file"]) |
| 123 | + if not file.is_absolute(): |
| 124 | + file = pathlib.Path(entry["directory"]) / file |
| 125 | + try: |
| 126 | + rel = file.resolve().relative_to(repo_root).as_posix() |
| 127 | + except ValueError: |
| 128 | + return None |
| 129 | + if rel not in tracked: |
| 130 | + return None # e.g. build-directory-generated sources |
| 131 | + if rel.startswith(EXCLUDED_DIRS) or rel.endswith(".compile_fail.cpp"): |
| 132 | + return None |
| 133 | + |
| 134 | + args = parse_command(entry["command"], entry["file"]) |
| 135 | + return file, rel, args |
| 136 | + |
| 137 | + |
| 138 | +def resolve_header(spelling: str, args: list[str]) -> pathlib.Path | None: |
| 139 | + for arg in args: |
| 140 | + if not arg.startswith("-I"): |
| 141 | + continue |
| 142 | + candidate = pathlib.Path(arg[2:]) / spelling |
| 143 | + if candidate.is_file(): |
| 144 | + return candidate |
| 145 | + return None |
| 146 | + |
| 147 | + |
| 148 | +def header_target(entry: dict, repo_root: pathlib.Path) -> Target | None: |
| 149 | + wrapper = pathlib.Path(entry["file"]) |
| 150 | + parts = wrapper.parts |
| 151 | + idx = next( |
| 152 | + ( |
| 153 | + i |
| 154 | + for i, part in enumerate(parts) |
| 155 | + if any(part.endswith(suffix) for suffix in VERIFY_HEADER_SETS_DIRS) |
| 156 | + ), |
| 157 | + None, |
| 158 | + ) |
| 159 | + if idx is None: |
| 160 | + return None |
| 161 | + spelling = "/".join(parts[idx + 1 :])[: -len(".cxx")] |
| 162 | + |
| 163 | + args = parse_command(entry["command"], entry["file"]) |
| 164 | + header = resolve_header(spelling, args) |
| 165 | + if header is None: |
| 166 | + sys.exit(f"error: could not resolve header {spelling!r} for {wrapper}") |
| 167 | + |
| 168 | + rel = header.resolve().relative_to(repo_root).as_posix() |
| 169 | + return header, rel, args |
| 170 | + |
| 171 | + |
| 172 | +def main() -> int: |
| 173 | + if len(sys.argv) != 2: |
| 174 | + sys.exit(f"usage: {sys.argv[0]} <build-dir>") |
| 175 | + |
| 176 | + if shutil.which(CLANG_INCLUDE_CLEANER) is None: |
| 177 | + sys.exit(f"error: {CLANG_INCLUDE_CLEANER} not found on PATH") |
| 178 | + |
| 179 | + build_dir = pathlib.Path(sys.argv[1]).resolve() |
| 180 | + repo_root = pathlib.Path( |
| 181 | + subprocess.run( |
| 182 | + ["git", "rev-parse", "--show-toplevel"], |
| 183 | + check=True, |
| 184 | + capture_output=True, |
| 185 | + text=True, |
| 186 | + ).stdout.strip() |
| 187 | + ) |
| 188 | + |
| 189 | + compile_commands = json.loads( |
| 190 | + (build_dir / "compile_commands.json").read_text(encoding="utf-8") |
| 191 | + ) |
| 192 | + tracked = tracked_files(repo_root) |
| 193 | + |
| 194 | + tu_findings, tu_checked = run_checks( |
| 195 | + compile_commands, |
| 196 | + lambda entry: translation_unit_target(entry, repo_root, tracked), |
| 197 | + ) |
| 198 | + header_findings, header_checked = run_checks( |
| 199 | + compile_commands, |
| 200 | + lambda entry: header_target(entry, repo_root), |
| 201 | + ) |
| 202 | + |
| 203 | + findings = {**tu_findings, **header_findings} |
| 204 | + if findings: |
| 205 | + print("error: clang-include-cleaner findings:") |
| 206 | + in_ci = os.environ.get("GITHUB_ACTIONS") == "true" |
| 207 | + summary_lines = [] |
| 208 | + for path in sorted(findings): |
| 209 | + print(f" {path}") |
| 210 | + for line in findings[path]: |
| 211 | + print(f" {line}") |
| 212 | + summary_lines.append(f"{path}") |
| 213 | + summary_lines.extend(f" {line}" for line in findings[path]) |
| 214 | + if in_ci: |
| 215 | + summary = "; ".join(findings[path]) |
| 216 | + print( |
| 217 | + f"::error file={gh_escape_property(path)}::" |
| 218 | + f"clang-include-cleaner: {gh_escape_property(summary)}" |
| 219 | + ) |
| 220 | + |
| 221 | + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") |
| 222 | + if step_summary: |
| 223 | + with open(step_summary, "a", encoding="utf-8") as f: |
| 224 | + f.write("## clang-include-cleaner findings\n\n```\n") |
| 225 | + f.write("\n".join(summary_lines)) |
| 226 | + f.write("\n```\n") |
| 227 | + return 1 |
| 228 | + |
| 229 | + print( |
| 230 | + f"OK: no missing/unused includes across {len(tu_checked)} translation " |
| 231 | + f"unit(s) and {len(header_checked)} header(s)." |
| 232 | + ) |
| 233 | + return 0 |
| 234 | + |
| 235 | + |
| 236 | +if __name__ == "__main__": |
| 237 | + sys.exit(main()) |
0 commit comments