Skip to content
Closed
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
45 changes: 45 additions & 0 deletions .ci/clang-tidy-ci-scenarios.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Clang-tidy CI scenario checklist

Use this checklist to validate the changed-scope clang-tidy flow in real CI while non-clang-tidy PR workflows are temporarily disabled.

## Campaign setup

1. Keep `.github/workflows/clang-tidy.yml` PR trigger enabled.
2. Keep `pull_request` removed from other top-level workflows during this campaign.
3. For each scenario branch, open a PR against `main` and record outcomes below.

## Scenarios

| ID | Branch name | Change type | Expected mode | Expected has_changes | Expected outcome |
| --- | --- | --- | --- | --- | --- |
| S01 | `ci-scope-s01-header-only` | Change only a common header (`.h/.hpp`) | `changed` | `true` | Header resolves to one or more TUs; clang-tidy runs |
| S02 | `ci-scope-s02-header-plus-source` | Change one header and one source | `changed` | `true` | Union of direct source + header-impacted TUs |
| S03 | `ci-scope-s03-rename-source` | `git mv` a `.cpp` file | `changed` | `true` | Renamed source appears in file list; clang-tidy runs |
| S04 | `ci-scope-s04-rename-header` | `git mv` a header and update includes | `changed` | `true` | Renamed header resolves to impacted TUs |
| S05 | `ci-scope-s05-delete-source` | Delete a `.cpp` file | `changed` | usually `false` | Deleted file excluded by diff filter; no missing-file failure |
| S06 | `ci-scope-s06-non-cpp-only` | Change only docs/config | `changed` | `false` | Scope step reports no relevant files; clang-tidy skipped |
| S07 | `ci-scope-s07-merge-history` | Scenario branch includes a merge commit | `changed` | `true` or `false` | Merge-base path or fallback works without crash |

## Evidence to capture per scenario

- Scope step output values: `mode`, `has_changes`
- Logged changed files list
- Logged resolved translation units list
- Final clang-tidy step status (run/skip, pass/fail)
- Any warnings from scope/resolve scripts

## Result log

| ID | PR URL | Status | Notes |
| --- | --- | --- | --- |
| S01 | | | |
| S02 | | | |
| S03 | | | |
| S04 | | | |
| S05 | | | |
| S06 | | | |
| S07 | | | |

## Rollback

After completing validation, restore original `pull_request` triggers in all top-level workflows before merging to `main`.
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
176 changes: 176 additions & 0 deletions .ci/determine-clang-tidy-scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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:
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:
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]:
if event_name not in SUPPORTED_EVENTS:
raise RuntimeError(
f"Unsupported GitHub event '{event_name}'. Supported events: {', '.join(sorted(SUPPORTED_EVENTS))}."
)

base_sha = ""
head_sha = sha

if event_name == "pull_request":
base_sha = event.get("pull_request", {}).get("base", {}).get("sha", "")
elif event_name == "merge_group":
base_sha = event.get("merge_group", {}).get("base_sha", "")
elif event_name == "push":
base_sha = event.get("before", "")

if event_name in {"pull_request", "merge_group"} and 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:
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]:
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:
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:
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:
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":
write_github_outputs(args.github_output, "full", True)
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