blob: 0ab9f6adc361757b860be3ab39dc2a7c790a4083 [file]
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors
# SPDX-License-Identifier: Apache-2.0
"""
Doxygen Top-level Group Check
A Doxygen group that no other group pulls in with @ingroup or @addtogroup ends up at the root of
the API documentation index. Those top-level categories are meant to stay few and stable, so this
script compares them against an allowlist and fails on any addition, or on an allowlisted group
that is no longer top level.
The set is read from Doxygen's XML, not from the sources: a group's parent may be declared in
another file, or via @addtogroup nested inside a third group, and Doxygen merges all such
declarations. The rule below is the one doc/_extensions/zephyr/api_overview.py uses to render the
index -- a group is top-level if no other group lists it as an <innergroup>.
"""
import argparse
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import NamedTuple
ZEPHYR_BASE = Path(__file__).resolve().parents[2]
ALLOWLIST = ZEPHYR_BASE / "doc" / "_doxygen" / "toplevel_groups.txt"
class Problem(NamedTuple):
"""A finding to report, optionally anchored to a line of source.
`summary` names the kind of problem, `message` the group it applies to, and `remedy` how to
resolve it. Both surfaces announce the kind already -- an annotation through its title, the job
summary through a heading -- so `message` need not restate it, and the summary can group
findings under that heading and state `remedy` once instead of on every line.
An annotation always covers a single group, so `summary` is singular; the summary heading spans
every finding of a kind and so needs `summary_plural`, which cannot be derived by suffixing an
"s" ("entry" -> "entries").
`message` and `remedy` are markdown: names and Doxygen commands are wrapped in backticks so
that the summary renders them as code rather than, say, turning @ingroup into a user mention.
"""
summary: str
summary_plural: str
message: str
remedy: str
file: str | None = None
line: int | None = None
def quantify(count: int, singular: str, plural: str) -> str:
"""Render `count` with its noun agreeing in number."""
return f"{count} {singular if count == 1 else plural}"
def repo_relative(path: str) -> str | None:
"""Map a path recorded by Doxygen onto one relative to ZEPHYR_BASE.
Doxygen writes absolute paths from the machine it ran on, which need not share a prefix with
this checkout, so fall back to the longest trailing portion naming an existing file. Drop the
anchor first: joining an absolute path onto ZEPHYR_BASE discards ZEPHYR_BASE, which would let
a file outside the repository match.
"""
candidate = Path(path)
try:
return str(candidate.relative_to(ZEPHYR_BASE))
except ValueError:
pass
parts = candidate.parts[1:] if candidate.is_absolute() else candidate.parts
for i in range(len(parts)):
suffix = Path(*parts[i:])
if (ZEPHYR_BASE / suffix).exists():
return str(suffix)
return None
def declaration_of(name: str, source: str | None) -> tuple[str | None, int | None]:
"""Locate the @defgroup declaring `name`, as a (path relative to ZEPHYR_BASE, line) pair."""
cmd = ["git", "grep", "-n", "-E", rf"@defgroup[[:space:]]+{re.escape(name)}([[:space:]]|$)"]
if source:
cmd += ["--", source]
try:
out = subprocess.run(
cmd, cwd=ZEPHYR_BASE, capture_output=True, text=True, check=False
).stdout
except OSError:
return None, None
for line in out.splitlines():
path, _, rest = line.partition(":")
lineno, _, _ = rest.partition(":")
if lineno.isdigit():
return path, int(lineno)
return None, None
def group_source(compounddef: ET.Element) -> str | None:
"""Path of the file Doxygen read a group from, relative to ZEPHYR_BASE, if it can be known.
A group carries no location of its own, but its members do.
"""
location = compounddef.find("./sectiondef/memberdef/location")
if location is None or not location.get("file"):
return None
return repo_relative(location.get("file"))
def toplevel_groups(xml_dir: Path) -> dict[str, tuple[str, ET.Element]]:
"""Return {group name: (title, compounddef)} for every top-level group in a Doxygen XML dir."""
index = ET.parse(xml_dir / "index.xml").getroot()
refids = {
compound.get("refid"): compound.find("name").text
for compound in index
if compound.get("kind") == "group"
}
nested: set[str] = set()
groups: dict[str, tuple[str, ET.Element]] = {}
for refid in refids:
compounddef = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef")
nested.update(inner.get("refid") for inner in compounddef.findall("innergroup"))
title = compounddef.find("title")
groups[refid] = (title.text if title is not None else "", compounddef)
return {refids[refid]: groups[refid] for refid in refids if refid not in nested}
def read_allowlist(path: Path) -> set[str]:
"""Read the allowlist, one group name per line. '#' starts a comment."""
lines = path.read_text(encoding="utf-8").splitlines()
return {name for line in lines if (name := line.split("#", 1)[0].strip())}
def find_problems(xml_dir: Path) -> list[Problem]:
"""Compare the top-level groups against the allowlist."""
toplevel = toplevel_groups(xml_dir)
allowed = read_allowlist(ALLOWLIST)
problems = []
for name, (title, compounddef) in sorted(toplevel.items()):
if name in allowed:
continue
file, line = declaration_of(name, group_source(compounddef))
problems.append(
Problem(
summary="New top-level Doxygen group",
summary_plural="New top-level Doxygen groups",
message=f'`{name}` ("{title}")',
remedy=(
"Give the group a parent with `@ingroup` so that it nests under an existing "
"category. Where a new top-level category really is intended, allowlist the "
"group in `doc/_doxygen/toplevel_groups.txt`; that requires approval from the "
"documentation maintainers."
),
file=file,
line=line,
)
)
for name in sorted(allowed - set(toplevel)):
problems.append(
Problem(
summary="Stale top-level group allowlist entry",
summary_plural="Stale top-level group allowlist entries",
message=f"`{name}`",
remedy=(
"The group now has a parent, or no longer exists, so its line in "
"`doc/_doxygen/toplevel_groups.txt` is obsolete. Delete the line to keep the "
"allowlist in step with the API index."
),
)
)
return problems
def annotate(problems: list[Problem]) -> None:
"""Print each problem as a GitHub Actions error annotation.
Each annotation is read on its own, away from the job summary, so it carries its remedy too.
Annotations render as plain text, hence stripping the markdown backticks.
"""
for problem in problems:
location = f"file={problem.file},line={problem.line}," if problem.file else ""
text = f"{problem.message}: {problem.remedy}".replace("`", "")
print(f"::error {location}title={problem.summary}::{text}")
def write_summary(problems: list[Problem], summary_file: str) -> None:
"""Append a markdown summary of the problems to `summary_file`."""
try:
with open(summary_file, "a", encoding="utf-8") as f:
f.write("\n## 🗂️ Doxygen Top-level Group Check Results\n\n")
if not problems:
f.write("✅ **No new top-level Doxygen groups!**\n")
return
count = quantify(len(problems), "problem", "problems")
f.write(f"> [!CAUTION]\n> **{count} with the top-level API documentation index.**\n\n")
kinds: dict[str, list[Problem]] = {}
for problem in problems:
kinds.setdefault(problem.summary, []).append(problem)
for found in kinds.values():
heading = found[0].summary if len(found) == 1 else found[0].summary_plural
f.write(f"### {heading}\n\n{found[0].remedy}\n\n")
for problem in found:
location = f" — `{problem.file}:{problem.line}`" if problem.file else ""
f.write(f"- {problem.message}{location}\n")
f.write("\n")
f.write(
"See the project's [Doxygen style guidelines]"
"(https://docs.zephyrproject.org/latest/contribute/style/doxygen.html).\n"
)
except OSError as e:
print(f"::warning::Failed to write summary file '{summary_file}': {e}")
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
allow_abbrev=False,
)
parser.add_argument("--xml-dir", required=True, help="Path to the Doxygen XML output directory")
args = parser.parse_args()
xml_dir = Path(args.xml_dir)
if not (xml_dir / "index.xml").exists():
print(f"::error::Doxygen XML index not found under '{xml_dir}'. Was Doxygen run?")
return 1
if not ALLOWLIST.exists():
print(f"::error::Allowlist not found: {ALLOWLIST}")
return 1
try:
problems = find_problems(xml_dir)
except (ET.ParseError, OSError) as e:
print(f"::error::Failed to read Doxygen XML under '{xml_dir}': {e}")
return 1
annotate(problems)
if summary_file := os.environ.get("GITHUB_STEP_SUMMARY"):
write_summary(problems, summary_file)
if problems:
count = quantify(len(problems), "problem", "problems")
print(f"Found {count} with the top-level Doxygen groups.")
return 1
print("No new top-level Doxygen groups.")
return 0
if __name__ == "__main__":
sys.exit(main())