Files
tqcq 5b871bba08
cpp-template / format (ubuntu-22.04) (push) Successful in 53s
cpp-template / format (ubuntu-24.04) (push) Successful in 54s
cpp-template / debug (ubuntu-22.04) (push) Successful in 1m50s
cpp-template / debug (ubuntu-24.04) (push) Successful in 1m51s
cpp-template / clang-tidy (ubuntu-22.04) (push) Successful in 1m40s
cpp-template / clang-tidy (ubuntu-24.04) (push) Successful in 1m38s
cpp-template / format (ubuntu-20.04) (push) Has been cancelled
cpp-template / debug (ubuntu-20.04) (push) Has been cancelled
cpp-template / clang-tidy (ubuntu-20.04) (push) Has been cancelled
cpp-template / release (ubuntu-20.04) (push) Has been cancelled
cpp-template / release (ubuntu-24.04) (push) Has been cancelled
cpp-template / install-consumer (ubuntu-20.04) (push) Has been cancelled
cpp-template / install-consumer (ubuntu-22.04) (push) Has been cancelled
cpp-template / install-consumer (ubuntu-24.04) (push) Has been cancelled
cpp-template / asan (ubuntu-20.04) (push) Has been cancelled
cpp-template / asan (ubuntu-22.04) (push) Has been cancelled
cpp-template / asan (ubuntu-24.04) (push) Has been cancelled
cpp-template / tsan (ubuntu-20.04) (push) Has been cancelled
cpp-template / tsan (ubuntu-22.04) (push) Has been cancelled
cpp-template / tsan (ubuntu-24.04) (push) Has been cancelled
cpp-template / fuzz-smoke (ubuntu-20.04) (push) Has been cancelled
cpp-template / fuzz-smoke (ubuntu-22.04) (push) Has been cancelled
cpp-template / fuzz-smoke (ubuntu-24.04) (push) Has been cancelled
cpp-template / no-network-negative (ubuntu-20.04) (push) Has been cancelled
cpp-template / no-network-negative (ubuntu-22.04) (push) Has been cancelled
cpp-template / no-network-negative (ubuntu-24.04) (push) Has been cancelled
cpp-template / release (ubuntu-22.04) (push) Has been cancelled
fix: use separate clang-tidy configs for test and production code
- Re-enable readability-magic-numbers in .clang-tidy (strict) for
  production code.
- Add .clang-tidy-test (relaxed) that excludes magic-numbers for
  *_test.cc, *_benchmark.cc, and *_fuzz.cc files.
- clang_tidy.py now selects config by file suffix via _config_for_file(),
  matching _test./_benchmark./_fuzz. against the filename.
2026-05-19 12:55:45 +08:00

285 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Strict clang-tidy policy runner for the C++ template project."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
import sys
from collections.abc import Sequence
from typing import cast
from lib import common, path_filter
STATUS_PASS = "PASS"
STATUS_FAIL = "FAIL"
CLANG_TIDY_TOOL = "clang-tidy"
MIN_CLANG_TIDY_MAJOR = 17
def detect_system_include_extra_args() -> tuple[str, ...]:
"""Detect extra -isystem args for static clang-tidy binaries.
Statically-linked clang-tidy may lack a resource directory, causing
'stddef.h not found' errors when parsing system headers. Query the
local GCC installation for its builtin include path so clang-tidy can
find standard C headers regardless of GCC version or distro.
"""
try:
result = subprocess.run(
["gcc", "-print-file-name=include"],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
return ()
if result.returncode != 0:
return ()
gcc_include = result.stdout.strip()
if gcc_include and pathlib.Path(gcc_include).is_dir():
return (f"--extra-arg=-isystem{gcc_include}",)
return ()
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
root = common.find_project_root(pathlib.Path(__file__))
try:
build_dir_arg = cast(pathlib.Path, args.build_dir)
build_dir = build_dir_arg if build_dir_arg.is_absolute() else root / build_dir_arg
compile_commands = require_compile_commands(build_dir)
files = collect_files(root, cast(Sequence[str], args.files), compile_commands=compile_commands)
list_files = cast(bool, args.list_files)
dry_run = cast(bool, args.dry_run)
if list_files:
for path in files:
print(display_path(root, path))
return 0
print(f"build_dir: {build_dir}")
print(f"candidate_files: {len(files)}")
if dry_run:
common.print_plan(build_plan(root, build_dir=build_dir, files=files))
print(f"{STATUS_PASS}: dry-run planned clang-tidy strict checks")
return 0
tool = require_clang_tidy()
common.print_plan(build_plan(root, build_dir=build_dir, files=files, tool=tool))
return run_clang_tidy(root, build_dir=build_dir, files=files, tool=tool)
except common.ScriptError as error:
print(f"{STATUS_FAIL}: {error}", file=sys.stderr)
return 2
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
_ = parser.add_argument(
"--build-dir",
type=pathlib.Path,
default=pathlib.Path("build/debug"),
help="Build directory containing compile_commands.json, default: build/debug.",
)
_ = parser.add_argument("--dry-run", action="store_true", help="Print planned commands without executing them.")
_ = parser.add_argument("--list-files", action="store_true", help="List C/C++ files selected by the path filter.")
_ = parser.add_argument(
"files",
nargs="*",
help="Optional C/C++ files to check. Defaults to filtered project C/C++ files.",
)
return parser
def collect_files(
root: pathlib.Path,
requested_files: Sequence[str],
*,
compile_commands: pathlib.Path | None = None,
) -> tuple[pathlib.Path, ...]:
if not requested_files:
if compile_commands is None:
return tuple(path_filter.iter_cpp_files(root))
return compile_command_files(root, compile_commands)
files: list[pathlib.Path] = []
for value in requested_files:
path = pathlib.Path(value)
if not path.is_absolute():
path = root / path
path = path.resolve()
if not path.is_file():
raise common.ScriptError(f"clang-tidy input is not a file: {value}")
if not path_filter.is_cpp_source(path):
raise common.ScriptError(f"clang-tidy input is not a C/C++ source/header: {value}")
if is_within_root(path, root) and path_filter.is_under_excluded_dir(path, root):
raise common.ScriptError(f"clang-tidy input is under an excluded directory: {display_path(root, path)}")
files.append(path)
return tuple(sorted(dict.fromkeys(files)))
def compile_command_files(root: pathlib.Path, compile_commands: pathlib.Path) -> tuple[pathlib.Path, ...]:
entries = cast(list[object], json.loads(compile_commands.read_text(encoding="utf-8")))
files: list[pathlib.Path] = []
for entry in entries:
if not isinstance(entry, dict):
continue
entry_map = cast(dict[str, object], entry)
file_value = entry_map.get("file")
if not isinstance(file_value, str):
continue
path = pathlib.Path(file_value)
if not path.is_absolute():
directory_value = entry_map.get("directory")
directory = pathlib.Path(directory_value) if isinstance(directory_value, str) else root
path = directory / path
path = path.resolve()
if not path.is_file():
continue
if not is_within_root(path, root):
continue
if path_filter.is_under_excluded_dir(path, root):
continue
if not path_filter.is_cpp_source(path):
continue
files.append(path)
return tuple(sorted(dict.fromkeys(files)))
_TEST_SUFFIXES = ("_test.", "_benchmark.", "_fuzz.")
def _is_test_like(path: pathlib.Path) -> bool:
"""Return whether a file is a test, benchmark, or fuzz source."""
name = path.name
return any(suffix in name for suffix in _TEST_SUFFIXES)
def _config_for_file(root: pathlib.Path, path: pathlib.Path) -> pathlib.Path:
"""Select the clang-tidy config appropriate for the file type.
Test, benchmark, and fuzz files use .clang-tidy-test which relaxes
readability-magic-numbers. All other files use the strict .clang-tidy.
"""
if _is_test_like(path):
test_config = root / ".clang-tidy-test"
if test_config.is_file():
return test_config
return root / ".clang-tidy"
def build_plan(
root: pathlib.Path,
*,
build_dir: pathlib.Path,
files: Sequence[pathlib.Path],
tool: str = CLANG_TIDY_TOOL,
) -> tuple[common.CommandPlan, ...]:
extra_args = detect_system_include_extra_args()
return tuple(
common.CommandPlan(
label=f"clang-tidy:{display_path(root, path)}",
command=(
tool,
f"-p={build_dir}",
f"--config-file={_config_for_file(root, path)}",
"--warnings-as-errors=*",
"--header-filter=^(src|examples)/.*",
*extra_args,
str(path),
),
cwd=root,
required_tool=tool,
)
for path in files
)
def run_clang_tidy(root: pathlib.Path, *, build_dir: pathlib.Path, files: Sequence[pathlib.Path], tool: str) -> int:
if not files:
print(f"{STATUS_PASS}: no C/C++ files selected")
return 0
failed_files: list[str] = []
for plan in build_plan(root, build_dir=build_dir, files=files, tool=tool):
completed = subprocess.run(list(plan.command), cwd=str(root), check=False)
if completed.returncode != 0:
failed_files.append(plan.label.removeprefix("clang-tidy:"))
if not failed_files:
print(f"{STATUS_PASS}: clang-tidy verified {len(files)} file(s)")
return 0
print(f"{STATUS_FAIL}: clang-tidy failed for {len(failed_files)} file(s)", file=sys.stderr)
for failed_file in failed_files:
print(f"failed_file: {failed_file}", file=sys.stderr)
return 1
def require_compile_commands(build_dir: pathlib.Path) -> pathlib.Path:
compile_commands = build_dir / "compile_commands.json"
if not compile_commands.is_file():
raise common.ScriptError(
"compile_commands.json is required before clang-tidy execution; "
+ f"expected: {compile_commands}. Configure debug with "
+ "`cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`."
)
return compile_commands
def require_clang_tidy(tool: str = CLANG_TIDY_TOOL) -> str:
path = common.require_tool(tool)
completed = subprocess.run([path, "--version"], text=True, capture_output=True, check=False)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or f"exit {completed.returncode}"
raise common.ScriptError(f"Unable to run {tool} --version: {detail}")
version_text = completed.stdout.strip()
major = parse_clang_tidy_major(version_text)
if major is None:
raise common.ScriptError(f"Unable to parse {tool} version from: {version_text}")
if major < MIN_CLANG_TIDY_MAJOR:
raise common.ScriptError(f"{tool} {MIN_CLANG_TIDY_MAJOR}+ is required; found major version {major}: {version_text}")
print(f"clang-tidy version: {version_text}")
return path
def parse_clang_tidy_major(version_text: str) -> int | None:
match = re.search(r"\bversion\s+(\d+)(?:\.|\b)", version_text)
if match is None:
return None
return int(match.group(1))
def display_path(root: pathlib.Path, path: pathlib.Path) -> str:
if is_within_root(path, root):
return str(path.relative_to(root))
return str(path)
def is_within_root(path: pathlib.Path, root: pathlib.Path) -> bool:
try:
_ = path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
if __name__ == "__main__":
raise SystemExit(main())