{{ message }}
Repository navigation
Expand file tree
/
Copy pathclang-tidy-verify.py
More file actions
328 lines (272 loc) · 11.5 KB
/
Copy pathclang-tidy-verify.py
File metadata and controls
328 lines (272 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Alex Spataru <https://serial-studio.com/>
# SPDX-License-Identifier: GPL-3.0-or-later
"""clang-tidy advisory runner for Serial Studio.
Runs clang-tidy under the root `.clang-tidy` over first-party C++ sources
(`core/`, `app/src`, `app/tests`) and writes `.tidy-report` at the repo root in
the same shape as `.code-report`: findings grouped by check, one
`path:line -- message` line each. Advisory by design: the exit code is 0
whatever it finds. `--strict` promotes findings to exit 1 and a missing
prerequisite to exit 2, for a CI leg that owns a build directory.
clang-tidy needs a compile database, and this script never configures a build.
It uses an existing `compile_commands.json` (`--build-dir`, else the first
`build/*/compile_commands.json`) and prints one line and skips when there is
none. The binary comes from `--clang-tidy`, then PATH, then the Qt Creator and
LLVM installs under `C:/Developer` and `C:/Program Files` (Homebrew's llvm keg
on macOS). Headers are not run directly: a header's findings arrive through the
source that includes it, filtered by the config's HeaderFilterRegex.
Usage:
python3 scripts/clang-tidy-verify.py core/Ui/UI/SnapGuides.cpp # named files
python3 scripts/clang-tidy-verify.py --changed # working tree + index
python3 scripts/clang-tidy-verify.py --changed --strict # exit 1 on findings
python3 scripts/clang-tidy-verify.py --changed --build-dir build/x --clang-tidy PATH
Exit codes:
0 - ran (findings are advisory) or skipped for a missing prerequisite
1 - --strict and findings were reported
2 - --strict and the check could not run, or an argument error
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
REPORT_PATH = REPO_ROOT / ".tidy-report"
CONFIG_PATH = REPO_ROOT / ".clang-tidy"
FIRST_PARTY_ROOTS = ("core/", "app/src/", "app/tests/")
SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
SKIP_PARTS = frozenset({"ThirdParty", "build"})
# Where an IDE-bundled or standalone clang-tidy lives when it is not on PATH. Globs are relative
# to the drive root so a Qt Creator upgrade or a versioned LLVM directory still resolves.
CANDIDATE_GLOBS = (
"C:/Developer/Qt/Tools/QtCreator/bin/clang/bin/clang-tidy.exe",
"C:/Developer/Qt/Tools/*/bin/clang/bin/clang-tidy.exe",
"C:/Developer/LLVM*/bin/clang-tidy.exe",
"C:/Program Files/LLVM/bin/clang-tidy.exe",
"C:/Program Files/LLVM*/bin/clang-tidy.exe",
"/opt/homebrew/opt/llvm/bin/clang-tidy",
"/usr/local/opt/llvm/bin/clang-tidy",
)
# clang-tidy's diagnostic line: `<path>:<line>:<col>: <severity>: <message> [<check>]`.
FINDING_RE = re.compile(
r"^(?P<path>.+?):(?P<line>\d+):(?P<col>\d+): "
r"(?P<severity>warning|error): (?P<message>.*?) \[(?P<check>[\w.,-]+)\]$"
)
REPORT_HEADER = """# clang-tidy Advisory Report
Generated by `scripts/clang-tidy-verify.py` under the root `.clang-tidy`. Every
entry is an advisory: the config keeps the repo's deliberate style off, so what
is left is worth a look, but none of it fails CI or `sanitize-commit.py`.
"""
@dataclass(frozen=True)
class Finding:
path: str
line: int
column: int
severity: str
check: str
message: str
def find_clang_tidy(explicit: str | None) -> Path | None:
"""Resolve the clang-tidy binary: explicit path, PATH, then the known install locations."""
if explicit:
path = Path(explicit)
return path if path.is_file() else None
on_path = shutil.which("clang-tidy")
if on_path:
return Path(on_path)
for pattern in CANDIDATE_GLOBS:
root = Path(pattern[:3]) if pattern[1] == ":" else Path("/")
rest = pattern[3:] if pattern[1] == ":" else pattern[1:]
if "*" not in rest:
candidate = root / rest
if candidate.is_file():
return candidate
continue
for candidate in sorted(root.glob(rest)):
if candidate.is_file():
return candidate
return None
def find_compile_db(build_dir: str | None) -> Path | None:
"""Resolve the directory holding compile_commands.json; never creates one."""
if build_dir:
candidate = Path(build_dir) / "compile_commands.json"
return candidate.parent if candidate.is_file() else None
found = sorted((REPO_ROOT / "build").glob("*/compile_commands.json"))
return found[0].parent if found else None
def is_first_party_source(rel: str) -> bool:
posix = rel.replace("\\", "/")
if not posix.startswith(FIRST_PARTY_ROOTS):
return False
if not posix.endswith(SOURCE_SUFFIXES):
return False
return SKIP_PARTS.isdisjoint(posix.split("/"))
def git_lines(*args: str) -> list[str]:
result = subprocess.run(
["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=False
)
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def changed_sources() -> list[Path]:
"""Working-tree and staged changes against HEAD, first-party C++ sources only."""
names = git_lines("diff", "--name-only", "HEAD") + git_lines(
"diff", "--name-only", "--cached"
)
unique = sorted(set(names))
return [REPO_ROOT / rel for rel in unique if is_first_party_source(rel)]
def named_sources(args: list[str]) -> list[Path]:
"""Explicit file arguments, resolved against the CWD then the repo root."""
out: list[Path] = []
for arg in args:
path = Path(arg)
if not path.is_absolute() and not path.exists():
path = REPO_ROOT / arg
out.append(path.resolve())
return out
def run_clang_tidy(
binary: Path, db_dir: Path, files: list[Path], extra_args: list[str]
) -> str:
cmd = [str(binary), "-p", str(db_dir), "--quiet"]
cmd += [f"--extra-arg={arg}" for arg in extra_args]
cmd += [str(path) for path in files]
result = subprocess.run(
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False
)
return result.stdout + "\n" + result.stderr
def relative_to_repo(raw: str) -> str | None:
"""Repo-relative POSIX path for a first-party file, else None (system, Qt, lib/)."""
path = Path(raw)
try:
rel = path.resolve().relative_to(REPO_ROOT)
except (ValueError, OSError):
return None
posix = rel.as_posix()
if not posix.startswith(FIRST_PARTY_ROOTS):
return None
if not SKIP_PARTS.isdisjoint(rel.parts):
return None
return posix
def parse_findings(output: str) -> list[Finding]:
"""Diagnostic lines to findings, first-party only, deduplicated across translation units."""
seen: set[Finding] = set()
findings: list[Finding] = []
for line in output.splitlines():
match = FINDING_RE.match(line.strip())
if not match:
continue
rel = relative_to_repo(match.group("path"))
if rel is None:
continue
finding = Finding(
path=rel,
line=int(match.group("line")),
column=int(match.group("col")),
severity=match.group("severity"),
check=match.group("check"),
message=match.group("message"),
)
if finding in seen:
continue
seen.add(finding)
findings.append(finding)
findings.sort(key=lambda f: (f.check, f.path, f.line, f.column))
return findings
def write_report(report_path: Path, findings: list[Finding]) -> None:
"""Group findings by check into the report, mirroring `.code-report`; a clean run removes
any stale report."""
if not findings:
if report_path.exists():
report_path.unlink()
return
by_check: dict[str, list[Finding]] = {}
for finding in findings:
by_check.setdefault(finding.check, []).append(finding)
out: list[str] = [REPORT_HEADER]
out.append(
f"\n## Advisories ({len(findings)}) -- CI passes, fix when convenient\n\n"
"One entry per finding, grouped by check. Suppress a deliberate one\n"
"with `// NOLINT(<check>)` and a reason; disable a check for the whole\n"
"tree in `.clang-tidy`, with a reason, when it fights the repo style.\n"
)
for check in sorted(by_check):
entries = by_check[check]
out.append(f"\n### `{check}` ({len(entries)})\n")
for finding in entries:
out.append(f"- `{finding.path}:{finding.line}` — {finding.message}\n")
out.append(f"\n---\n\n_Total flagged: {len(findings)}_\n")
with report_path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write("".join(out))
def skip(message: str, strict: bool) -> int:
print(f"clang-tidy-verify: {message} -- skipping")
return 2 if strict else 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="clang-tidy advisory runner")
parser.add_argument("files", nargs="*", help="C++ sources to check")
parser.add_argument(
"--changed",
action="store_true",
help="check the first-party C++ sources changed against HEAD (working tree and index)",
)
parser.add_argument("--build-dir", help="directory holding compile_commands.json")
parser.add_argument("--clang-tidy", help="path to the clang-tidy binary")
parser.add_argument(
"--extra-arg",
action="append",
default=[],
help="forwarded to clang-tidy as --extra-arg (repeatable)",
)
parser.add_argument(
"--report",
type=Path,
default=REPORT_PATH,
help="report path (default: .tidy-report at the repo root)",
)
parser.add_argument(
"--no-report", action="store_true", help="skip writing the report"
)
parser.add_argument(
"--strict",
action="store_true",
help="exit 1 on findings and 2 when the check could not run",
)
parser.add_argument(
"--quiet", "-q", action="store_true", help="print the summary line only"
)
args = parser.parse_args(argv)
if not args.files and not args.changed:
parser.error("pass files to check or --changed")
files = named_sources(args.files) if args.files else changed_sources()
if not files:
print("clang-tidy-verify: no first-party C++ sources to check")
return 0
binary = find_clang_tidy(args.clang_tidy)
if binary is None:
return skip(
"clang-tidy not found (PATH, --clang-tidy, Qt Creator, LLVM)", args.strict
)
db_dir = find_compile_db(args.build_dir)
if db_dir is None:
return skip(
"no compile_commands.json (pass --build-dir or configure a build under build/)",
args.strict,
)
if not CONFIG_PATH.is_file():
return skip("no .clang-tidy at the repo root", args.strict)
output = run_clang_tidy(binary, db_dir, files, args.extra_arg)
findings = parse_findings(output)
if not args.quiet:
for finding in findings:
print(f"{finding.path}:{finding.line}: {finding.message} [{finding.check}]")
if not args.no_report:
write_report(args.report, findings)
checks = len({finding.check for finding in findings})
target = args.report.name if not args.no_report else "no report"
print(
f"clang-tidy-verify: {len(findings)} advisories across {checks} checks in "
f"{len(files)} source(s) -> {target}"
)
return 1 if (args.strict and findings) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
You can’t perform that action at this time.
