3"""The member gate: every enum value, struct/union member and macro is documented.
5CLAUDE.md ("Doxygen Documentation Requirements") demands documentation on each
6one -- an inline ``/**< ... */`` or a preceding ``/** ... */`` block on every
7aggregate member, and a ``@brief``/``@def`` block on every macro.
9Separate from the function gate because it asks a different question of a
10different view of the source (see :mod:`doxy_lex`) over a wider scope (see
11:mod:`doxy_scope`), and because the two were enforced at different times: the
12function gate has always been strict, the member gate became strict in #246.
15from __future__
import annotations
18from pathlib
import Path
20from doxy_lex
import _body_depth, _first_code_offset, _match_brace, blank_noncode
21from doxy_scope
import repo_root
23_AGG_KW_RE = re.compile(
r"\b(enum|struct|union)\b")
24_AGG_TERMINATORS = frozenset(
";=,}")
27def find_aggregate_bodies(code: str) -> list[tuple[str, int, int]]:
28 """Locate enum/struct/union *definitions* (those with a ``{ ... }`` body).
30 ``code`` must be the blanked code-only view so keywords/braces inside
31 comments and strings are already gone. Returns a list of
32 ``(kind, open_idx, close_idx)`` for every definition, including nested ones
33 (each nested aggregate is reported on its own; the parent scan skips the
34 nested body via brace depth).
38 for m
in _AGG_KW_RE.finditer(code):
68 elif ch
in _AGG_TERMINATORS:
73 close_idx = _match_brace(code, open_idx)
76 results.append((kind, open_idx, close_idx))
80def member_name(kind: str, seg_code: str) -> str:
81 """Best-effort declarator name for a member segment (for the report only)."""
86 m = re.match(
r"([A-Za-z_]\w*)", s)
87 return m.group(1)
if m
else "(anon)"
89 s = s[s.rindex(
"}") + 1 :]
90 s = re.sub(
r":\s*\d+",
"", s)
91 s = re.sub(
r"\[[^\]]*\]",
"", s)
92 fp = re.search(
r"\(\s*\*\s*([A-Za-z_]\w*)", s)
95 ids = re.findall(
r"[A-Za-z_]\w*", s)
96 return ids[-1]
if ids
else "(anon)"
102 comments: list[tuple[int, int, str |
None]],
106) -> list[tuple[str, int, str, str, str]]:
107 """Yield offender rows for undocumented members of one aggregate body.
109 A member is documented when it carries either a preceding doc block
110 (``/** ... */`` etc.) in the gap before its code, or an inline doc comment
111 (``/**< ... */`` etc.) trailing it at the same brace depth. Both forms
112 satisfy CLAUDE.md. Inline docs nested one level deeper belong to the inner
113 aggregate (audited on its own pass), not to the enclosing named field.
115 delim =
"," if kind ==
"enum" else ";"
116 body_start, body_end = open_idx, close_idx
127 elif depth == 0
and ch == delim:
131 lows = [body_start, *delims]
132 highs = [*delims, body_end]
133 code_starts = [_first_code_offset(code, lo, hi)
for lo, hi
in zip(lows, highs, strict=
False)]
136 for idx, (lo, cs)
in enumerate(zip(lows, code_starts, strict=
False)):
139 nxt = next((c
for c
in code_starts[idx + 1 :]
if c
is not None), body_end)
140 pre_ok = any(st ==
"pre" and lo < s < cs
for s, _e, st
in comments)
142 st ==
"inline" and cs < s < nxt
and _body_depth(code, body_start, s) == 0
143 for s, _e, st
in comments
145 if pre_ok
or inline_ok:
147 line_no = code.count(
"\n", 0, cs) + 1
148 name = member_name(kind, code[cs : highs[idx]])
149 label =
"enum-value" if kind ==
"enum" else "member"
150 rows.append((rel, line_no, label, name,
"undocumented"))
154_DEFINE_RE = re.compile(
r"^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)", re.MULTILINE)
157def _preceding_doc_run(
158 comments: list[tuple[int, int, str |
None]], code: str, upto: int
159) -> tuple[list[tuple[int, int]], bool]:
160 """Concatenated raw text + has-pre flag for the doc run just above ``upto``.
162 Walks the contiguous run of comments immediately preceding ``upto`` (only
163 whitespace allowed in the gaps) so both a single ``/** ... */`` block and a
164 stack of ``///`` lines are captured. Returns ``(text_spans, has_pre)`` where
165 ``text_spans`` is a list of ``(start, end)`` for the run's comments.
170 for start, end, style
in reversed(comments):
173 if code[end:boundary].strip():
175 spans.append((start, end))
176 has_pre = has_pre
or style ==
"pre"
178 return spans, has_pre
181_STANDARD_FEATURE_MACROS = frozenset(
196 raw: str, code: str, comments: list[tuple[int, int, str |
None]], rel: str
197) -> list[tuple[str, int, str, str, str]]:
198 """Yield offender rows for macros lacking a ``@brief``/``@def`` doc block."""
200 for m
in _DEFINE_RE.finditer(code):
202 if name
in _STANDARD_FEATURE_MACROS:
205 spans, has_pre = _preceding_doc_run(comments, code, def_line)
206 line_no = code.count(
"\n", 0, def_line) + 1
208 rows.append((rel, line_no,
"macro", name,
"undocumented"))
210 doc_text =
"".join(raw[s:e]
for s, e
in spans)
211 if "@brief" not in doc_text
and "@def" not in doc_text:
212 rows.append((rel, line_no,
"macro", name,
"missing-brief-or-def"))
216def audit_members_file(path: Path) -> list[tuple[str, int, str, str, str]]:
217 """Return member/enum/macro offender rows for one first-party file."""
219 raw = path.read_text(encoding=
"utf-8", errors=
"replace")
222 code, comments = blank_noncode(raw)
224 rel = str(path.relative_to(repo_root()))
228 for kind, open_idx, close_idx
in find_aggregate_bodies(code):
229 rows.extend(audit_aggregate(kind, code, comments, open_idx, close_idx, rel))
230 rows.extend(audit_macros(raw, code, comments, rel))
231 rows.sort(key=
lambda r: r[1])