Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions .ci/clang-tidy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import argparse
import re
import subprocess
import sys
import yaml
Expand Down Expand Up @@ -65,6 +66,12 @@ def parse_arguments():
help="Comma-separated list of diagnostic names to ignore (e.g., 'clang-diagnostic-error,clang-diagnostic-warning')",
)

parser.add_argument(
"--file-list",
type=Path,
help="Path to a newline-separated list of source files to analyze",
)

output_group = parser.add_mutually_exclusive_group()
output_group.add_argument(
"--quiet",
Expand All @@ -91,9 +98,38 @@ def parse_arguments():
f"compile_commands.json not found in build directory: {args.build_directory}"
)

if args.file_list is not None and not args.file_list.exists():
parser.error(f"File list does not exist: {args.file_list}")

return args


def create_file_pattern(file_list: Path) -> str:
"""
Create a regular expression that only matches the requested source files.

Args:
file_list: Path to a file containing one source path per line

Returns:
Regular expression fragment matching the selected files
"""

file_patterns = []
for line in file_list.read_text().splitlines():
source = line.strip()
if not source:
continue

resolved_source = (Path.cwd() / source).resolve(strict=False)
file_patterns.append(re.escape(str(resolved_source)))

if not file_patterns:
return ""

return "(?:" + "|".join(file_patterns) + ")"


def count_findings(file_name: Path, ignored_checks: list = []) -> int:
"""
Reads the YAML file generated by clang-tidy and counts the number of findings per diagnostic name.
Expand All @@ -107,7 +143,7 @@ def count_findings(file_name: Path, ignored_checks: list = []) -> int:
"""

with open(file_name, "r") as f:
data = yaml.safe_load(f)
data = yaml.safe_load(f) or {}

# Count findings per diagnostic name
counter = Counter()
Expand Down Expand Up @@ -155,7 +191,11 @@ def count_findings(file_name: Path, ignored_checks: list = []) -> int:


def run_clang_tidy(
build_dir: Path, output_file: Path, exclude_pattern: str, quiet: bool
build_dir: Path,
output_file: Path,
exclude_pattern: str,
quiet: bool,
include_pattern: str,
) -> int:
"""
Run clang-tidy on the build directory.
Expand All @@ -165,21 +205,24 @@ def run_clang_tidy(
output_file: Path to the output YAML file
exclude_pattern: Regular expression pattern to exclude files
quiet: Whether to suppress run-clang-tidy output
include_pattern: Regular expression fragment matching files to analyze

Returns:
Return code from run-clang-tidy
"""
# Convert the exclusion pattern to a negative lookahead pattern
# This makes run-clang-tidy check files that DO NOT match the exclude pattern
negated_pattern = f"^(?!.*({exclude_pattern})).*"
target_pattern = include_pattern or ".*"
if exclude_pattern:
target_pattern = f"^(?!.*(?:{exclude_pattern})){target_pattern}$"
else:
target_pattern = f"^{target_pattern}$"

cmd = [
"run-clang-tidy-17.py",
"-p",
str(build_dir),
"-export-fixes",
str(output_file),
negated_pattern,
target_pattern,
]

if quiet:
Expand All @@ -201,10 +244,19 @@ def run_clang_tidy(

if __name__ == "__main__":
args = parse_arguments()
include_pattern = ""

if args.file_list is not None:
include_pattern = create_file_pattern(args.file_list)
if not include_pattern:
print("No source files selected for clang-tidy.")
sys.exit(0)

print(f"Running clang-tidy on build directory: {args.build_directory}")
print(f"Output file: {args.output_file}")
print(f"Exclude pattern: {args.exclude}")
if args.file_list is not None:
print(f"File list: {args.file_list}")
print("-" * 60)

# Run clang-tidy
Expand All @@ -213,6 +265,7 @@ def run_clang_tidy(
args.output_file,
args.exclude,
not args.verbose, # quiet mode is default unless --verbose is specified
include_pattern,
)

# Parse ignored checks
Expand Down
193 changes: 193 additions & 0 deletions .ci/determine-clang-tidy-scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Determine clang-tidy execution scope for CI workflows.

This helper computes whether clang-tidy should run in:
- full mode: for pushes to main and merge_group events (analyze full repository scope)
- changed mode: for pull_request events (analyze changed C/C++ files only)

It writes both the filtered changed-file list and GitHub step outputs (mode, has_changes)
used by the workflow to choose between full scan, changed-scope scan, or skip path.
"""

import argparse
import json
import subprocess
import sys
from pathlib import Path


ZERO_SHA = "0000000000000000000000000000000000000000"
RELEVANT_SUFFIXES = (".c", ".cc", ".cpp", ".h", ".hh", ".hpp", ".hxx")
SUPPORTED_EVENTS = {"pull_request", "merge_group", "push"}


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse CLI arguments for clang-tidy scope determination."""
parser = argparse.ArgumentParser(description="Determine clang-tidy CI scope")
parser.add_argument("--event-name", required=True, help="GitHub event name")
parser.add_argument("--ref-name", required=True, help="GitHub ref name")
parser.add_argument("--sha", required=True, help="GitHub commit SHA")
parser.add_argument(
"--event-path", type=Path, required=True, help="Path to GitHub event payload"
)
parser.add_argument(
"--output-file",
type=Path,
required=True,
help="File that will receive changed C/C++ source paths",
)
parser.add_argument(
"--github-output",
type=Path,
required=True,
help="Path to the GitHub Actions step output file",
)
return parser.parse_args(argv)


def load_event(event_path: Path) -> dict:
"""Load and validate the GitHub event payload JSON object."""
try:
with event_path.open("r", encoding="utf-8") as event_file:
event = json.load(event_file)
except FileNotFoundError as exc:
raise RuntimeError(
f"GitHub event payload file not found: {event_path}."
) from exc
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Invalid JSON in GitHub event payload {event_path}: {exc.msg} at line {exc.lineno} column {exc.colno}."
) from exc

if not isinstance(event, dict):
raise RuntimeError(f"GitHub event payload {event_path} must contain a JSON object.")

return event


def determine_refs(event_name: str, sha: str, event: dict) -> tuple[str, str]:
"""Resolve base and head SHAs for supported GitHub events."""
if event_name != "pull_request":
raise RuntimeError(
f"Unsupported GitHub event '{event_name}' for ref resolution. Supported event: pull_request."
)

head_sha = sha
base_sha = event.get("pull_request", {}).get("base", {}).get("sha", "")

if not base_sha:
raise RuntimeError(f"Unable to determine base SHA for GitHub event '{event_name}'.")

if not head_sha:
raise RuntimeError("Unable to determine head SHA for clang-tidy scope calculation.")

return base_sha, head_sha


def run_git_command(args: list[str]) -> str:
"""Run a git command and return stdout, raising RuntimeError on failure."""
try:
result = subprocess.run(
["git", *args],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError as exc:
raise RuntimeError("The 'git' executable is not available in the CI environment.") from exc
except subprocess.CalledProcessError as exc:
details = (exc.stderr or exc.stdout or "no additional output").strip()
raise RuntimeError(
f"Git command failed: git {' '.join(args)}: {details}"
) from exc

return result.stdout


def list_changed_sources(base_sha: str, head_sha: str) -> list[str]:
"""Return sorted changed C/C++ file paths between base and head revisions."""
if base_sha and base_sha != ZERO_SHA:
try:
merge_base = run_git_command(["merge-base", head_sha, base_sha]).strip()
diff_output = run_git_command(
["diff", "--name-only", "--diff-filter=ACMR", f"{merge_base}...{head_sha}"]
)
except RuntimeError as exc:
if "git merge-base" not in str(exc):
raise

# PR merge refs and merge-queue refs may not contain every original
# commit object locally. Falling back to a direct base..head diff
# still scopes clang-tidy to files changed by the checked-out ref.
diff_output = run_git_command(
["diff", "--name-only", "--diff-filter=ACMR", f"{base_sha}..{head_sha}"]
)
else:
diff_output = run_git_command(
["diff-tree", "--no-commit-id", "--name-only", "--diff-filter=ACMR", "-r", head_sha]
)

return sorted(
{
line.strip()
for line in diff_output.splitlines()
if line.strip().endswith(RELEVANT_SUFFIXES)
}
)


def write_changed_files(output_file: Path, changed_files: list[str]) -> None:
"""Write changed file paths to disk as a newline-separated list."""
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(
"\n".join(changed_files) + ("\n" if changed_files else ""),
encoding="utf-8",
)


def write_github_outputs(github_output: Path, mode: str, has_changes: bool) -> None:
"""Append mode and has_changes outputs for GitHub Actions steps."""
github_output.parent.mkdir(parents=True, exist_ok=True)
with github_output.open("a", encoding="utf-8") as output_file:
output_file.write(f"mode={mode}\n")
output_file.write(f"has_changes={'true' if has_changes else 'false'}\n")


def main(argv: list[str] | None = None) -> int:
"""Execute scope selection flow and write workflow output artifacts."""
args = parse_args(argv)
args.output_file.parent.mkdir(parents=True, exist_ok=True)
args.output_file.write_text("", encoding="utf-8")

try:
if (args.event_name == "push" and args.ref_name == "main") or args.event_name == "merge_group":
write_github_outputs(args.github_output, "full", True)
return 0

if args.event_name == "push":
# The workflow is intended to run push only on main. If triggered on
# other branches, ignore silently instead of failing.
write_github_outputs(args.github_output, "changed", False)
return 0

event = load_event(args.event_path)
base_sha, head_sha = determine_refs(args.event_name, args.sha, event)
changed_files = list_changed_sources(base_sha, head_sha)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 1

write_changed_files(args.output_file, changed_files)
write_github_outputs(args.github_output, "changed", bool(changed_files))

if changed_files:
print("Changed files:")
for changed_file in changed_files:
print(changed_file)
else:
print("No modified source/header files found for clang-tidy.")

return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading