Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-16 08:16:23

0001 #!/usr/bin/env python3
0002 """Require every ``[[deprecated]]`` to carry a Doxygen ``@deprecated`` command.
0003 
0004 Part of the API-surface effort. A C++ ``[[deprecated]]`` attribute only warns at
0005 *compile* time; it never reaches the rendered documentation. Doxygen has a
0006 separate ``@deprecated`` (a.k.a. ``\\deprecated``) command that collects the
0007 entity into the generated "Deprecated List" and shows a note on its page. The
0008 two are independent, so a symbol can be deprecated in code yet look perfectly
0009 current in the docs -- exactly the surface our docs CI publishes.
0010 
0011 This check pairs them mechanically: for every declaration carrying a
0012 ``[[deprecated]]`` attribute in a scanned header, the Doxygen comment block that
0013 documents it must contain an ``@deprecated`` / ``\\deprecated`` command. It is a
0014 deliberately dependency-free, regex/heuristic tool (no libclang, no compiler) --
0015 it reads the same comment text Doxygen does, so "the tag Doxygen would render"
0016 and "the tag this check sees" are the same string.
0017 
0018 Association heuristic (no AST): from the attribute, walk upward past any
0019 declaration-prefix lines that belong to the same statement (e.g. a
0020 ``template<...>`` line or a leading return type), stopping at a statement
0021 boundary (blank line, ``;``, ``{``/``}``, an access specifier). The contiguous
0022 run of comment lines found there is the entity's doc block. A violation is a
0023 ``[[deprecated]]`` whose doc block is missing or lacks an ``@deprecated``
0024 command (including the case of no doc block at all).
0025 
0026     CI/public_api/check_deprecated_docs.py Core/include Plugins Fatras/include Alignment/include
0027     CI/public_api/check_deprecated_docs.py --write-baseline CI/public_api/deprecated_docs_baseline.txt <roots...>
0028     CI/public_api/check_deprecated_docs.py --baseline CI/public_api/deprecated_docs_baseline.txt <roots...>
0029 """
0030 
0031 from __future__ import annotations
0032 
0033 import argparse
0034 import os
0035 import re
0036 import sys
0037 from pathlib import Path
0038 
0039 DEPRECATED_ATTR_RE = re.compile(r"\[\[\s*(?:gnu\s*::\s*)?deprecated\b")
0040 # Doxygen command, either @deprecated or \deprecated.
0041 DEPRECATED_CMD_RE = re.compile(r"[@\\]deprecated\b")
0042 # A line that is part of a documentation comment (/// , //! , or a /* ... */
0043 # block body/opener). Continuation lines of a block comment start with ``*``.
0044 COMMENT_LINE_RE = re.compile(r"^\s*(///|//!|/\*|\*)")
0045 # A non-comment line that still belongs to the *same* declaration and may sit
0046 # between the doc block and the attribute (template heads, attribute specifiers).
0047 DECL_PREFIX_RE = re.compile(r"^\s*(template\b|requires\b|\[\[|explicit\b|virtual\b)")
0048 ACCESS_SPEC_RE = re.compile(r"^\s*(public|private|protected)\s*:")
0049 
0050 HEADER_SUFFIXES = {".hpp", ".ipp", ".cuh"}
0051 
0052 
0053 github = "GITHUB_ACTIONS" in os.environ
0054 
0055 
0056 def iter_header_files(root: Path):
0057     for dirpath, _dirs, files in os.walk(root):
0058         for name in files:
0059             fp = Path(dirpath) / name
0060             if fp.suffix in HEADER_SUFFIXES:
0061                 yield fp
0062 
0063 
0064 def doc_block_for(lines: list[str], attr_idx: int) -> list[str] | None:
0065     """Return the doc-comment lines documenting the declaration at ``attr_idx``.
0066 
0067     Walks upward from the attribute line, skipping declaration-prefix lines of
0068     the same statement, until it reaches the comment block or a boundary. Returns
0069     the comment lines (top-to-bottom) or ``None`` if there is no doc block.
0070     """
0071     i = attr_idx - 1
0072     # Skip decl-prefix lines that precede the attribute within the same statement
0073     # (e.g. a template head). Stop at any statement boundary.
0074     while i >= 0:
0075         stripped = lines[i].strip()
0076         if COMMENT_LINE_RE.match(lines[i]):
0077             break  # reached the doc block
0078         if stripped == "":
0079             return None  # blank line -> no doc block directly above
0080         if ACCESS_SPEC_RE.match(lines[i]):
0081             return None
0082         if stripped.endswith((";", "{", "}", "};")):
0083             return None  # previous statement/scope -> our decl is undocumented
0084         if DECL_PREFIX_RE.match(lines[i]):
0085             i -= 1
0086             continue
0087         # Some other declaration text above the attribute on its own line
0088         # (rare); treat as part of this declaration and keep looking up.
0089         i -= 1
0090     if i < 0:
0091         return None
0092     # Collect the contiguous comment block upward.
0093     end = i
0094     while i >= 0 and COMMENT_LINE_RE.match(lines[i]):
0095         i -= 1
0096     return lines[i + 1 : end + 1]
0097 
0098 
0099 def find_violations(repo: Path, roots: list[str]) -> list[dict]:
0100     violations: list[dict] = []
0101     for root in roots:
0102         root_path = repo / root
0103         if not root_path.exists():
0104             continue
0105         for fp in iter_header_files(root_path):
0106             rel = fp.relative_to(repo)
0107             try:
0108                 lines = fp.read_text(errors="ignore").splitlines()
0109             except OSError:
0110                 continue
0111             for idx, line in enumerate(lines):
0112                 if not DEPRECATED_ATTR_RE.search(line):
0113                     continue
0114                 block = doc_block_for(lines, idx)
0115                 has_doc = block is not None and len(block) > 0
0116                 has_tag = has_doc and any(DEPRECATED_CMD_RE.search(bl) for bl in block)
0117                 if has_tag:
0118                     continue
0119                 violations.append(
0120                     {
0121                         "file": rel.as_posix(),
0122                         "line": idx + 1,
0123                         "reason": (
0124                             "no-doc-block" if not has_doc else "no-deprecated-tag"
0125                         ),
0126                     }
0127                 )
0128     violations.sort(key=lambda v: (v["file"], v["line"]))
0129     return violations
0130 
0131 
0132 def key(v: dict) -> str:
0133     # File-scoped, line-independent so unrelated edits above a site do not churn
0134     # the baseline. A file may legitimately have several deprecated members, so
0135     # include the line to keep them distinct within a file.
0136     return f"{v['file']}:{v['line']}"
0137 
0138 
0139 def main() -> int:
0140     p = argparse.ArgumentParser(
0141         description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
0142     )
0143     p.add_argument("roots", nargs="+", help="directories to scan for headers")
0144     p.add_argument("--repo", default=".", help="repository root")
0145     p.add_argument(
0146         "--baseline", help="ratchet file: only fail on violations not listed here"
0147     )
0148     p.add_argument(
0149         "--write-baseline",
0150         help="write current violation keys to this file and exit 0",
0151     )
0152     args = p.parse_args()
0153 
0154     repo = Path(args.repo).resolve()
0155     violations = find_violations(repo, args.roots)
0156 
0157     if args.write_baseline:
0158         Path(args.write_baseline).write_text(
0159             "\n".join(sorted(key(v) for v in violations)) + "\n"
0160         )
0161         print(f"wrote {len(violations)} baseline entries to {args.write_baseline}")
0162         return 0
0163 
0164     baseline: set[str] = set()
0165     if args.baseline and Path(args.baseline).exists():
0166         baseline = {
0167             l.strip() for l in Path(args.baseline).read_text().splitlines() if l.strip()
0168         }
0169 
0170     new = [v for v in violations if key(v) not in baseline]
0171 
0172     def emit(v: dict) -> None:
0173         loc = f"{v['file']}:{v['line']}"
0174         detail = (
0175             "has no documentation comment"
0176             if v["reason"] == "no-doc-block"
0177             else "has a doc comment but no @deprecated command"
0178         )
0179         msg = f"[[deprecated]] at {loc} {detail}"
0180         if github:
0181             print(f"::error file={v['file']},line={v['line']}::{msg}")
0182         else:
0183             print(msg)
0184 
0185     print(f"== [[deprecated]] without @deprecated doc: {len(violations)} total ==\n")
0186     for v in violations:
0187         emit(v)
0188 
0189     if args.baseline:
0190         print(f"\n{len(new)} not in baseline.")
0191         return 1 if new else 0
0192 
0193     return 1 if violations else 0
0194 
0195 
0196 if __name__ == "__main__":
0197     sys.exit(main())