| #!/usr/bin/env python3 |
| # SPDX-FileCopyrightText: The Eigen Authors |
| # SPDX-License-Identifier: MPL-2.0 |
| |
| """Run clang-tidy on the lines an edit added. |
| |
| ``.clang-tidy`` is the authority on the conventions clang-tidy can express, so |
| rather than restate them this hook runs the real thing on the edited file and |
| reports only diagnostics on added lines (``--line-filter``). Without that |
| restriction the checks would be unusable interactively: ``Eigen/src`` holds |
| ~4100 ``typedef``s, and whole-file output buries the two lines just written. |
| |
| Editing a header under ``Eigen/src`` cannot be compiled directly — the module's |
| ``InternalHeaderCheck.h`` ``#error``s out — so a driver includes the module |
| umbrella first and then the edited header. The umbrella name is read from the |
| ``#error "Please include <X>"`` directive. This mirrors what |
| ``ci/scripts/run-clang-tidy.sh`` does for merge requests. Including the edited |
| header explicitly also covers a new header that the umbrella does not export |
| yet. |
| |
| LLVM's ``clang-tidy-diff.py`` cannot replace this routing: it invokes changed |
| headers directly, which trips Eigen's internal-header guard, and a PostToolUse |
| payload is not a unified diff. |
| |
| No compilation database is required: ``-std=c++14 -I<repo>`` is enough to parse |
| any file in the C++14 trees, which keeps the hook usable in a fresh checkout |
| with no build directory. |
| |
| The hook fails open — a missing clang-tidy, an unparsable file, a translation |
| unit that does not compile, or any unexpected error exits 0 rather than blocking |
| the harness. Only real findings on added lines block. |
| """ |
| |
| import argparse |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| |
| from style_common import (REPO_ROOT, diff_added_lines, hook_edit_snippets, # noqa: E402 |
| hook_post_image_and_added, is_cxx_path, line_filter_json) |
| |
| # Parsing an umbrella costs about a second, so keep the hook off trees whose |
| # conventions these checks do not describe (bench/, demos/, doc/ examples). |
| CHECKED_TREES = ("Eigen/", "unsupported/Eigen/", "test/", "unsupported/test/", "failtest/", "blas/", "lapack/") |
| SRC_TREES = ("Eigen/src/", "unsupported/Eigen/src/") |
| # Modules whose umbrella needs a third-party header we cannot assume is present. |
| EXTERNAL_DEP_MODULES = ("AccelerateSupport", "CholmodSupport", "KLUSupport", "MetisSupport", |
| "PaStiXSupport", "PardisoSupport", "SPQRSupport", "SuperLUSupport", |
| "UmfPackSupport") |
| PLEASE_INCLUDE = re.compile(r'"Please include ([^ "]+)') |
| TIMEOUT_SECONDS = 30 |
| |
| |
| def module_of(rel_path): |
| """Return the ``Eigen/src/<Module>`` name owning ``rel_path``, else None.""" |
| m = re.match(r"^(?:unsupported/)?Eigen/src/([^/]+)/", rel_path) |
| return m.group(1) if m else None |
| |
| |
| def umbrella_for(rel_path, root=REPO_ROOT): |
| """Return the module umbrella to include when linting a src-tree header. |
| |
| The source of truth is the ``#error "Please include <X>"`` directive on the |
| header itself or on its sibling ``InternalHeaderCheck.h``; the |
| ``<root>/<Module>`` heuristic covers arch-specific backends nested deeper |
| than the module root, which carry no directive of their own. |
| """ |
| module = module_of(rel_path) |
| if module is None or module in EXTERNAL_DEP_MODULES: |
| return None |
| for candidate in (rel_path, os.path.join(os.path.dirname(rel_path), "InternalHeaderCheck.h")): |
| try: |
| with open(os.path.join(root, candidate), encoding="utf-8", errors="replace") as handle: |
| hit = PLEASE_INCLUDE.search(handle.read()) |
| except OSError: |
| continue |
| if hit and os.path.isfile(os.path.join(root, hit.group(1))): |
| return hit.group(1) |
| fallback = ("unsupported/Eigen/" if rel_path.startswith("unsupported/") else "Eigen/") + module |
| return fallback if os.path.isfile(os.path.join(root, fallback)) else None |
| |
| |
| def cuda_include_dir(default_root="/usr/local/cuda"): |
| """Include directory of an installed CUDA toolkit, or None if there is none. |
| |
| Every header in the GPU module that includes ``GpuSupport.h`` reaches |
| ``<cuda_runtime.h>``. Without it clang drops the include and every |
| declaration behind it, leaving an AST too truncated to check the module |
| against; with it the header is checked like any other. |
| |
| ``default_root`` is where the toolkit is looked for once the environment |
| names none; a test passes a directory that holds no toolkit so the |
| negative case does not depend on the host. |
| """ |
| roots = [os.environ.get(name) for name in ("CUDAToolkit_ROOT", "CUDA_HOME", "CUDA_PATH")] |
| roots.append(default_root) |
| for root in roots: |
| if root and os.path.isfile(os.path.join(root, "include", "cuda_runtime.h")): |
| return os.path.join(root, "include") |
| return None |
| |
| |
| def compile_args(rel_path, root=REPO_ROOT): |
| """Include paths sufficient to parse ``rel_path`` without a compile database.""" |
| args = ["-std=c++14", "-I" + root] |
| if rel_path.startswith(("test/", "unsupported/test/")): |
| args += ["-I" + os.path.join(root, "test")] |
| cuda = cuda_include_dir() |
| if cuda: |
| args += ["-isystem", cuda] |
| return args |
| |
| |
| def tidy_target(rel_path, tmpdir, root=REPO_ROOT): |
| """Return the path clang-tidy should be pointed at, or None to skip. |
| |
| Source files are linted directly; src-tree headers go through a generated |
| driver that includes their module umbrella followed by the header itself. |
| """ |
| if rel_path.startswith(SRC_TREES) or not os.path.splitext(rel_path)[1]: |
| include = umbrella_for(rel_path, root) if rel_path.startswith(SRC_TREES) else rel_path |
| if include is None: |
| return None |
| driver = os.path.join(tmpdir, "tidy_driver_" + rel_path.replace("/", "_") + ".cpp") |
| with open(driver, "w", encoding="utf-8") as handle: |
| handle.write("#include <%s>\n" % include) |
| if rel_path.startswith(SRC_TREES): |
| handle.write("#include <%s>\n" % rel_path) |
| return driver |
| if os.path.splitext(rel_path)[1].lower() in (".cpp", ".cc", ".cxx"): |
| return os.path.join(root, rel_path) |
| return None # a .h outside the src trees has no reliable standalone TU |
| |
| |
| def run_clang_tidy(per_file, root=REPO_ROOT, tidy=None): |
| """Lint the added lines of each file in ``per_file``. |
| |
| Returns (diagnostics, skipped): ``diagnostics`` are clang-tidy's warning |
| lines, ``skipped`` a list of (path, reason) for files that could not be |
| checked. A file whose translation unit does not compile is one of those: |
| clang-tidy emits no useful check output for it, and reporting it as clean |
| would claim a check that never ran. |
| """ |
| tidy = shutil.which(tidy or "clang-tidy") |
| if tidy is None: |
| return None, [] |
| diagnostics, skipped = [], [] |
| with tempfile.TemporaryDirectory() as tmpdir: |
| for rel_path, added in sorted(per_file.items()): |
| if not added or not is_cxx_path(rel_path) or not rel_path.startswith(CHECKED_TREES): |
| continue |
| target = tidy_target(rel_path, tmpdir, root) |
| if target is None: |
| skipped.append((rel_path, "no standalone translation unit")) |
| continue |
| cmd = [tidy, "--quiet", |
| "--config-file=" + os.path.join(root, ".clang-tidy"), |
| "--header-filter=" + re.escape(rel_path), |
| "--line-filter=" + line_filter_json({rel_path: added}), |
| target, "--"] + compile_args(rel_path, root) |
| try: |
| done = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SECONDS) |
| except OSError: |
| skipped.append((rel_path, "clang-tidy could not be run")) |
| continue |
| except subprocess.TimeoutExpired: |
| skipped.append((rel_path, "clang-tidy timed out after %ds" % TIMEOUT_SECONDS)) |
| continue |
| broke = False |
| output = "\n".join(part for part in (done.stdout, done.stderr) if part) |
| for line in output.splitlines(): |
| if not re.search(r": (warning|error): ", line): |
| continue |
| if "clang-diagnostic-error" in line or ": error: " in line: |
| broke = True |
| continue |
| diagnostics.append(re.sub(r"^" + re.escape(root) + "/", "", line)) |
| if broke: |
| skipped.append((rel_path, "translation unit did not compile")) |
| elif done.returncode != 0: |
| skipped.append((rel_path, "clang-tidy failed")) |
| return diagnostics, skipped |
| |
| |
| def run_hook_mode(tidy=None): |
| try: |
| payload = json.load(sys.stdin) |
| except Exception: |
| return 0 |
| rel_path, snippets = hook_edit_snippets(payload) |
| if rel_path is None or not is_cxx_path(rel_path) or not rel_path.startswith(CHECKED_TREES): |
| return 0 |
| _, added, exact = hook_post_image_and_added(payload, rel_path, snippets) |
| if not exact or not added: |
| # Without real line numbers a line filter would point at the wrong |
| # lines; silence beats a misplaced diagnostic. |
| return 0 |
| diagnostics, skipped = run_clang_tidy({rel_path: added}, tidy=tidy) |
| if not diagnostics: |
| broken = [reason for path, reason in (skipped or []) if reason == "translation unit did not compile"] |
| if broken: |
| # A header edited mid-refactor routinely fails to parse on its own, |
| # so show the user a non-blocking notice rather than feeding Claude |
| # an error with no finding to act on. |
| message = "clang-tidy (%s): not checked — the translation unit did not compile." % rel_path |
| sys.stdout.write(json.dumps({"systemMessage": message}) + "\n") |
| return 0 |
| return 0 |
| sys.stderr.write("clang-tidy (%s) — added lines only:\n" % rel_path) |
| for line in diagnostics[:8]: |
| sys.stderr.write(" %s\n" % line) |
| if len(diagnostics) > 8: |
| sys.stderr.write(" ... and %d more\n" % (len(diagnostics) - 8)) |
| sys.stderr.write("Advisory: these are Eigen conventions from .clang-tidy; keep a flagged construct " |
| "only if it is justified, and say why at the construct.\n") |
| return 2 |
| |
| |
| def run_diff_mode(base, tidy=None): |
| per_file = diff_added_lines(base) |
| diagnostics, skipped = run_clang_tidy(per_file, tidy=tidy) |
| if diagnostics is None: |
| sys.stderr.write("%s not found; skipping.\n" % (tidy or "clang-tidy")) |
| return 0 |
| for line in diagnostics: |
| print(line) |
| for rel_path, reason in skipped: |
| print("%s: not checked (%s)" % (rel_path, reason)) |
| if diagnostics: |
| print("\n%d clang-tidy finding(s) on added lines." % len(diagnostics)) |
| return 1 if diagnostics else 0 |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| mode = parser.add_mutually_exclusive_group(required=True) |
| mode.add_argument("--diff", metavar="BASE", help="lint lines added since merge-base(BASE, HEAD)") |
| mode.add_argument("--claude-hook", action="store_true", help="run as a Claude Code PostToolUse hook") |
| parser.add_argument("-b", "--binary", metavar="CLANG_TIDY", |
| help="clang-tidy executable to use, as a PATH name or a full path " |
| "(e.g. clang-tidy-19 from LLVM's version-suffixed packages); " |
| "default: clang-tidy") |
| args = parser.parse_args() |
| if args.claude_hook: |
| try: |
| return run_hook_mode(tidy=args.binary) |
| except Exception: |
| return 0 # a broken hook must not block the harness |
| if args.binary and shutil.which(args.binary) is None: |
| # In diff mode an explicitly named binary that is absent is a usage |
| # error; silently skipping would claim a check that never ran. Hook |
| # mode above keeps the fail-open contract instead. |
| parser.error("--binary %s not found" % args.binary) |
| return run_diff_mode(args.diff, tidy=args.binary) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |