File indexing completed on 2026-08-16 08:16:23
0001
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
0041 DEPRECATED_CMD_RE = re.compile(r"[@\\]deprecated\b")
0042
0043
0044 COMMENT_LINE_RE = re.compile(r"^\s*(///|//!|/\*|\*)")
0045
0046
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
0073
0074 while i >= 0:
0075 stripped = lines[i].strip()
0076 if COMMENT_LINE_RE.match(lines[i]):
0077 break
0078 if stripped == "":
0079 return None
0080 if ACCESS_SPEC_RE.match(lines[i]):
0081 return None
0082 if stripped.endswith((";", "{", "}", "};")):
0083 return None
0084 if DECL_PREFIX_RE.match(lines[i]):
0085 i -= 1
0086 continue
0087
0088
0089 i -= 1
0090 if i < 0:
0091 return None
0092
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
0134
0135
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())