Skip to content

Commit ca0374f

Browse files
committed
[ci] Add clang-include-cleaner job
1 parent 97e1b57 commit ca0374f

8 files changed

Lines changed: 331 additions & 20 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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())

.github/workflows/coverage.yml

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,21 @@ jobs:
4646
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-22 200
4747
sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-22 200
4848
49+
- name: Install libstdc++-16
50+
run: |
51+
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
52+
sudo apt-get update
53+
sudo apt-get install -y g++-16
54+
4955
- name: Fix APT cache permissions
5056
if: always()
5157
run: sudo rm -rf ~/.cache/apt/archives/lock ~/.cache/apt/archives/partial
5258

5359
- name: Configure (coverage)
5460
run: |
5561
cmake --preset dev-clang -B build/coverage \
56-
-DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" \
57-
-DCMAKE_EXE_LINKER_FLAGS="-fprofile-instr-generate"
62+
-DCMAKE_CXX_FLAGS_INIT="--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/16 -fprofile-instr-generate -fcoverage-mapping" \
63+
-DCMAKE_EXE_LINKER_FLAGS_INIT="-fprofile-instr-generate"
5864
5965
- name: Build
6066
run: cmake --build build/coverage --config Debug
@@ -76,9 +82,20 @@ jobs:
7682
first="${objects[0]}"
7783
obj_args=()
7884
for o in "${objects[@]:1}"; do obj_args+=("-object=$o"); done
79-
llvm-cov-22 report "$first" "${obj_args[@]}" \
85+
report=$(llvm-cov-22 report "$first" "${obj_args[@]}" \
8086
-instr-profile=build/coverage/coverage.profdata \
81-
-use-color=false src include/eggs 2>/dev/null
87+
-path-equivalence=".,${GITHUB_WORKSPACE}" \
88+
-use-color=false src include/eggs 2>/dev/null)
89+
echo "$report"
90+
if [ -n "$GITHUB_STEP_SUMMARY" ]; then
91+
{
92+
echo "## Coverage report (llvm-cov)"
93+
echo
94+
echo '```'
95+
echo "$report"
96+
echo '```'
97+
} >> "$GITHUB_STEP_SUMMARY"
98+
fi
8299
83100
- name: Generate HTML report
84101
run: |
@@ -88,6 +105,7 @@ jobs:
88105
for o in "${objects[@]:1}"; do obj_args+=("-object=$o"); done
89106
llvm-cov-22 show "$first" "${obj_args[@]}" \
90107
-instr-profile=build/coverage/coverage.profdata \
108+
-path-equivalence=".,${GITHUB_WORKSPACE}" \
91109
-format=html \
92110
-output-dir=build/coverage/html \
93111
-show-line-counts-or-regions \
@@ -148,8 +166,8 @@ jobs:
148166
- name: Configure (coverage)
149167
run: |
150168
cmake --preset dev-gcc -B build/coverage-gcc \
151-
-DCMAKE_CXX_FLAGS="--coverage" \
152-
-DCMAKE_EXE_LINKER_FLAGS="--coverage"
169+
-DCMAKE_CXX_FLAGS_INIT="--coverage" \
170+
-DCMAKE_EXE_LINKER_FLAGS_INIT="--coverage"
153171
154172
- name: Build
155173
run: cmake --build build/coverage-gcc --config Debug
@@ -164,22 +182,36 @@ jobs:
164182
run: |
165183
lcov --capture \
166184
--gcov-tool gcov-16 \
185+
--rc branch_coverage=1 \
167186
--ignore-errors mismatch,inconsistent \
187+
--base-directory . \
168188
--directory build/coverage-gcc \
169189
--output-file build/coverage-gcc/coverage.info
170190
lcov --extract build/coverage-gcc/coverage.info \
171191
'*/src/*' '*/include/eggs/*' \
192+
--rc branch_coverage=1 \
172193
--ignore-errors unused \
173194
--output-file build/coverage-gcc/coverage.info
174195
175196
- name: Coverage report
176197
run: |
177-
lcov --list build/coverage-gcc/coverage.info
198+
report=$(lcov --list build/coverage-gcc/coverage.info --rc branch_coverage=1)
199+
echo "$report"
200+
if [ -n "$GITHUB_STEP_SUMMARY" ]; then
201+
{
202+
echo "## Coverage report (gcov)"
203+
echo
204+
echo '```'
205+
echo "$report"
206+
echo '```'
207+
} >> "$GITHUB_STEP_SUMMARY"
208+
fi
178209
179210
- name: Generate HTML report
180211
run: |
181212
genhtml build/coverage-gcc/coverage.info \
182213
--output-directory build/coverage-gcc/html \
214+
--rc branch_coverage=1 \
183215
--quiet
184216
185217
- name: Upload coverage report

.github/workflows/lint.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,57 @@ jobs:
125125
if: steps.diff-base.outputs.ref != ''
126126
run: python3 .github/scripts/lint/warn-everything-check-changed.py
127127
build/warn-everything/warn-everything.sarif ${{ steps.diff-base.outputs.ref }}
128+
129+
include-cleaner:
130+
name: clang-include-cleaner (clang-22)
131+
runs-on: ubuntu-24.04
132+
timeout-minutes: 5
133+
134+
steps:
135+
- uses: actions/checkout@v5
136+
137+
- name: Install CMake
138+
uses: lukka/get-cmake@latest
139+
with:
140+
cmakeVersion: "4.4.0"
141+
142+
- name: Set up APT package cache
143+
run: |
144+
mkdir -p ~/.cache/apt/archives/partial
145+
echo "Dir::Cache::archives \"$HOME/.cache/apt/archives\";" \
146+
| sudo tee /etc/apt/apt.conf.d/99user-cache
147+
148+
- name: Cache APT packages
149+
continue-on-error: true
150+
uses: actions/cache@v5
151+
with:
152+
path: ~/.cache/apt/archives
153+
key: apt-ubuntu-24.04-clang-tools-22-${{ hashFiles('.github/workflows/lint.yml') }}
154+
restore-keys: apt-ubuntu-24.04-clang-tools-22-
155+
156+
- name: Install Clang 22
157+
run: |
158+
wget -q https://apt.llvm.org/llvm.sh
159+
sudo bash llvm.sh 22
160+
sudo apt-get install -y clang-tools-22
161+
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-22 200
162+
sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-22 200
163+
sudo update-alternatives --install /usr/bin/clang-include-cleaner \
164+
clang-include-cleaner /usr/bin/clang-include-cleaner-22 200
165+
166+
- name: Install libstdc++-16
167+
run: |
168+
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
169+
sudo apt-get update
170+
sudo apt-get install -y g++-16
171+
172+
- name: Fix APT cache permissions
173+
if: always()
174+
run: sudo rm -rf ~/.cache/apt/archives/lock ~/.cache/apt/archives/partial
175+
176+
- name: Configure
177+
run: cmake --preset dev-clang -B build/include-cleaner
178+
-DCMAKE_CXX_FLAGS_INIT=--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/16
179+
180+
- name: Check includes
181+
run: python3 .github/scripts/lint/check_include_cleaner.py build/include-cleaner

0 commit comments

Comments
 (0)