ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
doxy_style.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The file-header and ``@param``-direction rules from ``docs/STYLE_GUIDE.md``.
4
5Both rules were stated as facts by the style guide and implemented by nothing
6(#532). The guide said the file-header block's tag order mattered "because the
7cite_check / world_tag scripts grep on it" -- neither does: ``cite_check.py``
8greps ``HUM Ch`` and ``check_world_tags.py`` greps the ``[Ring N / X]`` /
9``{World: X}`` pair, and neither has ever read ``@file``, ``@brief`` or
10``@details``. The guide also said a plain ``@param`` without a direction "is
11rejected", while ``doxy_functions``'s regex made the bracket optional with an
12in-source comment reading "any direction".
13
14These two live here rather than in ``doxy_functions`` / ``doxy_members``
15because they are properties of the COMMENT TEXT rather than of a symbol. A
16file header attaches to no declaration at all, and NOT ONE of the 55 bare
17``@param`` tags the rule found in the tree sat on a function declaration: 53
18documented function-like MACROS and 2 documented a callback typedef, none of
19which the function gate looks at. Scope is therefore the derived first-party C
20set from ``lint_targets.first_party_paths`` -- ``git ls-files`` minus the named
21SOUP / generated exemptions -- not ``doxy_scope.SCAN_DIRS``, which stops at
22``libs``/``port``.
23
24STRICT RULES
25------------
26``@file`` (present, and naming this file), ``@brief``, ``@details``, and the
27``@param`` direction are hard requirements. The original ``@details`` debt was
28closed, so no baseline remains and every new violation fails on sight.
29
30BOTH ACCEPTANCE PROPERTIES FROM #190
31------------------------------------
32*No constant is compared to itself.* Every verdict here is re-derived from the
33file text on each run; the baseline is an allow-list of paths, never a
34transcribed copy of the measurement.
35
36*Every scan has a vacuity floor.* A checker that silently scans nothing
37reports a clean tree, and this repository has now found that exact failure
38twice. Both the file count and the number of ``@param`` tags actually reached
39are floored below, so a collapsed enumeration or a broken comment lexer fails
40loudly instead of passing.
41"""
42
43from __future__ import annotations
44
45import re
46import sys
47from pathlib import Path
48
49sys.path.insert(0, str(Path(__file__).resolve().parent))
50
51from doxy_lex import blank_noncode
52from doxy_scope import GENERATED_PROTOCOL_FILES
53from lint_targets import first_party_paths
54
55#: The real checkout root; every reported path is relative to it.
56REPO_ROOT = Path(__file__).resolve().parents[2]
57
58#: Suffixes this gate audits. Every first-party C/C++ translation unit and
59#: header; `lint_targets` decides which of those are ours.
60SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp")
61
62#: Vacuity floor on the file enumeration. Measured 2115 first-party C files at
63#: the time of writing; this is not a policy on tree size, it is a refusal to
64#: report a clean tree after enumerating almost none of it.
65FILE_SCAN_FLOOR = 1500
66
67#: Vacuity floor on the ``@param`` tags the comment lexer actually reached.
68#: Measured 16291. Floors the OTHER way this gate can go quiet: the file list
69#: can be perfect while `blank_noncode` stops yielding doc comments, in which
70#: case every ``@param`` rule silently checks nothing.
71PARAM_SCAN_FLOOR = 5000
72
73#: The three directions docs/STYLE_GUIDE.md names, and the only three the tree
74#: uses (measured: in 12392, out 2549, in,out 1292). Whitespace inside the
75#: bracket is stripped before the comparison, so ``[in, out]`` is accepted.
76VALID_DIRECTIONS = frozenset({"in", "out", "in,out"})
77
78#: Doxygen accepts ``@cmd`` and ``\cmd`` interchangeably, and this tree uses
79#: both -- ``port/mbedtls`` carries upstream's backslash form. A rule keyed on
80#: ``@`` alone would silently exempt every backslash-form header.
81_TAG_RE = {name: re.compile(r"[@\\]" + name + r"\b") for name in ("file", "brief", "details")}
82
83#: ``\file`` takes its optional argument on its OWN line; when the name is
84#: pushed to the next line doxygen reads the command as argument-less, meaning
85#: "the file this block is in". 17 headers in this tree are written that way,
86#: so the argument is matched same-line-only and an empty one is correct.
87_FILE_ARG_RE = re.compile(r"[@\\]file[ \t]*(?P<rest>[^\r\n]*)")
88
89#: One ``@param``, with its direction bracket if it has one.
90_PARAM_RE = re.compile(r"[@\\]param(?P<bracket>[ \t]*\‍[[^\‍]]*\‍])?")
91
92#: Offender lines printed before the report truncates, so a hook stays readable.
93OFFENDER_CAP = 50
94
95#: Rule code for the ratcheted rule. Named because two functions branch on it.
96DETAILS_MISSING = "DETAILS_MISSING"
97
98#: One finding: repo-relative path, 1-based line, rule code, human detail.
99Row = tuple[str, int, str, str]
100
101
102def _doc_comments(raw: str) -> list[tuple[int, str]]:
103 """Return ``(offset, text)`` for every Doxygen comment in ``raw``.
104
105 Uses the shared blanking lexer rather than a regex so that a ``/**`` inside
106 a string literal is not mistaken for a doc block. Plain ``/* ... */``
107 comments are excluded: a ``@param`` in one is prose, not documentation.
108 """
109 _code, comments = blank_noncode(raw)
110 return [(start, raw[start:end]) for start, end, style in comments if style is not None]
111
112
113def _line_of(raw: str, offset: int) -> int:
114 """1-based line number of ``offset`` in ``raw``."""
115 return raw.count("\n", 0, offset) + 1
116
117
118def _file_block(docs: list[tuple[int, str]]) -> tuple[int, str] | None:
119 r"""The first Doxygen comment carrying a ``@file`` / ``\file`` command."""
120 for offset, text in docs:
121 if _TAG_RE["file"].search(text):
122 return offset, text
123 return None
124
125
126def _file_arg(block: str) -> str:
127 """The argument given to ``@file``, or ``""`` when it has none."""
128 match = _FILE_ARG_RE.search(block)
129 if match is None:
130 return ""
131 tokens = match.group("rest").split()
132 return tokens[0] if tokens else ""
133
134
135def _file_arg_resolves(rel: str, arg: str) -> bool:
136 """Whether ``@file <arg>`` names the file it sits in.
137
138 Accepts the bare basename (what the style guide asks for), the full
139 repo-relative path (which hundreds of files in this tree actually use),
140 any trailing path segment of it, and an absent argument.
141
142 This is deliberately a RESOLUTION rule, not the style guide's original
143 "filename only, not the path". Both spellings resolve in doxygen, both are
144 in wide use here, and the defect worth catching is the third case: a
145 ``@file`` left naming the old location after a ``git mv``, which
146 ``docs/DOCS.md`` already lists as a common doxygen warning.
147 """
148 value = arg.replace("\\", "/").strip()
149 if not value:
150 return True
151 return rel == value or rel.endswith("/" + value) or Path(rel).name == value
152
153
154def _audit_file_block(rel: str, raw: str, docs: list[tuple[int, str]]) -> list[Row]:
155 """Check the file-header block: present, self-naming, ``@brief``, ``@details``."""
156 found = _file_block(docs)
157 if found is None:
158 return [(rel, 1, "FILE_BLOCK_MISSING", "no file-header Doxygen block carrying @file")]
159 offset, block = found
160 line = _line_of(raw, offset)
161 rows: list[Row] = []
162 arg = _file_arg(block)
163 if not _file_arg_resolves(rel, arg):
164 detail = f"@file names '{arg}', which is not this file"
165 rows.append((rel, line, "FILE_TAG_MISMATCH", detail))
166 if not _TAG_RE["brief"].search(block):
167 rows.append((rel, line, "BRIEF_MISSING", "the file-header block has no @brief"))
168 if not _TAG_RE["details"].search(block):
169 rows.append((rel, line, DETAILS_MISSING, "the file-header block has no @details"))
170 return rows
171
172
173def _audit_params(rel: str, raw: str, docs: list[tuple[int, str]]) -> tuple[list[Row], int]:
174 """Check every ``@param`` carries one of the three legal directions.
175
176 Returns the findings and the number of ``@param`` tags reached, which the
177 caller floors: a lexer that stopped yielding doc comments would otherwise
178 report a clean tree over zero tags.
179 """
180 rows: list[Row] = []
181 seen = 0
182 for offset, block in docs:
183 for match in _PARAM_RE.finditer(block):
184 seen += 1
185 line = _line_of(raw, offset + match.start())
186 bracket = match.group("bracket")
187 if bracket is None:
188 rows.append(
189 (rel, line, "PARAM_NO_DIRECTION", "plain @param: no [in]/[out]/[in,out]")
190 )
191 continue
192 direction = re.sub(r"\s+", "", bracket).strip("[]")
193 if direction not in VALID_DIRECTIONS:
194 rows.append(
195 (rel, line, "PARAM_BAD_DIRECTION", f"@param[{direction}] is not a direction")
196 )
197 return rows, seen
198
199
200def audit_text(rel: str, raw: str) -> tuple[list[Row], int]:
201 """Audit one file's text. Returns its findings and its ``@param`` count."""
202 docs = _doc_comments(raw)
203 rows = _audit_file_block(rel, raw, docs)
204 param_rows, seen = _audit_params(rel, raw, docs)
205 return [*rows, *param_rows], seen
206
207
208def scan() -> tuple[list[Row], list[str], int]:
209 """Audit the whole first-party C set.
210
211 Returns the findings, the paths actually READ, and the ``@param`` count.
212 The second element is deliberately not the enumeration: ``git ls-files``
213 still lists a path deleted from the working tree without ``git rm``, and a
214 file that is not there is not a documentation violation. Skipping it and
215 flooring on what was read means a mass disappearance still fails, while one
216 half-finished deletion does not crash the pre-commit hook.
217 """
218 read: list[str] = []
219 rows: list[Row] = []
220 params = 0
221 for rel in first_party_paths(SOURCE_SUFFIXES):
222 if rel in GENERATED_PROTOCOL_FILES:
223 continue
224 path = REPO_ROOT / rel
225 if not path.is_file():
226 continue
227 read.append(rel)
228 file_rows, seen = audit_text(rel, path.read_text(encoding="utf-8", errors="replace"))
229 rows.extend(file_rows)
230 params += seen
231 return rows, read, params
232
233
234def _floor_failure(paths: list[str], params: int) -> str | None:
235 """The vacuity complaint for this scan, or None when both floors clear."""
236 if len(paths) < FILE_SCAN_FLOOR:
237 return (
238 f"READ only {len(paths)} first-party C file(s), floor is "
239 f"{FILE_SCAN_FLOOR}. A collapsed scope reports a clean tree because "
240 "it looked at almost nothing."
241 )
242 if params < PARAM_SCAN_FLOOR:
243 return (
244 f"reached only {params} @param tag(s), floor is {PARAM_SCAN_FLOOR}. "
245 "The doc-comment lexer has stopped yielding blocks, so the direction "
246 "rule is checking nothing."
247 )
248 return None
249
250
251def _print_rows(rows: list[Row]) -> None:
252 """Print offender rows, truncated to ``OFFENDER_CAP``."""
253 for rel, line, code, detail in rows[:OFFENDER_CAP]:
254 print(f" {rel}:{line} {code} -- {detail}")
255 if len(rows) > OFFENDER_CAP:
256 print(f" ... and {len(rows) - OFFENDER_CAP} more")
257
258
259def run_check() -> int:
260 """The gate. Returns 0 when clean, 1 on findings, 2 when it could not run."""
261 rows, paths, params = scan()
262 complaint = _floor_failure(paths, params)
263 if complaint is not None:
264 sys.stderr.write(f"doxy_audit --style: FATAL -- {complaint}\n")
265 return 2
266 rows.sort(key=lambda row: (row[0], row[1]))
267 violations = rows
268 if not violations:
269 print(
270 f"doxy_audit --style: violations=0 (PASS) over {len(paths)} files, "
271 f"{params} @param tags; strict @details enforcement"
272 )
273 return 0
274 print(f"doxy_audit --style: violations={len(violations)} (FAIL)")
275 if violations:
276 print("Offenders (file:line rule -- detail):")
277 _print_rows(violations)
278 print()
279 print("docs/STYLE_GUIDE.md 'File-header Doxygen block' and 'Function")
280 print("documentation' state these rules; this gate is what enforces them.")
281 return 1