File indexing completed on 2026-08-16 08:16:23
0001
0002 """Measure the ACTS public API surface from Doxygen XML.
0003
0004 The "public API surface" is the set of *documented*, non-``detail`` /
0005 non-``Experimental`` entities in the ``Acts`` namespace, matching the policy in
0006 docs/pages/versioning.md. Doxygen parses syntactically, so this needs no
0007 dependency headers or compile flags -- just the ``doxygen`` binary.
0008
0009 Typical use (runs Doxygen itself, then reports):
0010
0011 CI/public_api/public_api_surface.py --run --json surface.json --markdown surface.md
0012
0013 Or parse an existing XML tree:
0014
0015 CI/public_api/public_api_surface.py --xml <dir>/xml --markdown -
0016
0017 Writes a Markdown summary to ``$GITHUB_STEP_SUMMARY`` when that env var is set.
0018 """
0019
0020 from __future__ import annotations
0021
0022 import argparse
0023 import glob
0024 import os
0025 import subprocess
0026 import sys
0027 import tempfile
0028 import unicodedata
0029 import xml.etree.ElementTree as ET
0030 from collections import Counter, defaultdict
0031 from pathlib import Path
0032
0033
0034 DOXYFILE = str(Path(__file__).resolve().parent / "Doxyfile")
0035
0036
0037
0038
0039 EXCLUDED_PLUGINS: set[str] = set()
0040
0041
0042 def standard_roots(under: Path) -> list[Path]:
0043 """Header roots whose public API we track, resolved under `under`.
0044
0045 Core, Fatras and Alignment, plus every plugin under Plugins/. The Examples
0046 tree and the top-level Detray/Traccc integration folders are not included.
0047 """
0048 roots: list[Path] = []
0049 for rel in ("Core/include", "Fatras/include", "Alignment/include"):
0050 p = under / rel
0051 if p.is_dir():
0052 roots.append(p)
0053 plugins = under / "Plugins"
0054 if plugins.is_dir():
0055 for pdir in sorted(plugins.iterdir()):
0056 if (
0057 pdir.is_dir()
0058 and pdir.name not in EXCLUDED_PLUGINS
0059 and (pdir / "include").is_dir()
0060 ):
0061 roots.append(pdir / "include")
0062 return roots
0063
0064
0065
0066 NS_MEMBER_BUCKET = {
0067 "function": "free_functions",
0068 "typedef": "aliases",
0069 "variable": "variables",
0070 "enum": "enums",
0071 "concept": "concepts",
0072 }
0073
0074
0075 def is_internal(name: str) -> bool:
0076 return bool(name) and ("detail" in name or "Experimental" in name)
0077
0078
0079 def module_of(location: str | None) -> str:
0080 """Component (and Core sub-module) from a header location path.
0081
0082 e.g. '.../Core/include/Acts/Surfaces/X.hpp' -> 'Core/Surfaces',
0083 '.../Plugins/Json/include/ActsPlugins/Json/Y.hpp' -> 'Plugin:Json',
0084 '.../Fatras/include/ActsFatras/Z.hpp' -> 'Fatras'.
0085 """
0086 if not location:
0087 return "?"
0088 parts = Path(location).as_posix().split("/")
0089 if "Plugins" in parts:
0090 i = parts.index("Plugins")
0091 if i + 1 < len(parts):
0092 return "Plugin:" + parts[i + 1]
0093 if "Fatras" in parts:
0094 return "Fatras"
0095 if "Alignment" in parts:
0096 return "Alignment"
0097 if "Acts" in parts:
0098 i = parts.index("Acts")
0099 return f"Core/{parts[i + 1]}" if i + 1 < len(parts) - 1 else "Core"
0100 return "?"
0101
0102
0103 def run_doxygen(repo: Path, out: Path, input_dirs: list[Path]) -> None:
0104 out.mkdir(parents=True, exist_ok=True)
0105
0106 doxy_input = " ".join(f'"{d}"' for d in input_dirs)
0107 env = dict(os.environ, DOXY_OUT=str(out), DOXY_INPUT=doxy_input)
0108
0109
0110
0111
0112 proc = subprocess.run(
0113 ["doxygen", DOXYFILE], cwd=repo, env=env, capture_output=True, text=True
0114 )
0115 if proc.returncode != 0:
0116 sys.stderr.write(proc.stdout)
0117 sys.stderr.write(proc.stderr)
0118 raise subprocess.CalledProcessError(proc.returncode, "doxygen")
0119 n = proc.stderr.count("warning:") + proc.stderr.count("error:")
0120 if n:
0121 print(f"(doxygen: {n} non-fatal diagnostics suppressed)", file=sys.stderr)
0122
0123
0124
0125
0126
0127
0128
0129
0130 def _sanitize(text: str) -> str:
0131 return "".join(c for c in text if unicodedata.category(c) not in ("Cf", "Cc"))
0132
0133
0134 def _name(el, tag: str) -> str:
0135 return _sanitize(el.findtext(tag) or "")
0136
0137
0138 def _norm_type(el) -> str:
0139 """Flatten a Doxygen <type>/<param><type> element to normalized text."""
0140 if el is None:
0141 return ""
0142 return _sanitize(" ".join("".join(el.itertext()).split()))
0143
0144
0145 def callable_forms(md, qualname: str) -> dict[str, str]:
0146 """Expand a function memberdef into its source-callable signatures.
0147
0148 A parameter with a default value makes shorter calls valid too, so
0149 ``f(A, B = d)`` yields both ``f(A)`` and ``f(A, B)``. Comparing these
0150 expanded forms across two revisions makes *adding a defaulted argument*
0151 non-breaking while *adding a non-defaulted argument* (or removing/retyping
0152 one) shows up as a removed form. Maps form-key -> return type.
0153 """
0154 params = md.findall("param")
0155 types = [_norm_type(p.find("type")) for p in params]
0156 has_default = [p.find("defval") is not None for p in params]
0157 required = len(types)
0158 for i, d in enumerate(has_default):
0159 if d:
0160 required = i
0161 break
0162 ret = _norm_type(md.find("type"))
0163 const = " const" if md.get("const") == "yes" else ""
0164 forms = {}
0165 for m in range(required, len(types) + 1):
0166 forms[f"{qualname}({', '.join(types[:m])}){const}"] = ret
0167 return forms
0168
0169
0170 def parse_xml(xml_dir: Path) -> dict:
0171 counts: Counter[str] = Counter()
0172 per_module: dict[str, Counter] = defaultdict(Counter)
0173 type_names: set[str] = set()
0174 ns_member_names: set[str] = set()
0175 symbols: set[str] = set()
0176 callables: dict[str, str] = {}
0177 fields: dict[str, str] = {}
0178 methods = 0
0179
0180 for f in glob.glob(str(xml_dir / "*.xml")):
0181 base = os.path.basename(f)
0182 if base in ("index.xml", "Doxyfile.xml"):
0183 continue
0184 try:
0185 root = ET.parse(f).getroot()
0186 except ET.ParseError:
0187 continue
0188 for cd in root.findall("compounddef"):
0189 kind = cd.get("kind")
0190 name = _name(cd, "compoundname")
0191 if not name.startswith("Acts") or is_internal(name):
0192 continue
0193 loc = cd.find("location")
0194 locfile = loc.get("file") if loc is not None else None
0195
0196 if kind in ("class", "struct", "union"):
0197 bare = name.split("<")[0]
0198 if bare not in type_names:
0199 type_names.add(bare)
0200 counts["types"] += 1
0201 per_module[module_of(locfile)]["types"] += 1
0202 symbols.add(f"type {bare}")
0203
0204 for md in cd.iter("memberdef"):
0205 if md.get("prot") != "public":
0206 continue
0207 if md.get("kind") == "function":
0208 methods += 1
0209 callables.update(
0210 callable_forms(md, f"{bare}::{_name(md, 'name')}")
0211 )
0212 elif md.get("kind") == "variable":
0213 mname = _name(md, "name")
0214 fields[f"{bare}::{mname}"] = _norm_type(md.find("type"))
0215
0216 elif kind == "concept":
0217
0218 if name not in ns_member_names:
0219 ns_member_names.add(name)
0220 counts["concepts"] += 1
0221 per_module[module_of(locfile)]["concepts"] += 1
0222 symbols.add(f"concept {name}")
0223
0224 elif kind == "namespace":
0225 for md in cd.findall("sectiondef/memberdef"):
0226 mk = md.get("kind")
0227 bucket = NS_MEMBER_BUCKET.get(mk)
0228 if not bucket:
0229 continue
0230 mname = _name(md, "name")
0231 full = f"{name}::{mname}"
0232 if full in ns_member_names:
0233 continue
0234 ns_member_names.add(full)
0235 counts[bucket] += 1
0236 symbols.add(f"{bucket} {full}")
0237 if mk == "function":
0238 callables.update(callable_forms(md, full))
0239 mloc = md.find("location")
0240 per_module[
0241 module_of(mloc.get("file") if mloc is not None else None)
0242 ][bucket] += 1
0243
0244 total = sum(counts.values())
0245 return {
0246 "counts": dict(counts),
0247 "total": total,
0248 "public_methods": methods,
0249 "public_fields": len(fields),
0250 "per_module": {m: dict(c) for m, c in per_module.items()},
0251 "symbols": sorted(symbols),
0252 "callables": callables,
0253 "fields": fields,
0254 }
0255
0256
0257 BUCKET_ORDER = ["types", "free_functions", "aliases", "variables", "enums", "concepts"]
0258
0259
0260 def render_markdown(data: dict, doxy_version: str | None) -> str:
0261 c = data["counts"]
0262 lines = ["## ACTS public API surface", ""]
0263 lines.append(
0264 f"**{data['total']}** documented public names in `Acts*::` "
0265 f"(excluding `detail` / `Experimental`), plus "
0266 f"**{data['public_methods']}** public methods and "
0267 f"**{data.get('public_fields', 0)}** public data members on "
0268 f"documented types."
0269 )
0270 scope = data.get("scope")
0271 if scope:
0272 lines.append(
0273 f"\n_Scope: {scope}"
0274 + (f" via Doxygen {doxy_version}._" if doxy_version else "._")
0275 )
0276 elif doxy_version:
0277 lines.append(f"\n_Doxygen {doxy_version}._")
0278 lines += ["", "| category | count |", "|---|---:|"]
0279 for k in BUCKET_ORDER:
0280 if k in c:
0281 lines.append(f"| {k.replace('_', ' ')} | {c[k]} |")
0282 lines.append(f"| **total** | **{data['total']}** |")
0283
0284 lines += [
0285 "",
0286 "<details><summary>By module</summary>",
0287 "",
0288 "| module | total | " + " | ".join(BUCKET_ORDER) + " |",
0289 "|---|---:|" + "|".join(["---:"] * len(BUCKET_ORDER)) + "|",
0290 ]
0291 mods = sorted(data["per_module"].items(), key=lambda kv: -sum(kv[1].values()))
0292 for m, d in mods:
0293 row = [m, str(sum(d.values()))] + [str(d.get(k, 0)) for k in BUCKET_ORDER]
0294 lines.append("| " + " | ".join(row) + " |")
0295 lines += ["", "</details>"]
0296 return "\n".join(lines) + "\n"
0297
0298
0299 def main() -> int:
0300 ap = argparse.ArgumentParser(
0301 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
0302 )
0303 ap.add_argument("--repo", default=".", help="repository root (has the Doxyfile)")
0304 ap.add_argument("--run", action="store_true", help="run doxygen (else use --xml)")
0305 ap.add_argument(
0306 "--input", nargs="+", metavar="DIR", help="explicit header root(s) to measure"
0307 )
0308 ap.add_argument(
0309 "--roots-under",
0310 metavar="DIR",
0311 help="measure the standard component set (Core, Fatras, Alignment, "
0312 "and all plugins) resolved under DIR; use for another checkout",
0313 )
0314 ap.add_argument("--xml", help="existing Doxygen XML dir to parse")
0315 ap.add_argument("--json", help="write report JSON here")
0316 ap.add_argument("--markdown", help="write Markdown here ('-' for stdout)")
0317 ap.add_argument(
0318 "--summary",
0319 action="store_true",
0320 help="also append the Markdown to $GITHUB_STEP_SUMMARY",
0321 )
0322 args = ap.parse_args()
0323
0324 repo = Path(args.repo).resolve()
0325
0326 doxy_version = None
0327 try:
0328 doxy_version = subprocess.run(
0329 ["doxygen", "--version"], capture_output=True, text=True
0330 ).stdout.split()[0]
0331 except (FileNotFoundError, IndexError):
0332 pass
0333
0334 scope = None
0335 tmp = None
0336 if args.run:
0337 if args.input:
0338 input_dirs = [Path(p).resolve() for p in args.input]
0339 else:
0340 input_dirs = standard_roots(
0341 Path(args.roots_under).resolve() if args.roots_under else repo
0342 )
0343 if not input_dirs:
0344 print("error: no header roots found to measure", file=sys.stderr)
0345 return 2
0346
0347 def component(d: Path) -> str:
0348 parts = d.parts
0349 if "Plugins" in parts:
0350 return "Plugin:" + parts[parts.index("Plugins") + 1]
0351 for comp in ("Core", "Fatras", "Alignment"):
0352 if comp in parts:
0353 return comp
0354 return d.name
0355
0356 scope = ", ".join(dict.fromkeys(component(d) for d in input_dirs))
0357 tmp = Path(tempfile.mkdtemp(prefix="acts-api-surface-"))
0358 run_doxygen(repo, tmp, input_dirs)
0359 xml_dir = tmp / "xml"
0360 elif args.xml:
0361 xml_dir = Path(args.xml)
0362 else:
0363 print("error: pass --run or --xml", file=sys.stderr)
0364 return 2
0365
0366 if not xml_dir.is_dir():
0367 print(f"error: no XML at {xml_dir}", file=sys.stderr)
0368 return 2
0369
0370 data = parse_xml(xml_dir)
0371 data["doxygen_version"] = doxy_version
0372 data["scope"] = scope
0373
0374 md = render_markdown(data, doxy_version)
0375
0376 if args.json:
0377 import json
0378
0379 Path(args.json).write_text(json.dumps(data, indent=2) + "\n")
0380 if args.markdown == "-" or args.markdown is None:
0381 print(md)
0382 elif args.markdown:
0383 Path(args.markdown).write_text(md)
0384
0385 summary = os.environ.get("GITHUB_STEP_SUMMARY")
0386 if args.summary and summary:
0387 with open(summary, "a") as fh:
0388 fh.write(md)
0389
0390 return 0
0391
0392
0393 if __name__ == "__main__":
0394 sys.exit(main())