#!/bin/bash # scripts/doxygen-coverage.sh # # Measure Doxygen documentation coverage of the public C++ API by parsing # the Doxygen XML output. Reports: # * total documentable members (functions, classes, structs, enums, # typedefs, variables) in code/include/** # * how many have a non-empty briefdescription/detaileddescription # * coverage % and list of undocumented members # # Prerequisite: doxygen must have been run with GENERATE_XML=YES (which # the project's Doxyfile sets). This script invokes it if XML is missing. # # Usage: # bash scripts/doxygen-coverage.sh # short summary # bash scripts/doxygen-coverage.sh --list-undoc # list undocumented members # bash scripts/doxygen-coverage.sh --threshold 95 # fail if coverage < 95 % # # Exit codes: # 0 coverage ≥ threshold (default 0 — informational only) # 1 coverage < threshold # 2 XML output missing / could not be parsed set -eu XML_DIR="doc/doxygen/xml" THRESHOLD=0 LIST_UNDOC=0 INCLUDE_DETAIL=0 while [ $# -gt 0 ]; do case "$1" in --threshold) THRESHOLD="$2"; shift 2 ;; --list-undoc) LIST_UNDOC=1; shift ;; --include-detail) INCLUDE_DETAIL=1; shift ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac done if [ ! -d "$XML_DIR" ]; then echo "XML output missing — running doxygen..." doxygen Doxyfile >/dev/null 2>&1 fi if [ ! -d "$XML_DIR" ]; then echo "ERROR: $XML_DIR still missing after doxygen run" >&2 exit 2 fi python3 - "$XML_DIR" "$LIST_UNDOC" "$THRESHOLD" "$INCLUDE_DETAIL" <<'PYEOF' import sys, os, glob, xml.etree.ElementTree as ET xml_dir, list_undoc, threshold, include_detail = \ sys.argv[1], int(sys.argv[2]), float(sys.argv[3]), int(sys.argv[4]) # Implementation-detail namespaces — not part of the public API surface. # Skipped by default; pass --include-detail to count them too. DETAIL_NAMES = ("::detail::", "::detail_xml::", "::cp_detail::", "::id_detail::", "::detail$", "::detail_xml$", "::cp_detail$", "::id_detail$") def is_detail(qualified_name: str) -> bool: if include_detail: return False return any(qualified_name.find(d.rstrip("$")) >= 0 for d in DETAIL_NAMES) # Restrict to compounds whose location is under code/include/ (the # public API). XML output also includes README.md and CLAUDE.md as # "file" kind compounds, which we want to skip. PUBLIC_PREFIX = os.path.abspath("code/include") + os.sep KINDS = {"function", "class", "struct", "enum", "typedef", "variable", "namespace"} total = 0 documented = 0 undoc = [] for path in sorted(glob.glob(os.path.join(xml_dir, "*.xml"))): if os.path.basename(path) in {"index.xml", "Doxyfile.xml", "indexpage.xml"}: continue if os.path.basename(path).startswith(("namespacestd", "md_")): continue try: tree = ET.parse(path) except ET.ParseError: continue for cd in tree.iter("compounddef"): kind = cd.attrib.get("kind", "") # only count compounds living in our public include tree loc = cd.find("location") if loc is None: continue file_attr = loc.attrib.get("file", "") if not file_attr.startswith(PUBLIC_PREFIX) and \ not file_attr.startswith("code/include/"): continue # The compound itself if kind in {"class", "struct", "namespace"}: cname = cd.findtext("compoundname", "?") if not is_detail(cname): total += 1 brief = cd.find("briefdescription") detail = cd.find("detaileddescription") has_doc = (brief is not None and len("".join(brief.itertext()).strip()) > 0) or \ (detail is not None and len("".join(detail.itertext()).strip()) > 0) if has_doc: documented += 1 else: undoc.append(f"{kind:9s} {cname} ({file_attr}:{loc.attrib.get('line','?')})") # Members inside the compound for memberdef in cd.iter("memberdef"): mkind = memberdef.attrib.get("kind", "") if mkind not in KINDS: continue prot = memberdef.attrib.get("prot", "public") if prot != "public": continue name = memberdef.findtext("name", "?") qual = memberdef.findtext("qualifiedname", name) if is_detail(qual): continue total += 1 brief = memberdef.find("briefdescription") detail = memberdef.find("detaileddescription") has_doc = (brief is not None and len("".join(brief.itertext()).strip()) > 0) or \ (detail is not None and len("".join(detail.itertext()).strip()) > 0) if has_doc: documented += 1 else: mloc = memberdef.find("location") fl = mloc.attrib.get("file", "?") if mloc is not None else "?" ln = mloc.attrib.get("line", "?") if mloc is not None else "?" undoc.append(f"{mkind:9s} {qual} ({fl}:{ln})") if total == 0: print("ERROR: no public members found — check that GENERATE_XML=YES and EXTRACT_ALL=YES") sys.exit(2) pct = 100.0 * documented / total print(f"Doxygen coverage (public symbols under code/include/):") print(f" documented: {documented}") print(f" total: {total}") print(f" coverage: {pct:.1f}%") print(f" undocumented: {total - documented}") if list_undoc: print() print("Undocumented symbols:") for s in undoc: print(f" {s}") if pct < threshold: print(f"\nFAIL: coverage {pct:.1f}% < threshold {threshold}%", file=sys.stderr) sys.exit(1) sys.exit(0) PYEOF