libeigen/eigen!2775 Co-authored-by: Rasmus Munk Larsen <rmlarsen@gmail.com>
224 lines
8.8 KiB
Python
224 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: The Eigen Authors
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
|
|
"""Shared plumbing for the added-line style checks.
|
|
|
|
Both ``check_style.py`` (textual conventions) and ``clang_tidy_hook.py``
|
|
(clang-tidy conventions) need the same two things: the set of line numbers a
|
|
change ADDED to each file, and the ability to turn a Claude Code PostToolUse
|
|
payload into that set. That logic lives here so the two checkers cannot drift
|
|
apart in what they consider an added line.
|
|
|
|
Run directly to emit a clang-tidy ``--line-filter`` argument for the files a
|
|
diff touches, which is how ``ci/scripts/run-clang-tidy.sh`` restricts an MR's
|
|
diagnostics to its own lines:
|
|
|
|
python3 scripts/style_common.py --line-filter BASE [--] [path ...]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CXX_EXT = {".h", ".hpp", ".hxx", ".cpp", ".cc", ".cxx", ".cu", ".cuh", ".inc"}
|
|
EXTENSIONLESS_HEADER_TREES = ("Eigen/", "unsupported/Eigen/")
|
|
|
|
|
|
def is_cxx_path(rel_path):
|
|
"""Return whether ``rel_path`` is C++ source, including Eigen's
|
|
extensionless public module headers such as ``Eigen/Core``."""
|
|
return (os.path.splitext(rel_path)[1].lower() in CXX_EXT
|
|
or (not os.path.splitext(os.path.basename(rel_path))[1]
|
|
and rel_path.startswith(EXTENSIONLESS_HEADER_TREES)))
|
|
|
|
|
|
def git(root, *args):
|
|
return subprocess.run(["git"] + list(args), cwd=root, capture_output=True, text=True)
|
|
|
|
|
|
def read_post_image(root, rel_path):
|
|
try:
|
|
with open(os.path.join(root, rel_path), encoding="utf-8", errors="replace") as handle:
|
|
return handle.read()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def added_lines_from_diff(diff_text):
|
|
"""Parse ``git diff -U0`` output into {relative_path: set(added line numbers)}."""
|
|
files = {}
|
|
path, new_line = None, 0
|
|
for raw in diff_text.splitlines():
|
|
if raw.startswith("+++ "):
|
|
name = raw[4:]
|
|
path = None if name == "/dev/null" else name[2:] if name.startswith("b/") else name
|
|
elif raw.startswith("@@"):
|
|
m = re.search(r"\+(\d+)(?:,(\d+))?", raw)
|
|
new_line = int(m.group(1)) if m else 0
|
|
elif raw.startswith("+") and not raw.startswith("+++"):
|
|
if path is not None:
|
|
files.setdefault(path, set()).add(new_line)
|
|
new_line += 1
|
|
elif not raw.startswith("-") and not raw.startswith("\\"):
|
|
# "\ No newline at end of file" is a marker, not a context line.
|
|
new_line += 1
|
|
return files
|
|
|
|
|
|
def added_from_structured_patch(tool_response):
|
|
"""Derive added line numbers from the tool response's structured patch,
|
|
which records the exact edited hunks. A valid deletion-only patch returns
|
|
an empty set; None means absent or malformed data and enables fallback."""
|
|
try:
|
|
added = set()
|
|
for hunk in tool_response["structuredPatch"]:
|
|
line_no = int(hunk["newStart"])
|
|
for entry in hunk["lines"]:
|
|
if entry.startswith("+"):
|
|
added.add(line_no)
|
|
line_no += 1
|
|
elif not entry.startswith("-") and not entry.startswith("\\"):
|
|
# "\ No newline at end of file" is a marker, not a context line.
|
|
line_no += 1
|
|
return added
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def hook_added_line_numbers(content, snippets):
|
|
"""Map the strings an edit added onto post-image line numbers.
|
|
|
|
Each snippet must occur exactly once — a repeated occurrence cannot be
|
|
told apart from pre-existing identical text, and would mark unrelated
|
|
lines. A snippet's span excludes the line after its trailing newline.
|
|
Returns None when any snippet is absent or ambiguous; the caller then
|
|
checks the snippet text standalone."""
|
|
added = set()
|
|
for snippet in snippets:
|
|
if not snippet:
|
|
continue
|
|
start = content.find(snippet)
|
|
if start < 0 or content.find(snippet, start + 1) >= 0:
|
|
return None
|
|
first = content.count("\n", 0, start) + 1
|
|
span = snippet.count("\n") + (0 if snippet.endswith("\n") else 1)
|
|
added.update(range(first, first + max(span, 1)))
|
|
return added
|
|
|
|
|
|
def hook_edit_snippets(payload):
|
|
"""Return (rel_path, snippets) for an edit payload, or (None, None) when
|
|
the tool call is not an in-repository C++ edit this checker handles."""
|
|
tool_input = payload.get("tool_input", {}) or {}
|
|
tool_name = payload.get("tool_name", "")
|
|
path = tool_input.get("file_path", "") or ""
|
|
if not path:
|
|
return None, None
|
|
rel_path = os.path.relpath(os.path.abspath(path), REPO_ROOT).replace(os.sep, "/")
|
|
if rel_path.startswith(".."):
|
|
return None, None
|
|
if tool_name == "Write":
|
|
snippets = [tool_input.get("content", "")]
|
|
elif tool_name == "Edit":
|
|
snippets = [tool_input.get("new_string", "")]
|
|
elif tool_name == "MultiEdit":
|
|
snippets = [e.get("new_string", "") for e in tool_input.get("edits", [])]
|
|
else:
|
|
return None, None
|
|
if not any(s.strip() for s in snippets):
|
|
return None, None
|
|
return rel_path, snippets
|
|
|
|
|
|
def hook_post_image_and_added(payload, rel_path, snippets):
|
|
"""Resolve the post-image lines and added line numbers for an edit.
|
|
|
|
The hook runs after the edit, so the file on disk is the post-image. The
|
|
edited lines come from the tool response's structured patch when present,
|
|
else from locating a uniquely occurring added string. Returns
|
|
(lines, added, exact): ``exact`` is False when the lines could not be
|
|
located in the real file and the snippet text is being checked standalone.
|
|
"""
|
|
text = read_post_image(REPO_ROOT, rel_path)
|
|
added = None
|
|
lines = None
|
|
if text is not None:
|
|
lines = text.splitlines()
|
|
added = added_from_structured_patch(payload.get("tool_response") or {})
|
|
if added is None:
|
|
if payload.get("tool_name") == "Write":
|
|
added = set(range(1, len(lines) + 1))
|
|
else:
|
|
added = hook_added_line_numbers(text, snippets)
|
|
if added is None:
|
|
lines = "\n".join(snippets).splitlines()
|
|
return lines, set(range(1, len(lines) + 1)), False
|
|
return lines, added, True
|
|
|
|
|
|
def diff_added_lines(base, root=REPO_ROOT, include_untracked=True):
|
|
"""Return {rel_path: set(added line numbers)} for everything added since
|
|
merge-base(base, HEAD), optionally including whole untracked C++ files."""
|
|
merge_base = git(root, "merge-base", base, "HEAD")
|
|
resolved = merge_base.stdout.strip() if merge_base.returncode == 0 else base
|
|
diff = git(root, "diff", "-U0", "--no-color", resolved)
|
|
if diff.returncode not in (0, 1):
|
|
sys.stderr.write(diff.stderr)
|
|
raise SystemExit(2)
|
|
per_file = added_lines_from_diff(diff.stdout)
|
|
if include_untracked:
|
|
untracked = git(root, "ls-files", "--others", "--exclude-standard")
|
|
for rel_path in untracked.stdout.splitlines():
|
|
if is_cxx_path(rel_path):
|
|
text = read_post_image(root, rel_path)
|
|
if text is not None:
|
|
per_file.setdefault(rel_path, set()).update(range(1, len(text.splitlines()) + 1))
|
|
return per_file
|
|
|
|
|
|
def as_ranges(numbers):
|
|
"""Collapse a set of line numbers into sorted inclusive [start, end] pairs."""
|
|
ranges = []
|
|
for number in sorted(numbers):
|
|
if ranges and number == ranges[-1][1] + 1:
|
|
ranges[-1][1] = number
|
|
else:
|
|
ranges.append([number, number])
|
|
return ranges
|
|
|
|
|
|
def line_filter_json(per_file):
|
|
"""Build clang-tidy's --line-filter argument from {rel_path: line numbers}.
|
|
|
|
clang-tidy matches ``name`` as a path suffix and, when the filter is
|
|
non-empty, reports nothing for files it does not list — so the filter
|
|
doubles as the file filter. Repository-relative paths are used rather
|
|
than bare basenames, which would collide across modules.
|
|
"""
|
|
return json.dumps([{"name": rel_path, "lines": as_ranges(lines)}
|
|
for rel_path, lines in sorted(per_file.items()) if lines])
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--line-filter", metavar="BASE", required=True,
|
|
help="emit a clang-tidy --line-filter for lines added since merge-base(BASE, HEAD)")
|
|
parser.add_argument("paths", nargs="*", help="restrict the filter to these repository-relative paths")
|
|
args = parser.parse_args()
|
|
per_file = diff_added_lines(args.line_filter, include_untracked=False)
|
|
if args.paths:
|
|
wanted = set(args.paths)
|
|
per_file = {p: lines for p, lines in per_file.items() if p in wanted}
|
|
print(line_filter_json(per_file))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|