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
- 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.
285 lines
9.6 KiB
Python
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())
|