| #!/usr/bin/env python3 |
| # SPDX-FileCopyrightText: The Eigen Authors |
| # SPDX-License-Identifier: MPL-2.0 |
| |
| """Advisory style check for added C++ code, for what clang-tidy cannot say. |
| |
| Flags the problems that recur in this repository's code reviews and that no |
| clang-tidy check states: narration-comment verbosity (see the comment rules in |
| ``AGENTS.md``), ``enum`` constant blocks, ``std::integral_constant<bool,...>``, |
| C++17-and-later constructs in the C++14 trees, and ``std::`` math in library |
| headers where ``numext::`` is required. Conventions clang-tidy *can* state |
| live in ``.clang-tidy`` and are checked by ``clang_tidy_hook.py`` against the |
| same added lines. Only lines a change ADDS are reported, but each file's |
| complete post-image is lexed so surrounding context — an enclosing block |
| comment, a Doxygen continuation, a multi-line initializer — is classified |
| correctly. |
| |
| The findings are advisory: a flagged construct may be justified, in which |
| case keep it and state the reason where the construct is. |
| |
| Modes: |
| --diff BASE check lines added relative to ``merge-base(BASE, HEAD)``, |
| including uncommitted changes and untracked C++ files; |
| exit 1 if findings |
| --claude-hook run as a Claude Code PostToolUse hook: read the tool-call |
| JSON from stdin and check the text the edit added; exit 2 |
| if findings so the harness feeds them back to the model |
| |
| The Claude Code hook is registered for this repository in |
| ``.claude/settings.json``. Other agents and humans can run the diff mode |
| directly, e.g. ``python3 scripts/check_style.py --diff origin/master``. |
| """ |
| |
| import argparse |
| import json |
| import os |
| import re |
| import sys |
| |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| |
| from style_common import (REPO_ROOT, added_lines_from_diff, added_from_structured_patch, # noqa: E402,F401 |
| diff_added_lines, hook_added_line_numbers, hook_edit_snippets, |
| hook_post_image_and_added, is_cxx_path, read_post_image) |
| |
| # Trees whose headers and tests must compile as C++14 (see AGENTS.md rule 3). |
| CXX14_TREES = ("Eigen/", "unsupported/Eigen/", "test/", "unsupported/test/", "failtest/", "blas/", "lapack/") |
| # Guarded exceptions with a documented newer requirement: SYCL sources build |
| # only in the C++17 SYCL configurations, while duccfft and the structured- |
| # binding failtests explicitly select C++17 in their test CMakeLists files. |
| CXX17_PREFIXES = ("Eigen/src/Core/arch/SYCL/",) |
| CXX17_FILES = { |
| "test/sycl_basic.cpp", |
| "unsupported/Eigen/src/FFT/duccfft_impl.h", |
| "unsupported/test/duccfft.cpp", |
| "failtest/structured_bindings_dynamic_matrix.cpp", |
| "failtest/structured_bindings_dynamic_array.cpp", |
| "failtest/structured_bindings_rowmajor.cpp", |
| } |
| # Library implementation headers, where numext:: is required over std:: math. |
| LIBRARY_SRC_TREES = ("Eigen/src/", "unsupported/Eigen/src/") |
| |
| DOXYGEN = re.compile(r"^\s*(/\*\*|/\*!|///|//!)") |
| LICENSE = re.compile(r"SPDX|Copyright|License|Mozilla Public", re.I) |
| # A bibliography is provenance, which AGENTS.md keeps, and it is long by nature: one entry per |
| # work cited already exceeds the block threshold. Matched on the block's own header line so that |
| # ordinary prose mentioning a reference is still counted. |
| REFERENCES = re.compile(r"^\s*(?://|/\*)?\s*References:\s*$", re.M) |
| STD_MATH = ( |
| r"abs|arg|conj|real|imag|sqrt|cbrt|isnan|isinf|isfinite|signbit|exp|exp2|expm1|log|log1p|log2|pow|" |
| r"sin|cos|tan|asin|acos|atan|atan2|asinh|acosh|atanh|sinh|cosh|tanh|hypot|fma|copysign|ldexp|floor|" |
| r"ceil|round|rint|trunc|fmod" |
| ) |
| |
| # Convention checks applied per added code line (comments and literals blanked). |
| # |
| # Only conventions clang-tidy cannot currently state belong here; NULL and |
| # typedef moved to modernize-use-nullptr and modernize-use-using in |
| # .clang-tidy, which clang_tidy_hook.py and ci/scripts/run-clang-tidy.sh run |
| # against the same added lines. The entries below are the remainder: those |
| # needing a CustomChecks query (parked in .clang-tidy until CI's clang-tidy |
| # reaches 22) and those no AST matcher reaches at all. |
| # |
| # One known gap in that handover: modernize-use-using in clang-tidy 18 (the |
| # checkformat:clangtidy toolchain) reports typedefs at class and namespace |
| # scope but not function-local ones (measured — Block.h 17 of 18, Redux.h 24 |
| # of 25, GeneralMatrixMatrix.h 21 of 28). That was an upstream defect, |
| # llvm/llvm-project#72179, fixed in the 19.x release, so the gap closes when |
| # the CI image's clang-tidy moves; until then a function-local typedef in new |
| # code goes unreported. Reintroducing a regex for it would bring back the |
| # false positives this handover removes. modernize-use-nullptr has no gap. |
| CODE_CHECKS = [ |
| # Awaiting the parked eigen-bool-constant query. |
| (r"std::integral_constant<\s*bool\b", "std::integral_constant<bool,...>: use Eigen's bool_constant (Meta.h)"), |
| # Awaiting the parked eigen-enum-constant query. No stock check states |
| # this rule: cppcoreguidelines-use-enum-class asks for `enum class`, |
| # whereas Eigen wants static constexpr for trait constants. |
| (r"^\s*enum\s*(?::[^{}]+)?\{", "enum constant block: trait/evaluator constants are static constexpr in new " |
| "code; Flags is `unsigned int`"), |
| ] |
| CXX14_CHECKS = [ |
| # Awaiting the parked eigen-if-constexpr query. A C++14 build does not |
| # reliably reject this on its own: clang accepts `if constexpr` as an |
| # extension and only warns under -Wc++17-extensions. |
| (r"\bif\s+constexpr\b", "if constexpr: supported code compiles as C++14; use EIGEN_IF_CONSTEXPR (...) with a " |
| "condition that is valid either way"), |
| # These do fail the default C++14 build; flagging them at edit time just |
| # shortens the loop, and covers headers no built target happens to include. |
| (r"\bstd::(any|span|optional|variant|string_view|byte|filesystem|void_t)\b", |
| "C++17/20 library facility: the supported baseline is C++14"), |
| ] |
| # Also C++14-tree only (CMakeLists.txt defaults CMAKE_CXX_STANDARD to 14), and |
| # not expressible as a matcher — designated initializers do not parse in C++14, |
| # so there is no AST for a check to match. |
| DESIGNATED_MSG = "designated initializer: C++20-only; use aggregate assignment with /*name=*/ comments" |
| |
| |
| RAW_PREFIX = re.compile(r"(?:^|[^0-9A-Za-z_])(?:u8|u|U|L)?R$") |
| |
| |
| def is_digit_separator(line, i): |
| """Return whether the apostrophe at ``i`` belongs to a C++ number.""" |
| if i + 1 >= len(line) or not line[i + 1].isalnum(): |
| return False |
| start = i |
| while start > 0 and (line[start - 1].isalnum() or line[start - 1] in "._'"): |
| start -= 1 |
| token = line[start:i] |
| return bool(token) and (token[0].isdigit() or (len(token) > 1 and token[0] == "." and token[1].isdigit())) |
| |
| |
| def scan_quoted(line, j, quote): |
| """Scan a quoted literal's remainder from position ``j``. |
| |
| Returns (next_position, closed, spliced): ``spliced`` is True when the |
| line ends with a backslash inside the literal, so the literal continues |
| on the next physical line. |
| """ |
| n = len(line) |
| while j < n: |
| if line[j] == "\\": |
| if j == n - 1: |
| return n, False, True |
| j += 2 |
| continue |
| if line[j] == quote: |
| return j + 1, True, False |
| j += 1 |
| return n, False, False |
| |
| |
| def lex_lines(lines): |
| """Lex a file's complete contiguous text. |
| |
| Returns (code_lines, comment_only, doxygen_only) as parallel lists: per |
| line, the code with comments and string/character literals blanked, the |
| stripped comment text when the line holds no code (else None), and whether |
| that comment belongs to Doxygen. Block comments, raw string literals, and |
| backslash-spliced ordinary literals carry their state across physical |
| lines. |
| """ |
| code_lines, comment_only, doxygen_only = [], [], [] |
| in_block = False |
| block_doxygen = False |
| raw_terminator = None # e.g. )delim" while inside a raw string literal |
| open_quote = None # quote character of a spliced ordinary literal |
| for line in lines: |
| out, i, n = [], 0, len(line) |
| had_code = False |
| had_comment = in_block |
| line_doxygen = block_doxygen if in_block else False |
| literal_at_start = raw_terminator is not None or open_quote is not None |
| while i < n: |
| if in_block: |
| j = line.find("*/", i) |
| if j < 0: |
| i = n |
| else: |
| in_block = False |
| block_doxygen = False |
| i = j + 2 |
| continue |
| if raw_terminator is not None: |
| j = line.find(raw_terminator, i) |
| if j < 0: |
| i = n |
| else: |
| i = j + len(raw_terminator) |
| raw_terminator = None |
| continue |
| if open_quote is not None: |
| i, closed, spliced = scan_quoted(line, i, open_quote) |
| if closed or not spliced: # an unspliced open literal is ill-formed; close at EOL |
| open_quote = None |
| continue |
| c = line[i] |
| if c == "/" and i + 1 < n and line[i + 1] == "/": |
| had_comment = True |
| line_doxygen = line_doxygen or bool(DOXYGEN.match(line[i:])) |
| break |
| if c == "/" and i + 1 < n and line[i + 1] == "*": |
| had_comment = True |
| block_doxygen = line.startswith(("/**", "/*!"), i) |
| line_doxygen = line_doxygen or block_doxygen |
| in_block = True |
| i += 2 |
| continue |
| if c == '"' and RAW_PREFIX.search(line[:i]): |
| paren = line.find("(", i + 1) |
| delimiter = line[i + 1:paren] if paren >= 0 else None |
| if delimiter is not None and len(delimiter) <= 16 and not re.search(r"[()\\\s]", delimiter): |
| out.append('""') |
| had_code = True |
| raw_terminator = ")" + delimiter + '"' |
| j = line.find(raw_terminator, paren + 1) |
| if j < 0: |
| i = n |
| else: |
| i = j + len(raw_terminator) |
| raw_terminator = None |
| continue |
| if c == "'" and is_digit_separator(line, i): |
| # C++14 digit separator inside a preprocessing-number, not a |
| # character-literal delimiter (for example 1'000 or 0xFF'00). |
| out.append(c) |
| had_code = True |
| i += 1 |
| continue |
| if c in "\"'": |
| out.append(c + c) |
| had_code = True |
| i, closed, spliced = scan_quoted(line, i + 1, c) |
| if not closed and spliced: |
| open_quote = c |
| continue |
| out.append(c) |
| if not c.isspace(): |
| had_code = True |
| i += 1 |
| stripped = line.strip() |
| is_comment = (not had_code) and had_comment and not literal_at_start |
| code_lines.append("".join(out)) |
| comment_only.append(stripped if is_comment else None) |
| doxygen_only.append(line_doxygen if is_comment else None) |
| return code_lines, comment_only, doxygen_only |
| |
| |
| def check_comments(comment_only, doxygen_only, added, findings): |
| """Flag comment blocks that gain six or more added narration lines. |
| |
| Blocks are formed over the full file, so an addition inside an existing |
| Doxygen, license or bibliography block inherits that block's exemption, and |
| only the ADDED lines count toward the threshold — extending a pre-existing |
| block by a line or two is not reported. |
| """ |
| def finish_block(block_start, block_end, is_doxygen): |
| block = comment_only[block_start:block_end] |
| added_in_block = [line_no for line_no in range(block_start + 1, block_end + 1) if line_no in added] |
| text = "\n".join(block) |
| exempt = is_doxygen or LICENSE.search(text) or REFERENCES.search(text) |
| if added_in_block and len(added_in_block) >= 6 and not exempt: |
| findings.append((added_in_block[0], "%d added lines of non-Doxygen comment: AGENTS.md keeps only " |
| "mathematics, invariants, compatibility constraints, provenance, " |
| "or the reason a deliberate construct must not be simplified" |
| % len(added_in_block))) |
| |
| block_start, block_doxygen = None, None |
| for idx in range(len(comment_only) + 1): |
| comment = comment_only[idx] if idx < len(comment_only) else None |
| if comment is not None: |
| if block_start is None: |
| block_start = idx |
| block_doxygen = doxygen_only[idx] |
| elif doxygen_only[idx] != block_doxygen: |
| finish_block(block_start, idx, block_doxygen) |
| block_start = idx |
| block_doxygen = doxygen_only[idx] |
| elif block_start is not None: |
| finish_block(block_start, idx, block_doxygen) |
| block_start = None |
| |
| |
| def check_designated_initializer(code_lines, line_no, findings): |
| code = code_lines[line_no - 1] |
| hit = re.search(r"[{,]\s*\.\w+\s*=", code) |
| if not hit and re.match(r"\s*\.\w+\s*=", code): |
| # A line-leading designator continues a braced initializer only when |
| # the previous code line ends with `{` or `,`; anything else is a |
| # wrapped member access or assignment. |
| for prev in range(line_no - 2, -1, -1): |
| prev_code = code_lines[prev].rstrip() |
| if prev_code: |
| hit = prev_code.endswith("{") or prev_code.endswith(",") |
| break |
| if hit: |
| findings.append((line_no, DESIGNATED_MSG)) |
| return True |
| return False |
| |
| |
| def check_conventions(rel_path, code_lines, added, findings): |
| checks = list(CODE_CHECKS) |
| cxx17_path = ( |
| rel_path in CXX17_FILES |
| or rel_path.startswith(CXX17_PREFIXES) |
| or (rel_path.startswith("unsupported/Eigen/src/Tensor/") and rel_path.endswith("Sycl.h")) |
| or (rel_path.startswith("unsupported/test/") and rel_path.endswith("_sycl.cpp")) |
| ) |
| if rel_path.startswith(CXX14_TREES) and not cxx17_path: |
| checks += CXX14_CHECKS |
| if rel_path.startswith(LIBRARY_SRC_TREES): |
| checks.append((r"(?:\(\s*)?\bstd::(?:%s)\s*(?:\)\s*)?\(" % STD_MATH, |
| "std:: math call in a library header: use the numext:: equivalent " |
| "(device- and custom-scalar-aware)")) |
| checks.append((r"(?:\(\s*)?\bstd::frexp\s*(?:\)\s*)?\(", |
| "std::frexp call in a library header: use EIGEN_USING_STD(frexp) and an unqualified call " |
| "for ADL and device/custom-scalar support")) |
| added_sorted = sorted(added) |
| for pattern, message in checks: |
| rx = re.compile(pattern) |
| for line_no in added_sorted: |
| if rx.search(code_lines[line_no - 1]): |
| findings.append((line_no, message)) |
| break # one report per pattern per file |
| for line_no in added_sorted: |
| if check_designated_initializer(code_lines, line_no, findings): |
| break |
| |
| |
| def find_findings(rel_path, lines, added): |
| """Check one file: ``lines`` is the complete post-image, ``added`` the set |
| of 1-based line numbers the change added. Returns [(line_no, message)].""" |
| if not is_cxx_path(rel_path): |
| return [] |
| added = {n for n in added if 1 <= n <= len(lines) and lines[n - 1].strip()} |
| if not added: |
| return [] |
| code_lines, comment_only, doxygen_only = lex_lines(lines) |
| findings = [] |
| check_comments(comment_only, doxygen_only, added, findings) |
| check_conventions(rel_path, code_lines, added, findings) |
| findings.sort() |
| return findings |
| |
| |
| def run_diff_mode(base, root=REPO_ROOT): |
| """Return [(rel_path, line_no, message)] for lines added since |
| merge-base(base, HEAD), including untracked C++ files.""" |
| per_file = diff_added_lines(base, root) |
| results = [] |
| for rel_path in sorted(per_file): |
| text = read_post_image(root, rel_path) |
| if text is None: # deleted or unreadable |
| continue |
| for line_no, message in find_findings(rel_path, text.splitlines(), per_file[rel_path]): |
| results.append((rel_path, line_no, message)) |
| return results |
| |
| |
| def run_hook_mode(): |
| try: |
| payload = json.load(sys.stdin) |
| except Exception: |
| return 0 |
| rel_path, snippets = hook_edit_snippets(payload) |
| if rel_path is None: |
| return 0 |
| # The hook runs after the edit, so the file on disk is the post-image; it is |
| # lexed whole so enclosing comments and initializers classify correctly. |
| lines, added, _ = hook_post_image_and_added(payload, rel_path, snippets) |
| findings = find_findings(rel_path, lines, added) |
| if findings: |
| sys.stderr.write("style check (%s) — review before proceeding:\n" % rel_path) |
| for _, message in findings[:8]: |
| sys.stderr.write(" - %s\n" % message) |
| sys.stderr.write("Advisory: keep a flagged construct only if it is justified, and say why at the " |
| "construct.\n") |
| return 2 |
| return 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="check lines added relative to merge-base(BASE, HEAD)") |
| mode.add_argument("--claude-hook", action="store_true", help="run as a Claude Code PostToolUse hook") |
| args = parser.parse_args() |
| if args.claude_hook: |
| try: |
| return run_hook_mode() |
| except Exception: |
| return 0 # a broken hook must not block the harness |
| results = run_diff_mode(args.diff) |
| for rel_path, line_no, message in results: |
| print("%s:%d: %s" % (rel_path, line_no, message)) |
| if results: |
| print("\n%d advisory finding(s) in added lines. Keep a flagged construct only if it is justified, " |
| "and say why at the construct." % len(results)) |
| return 1 if results else 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |