3"""The file-header and ``@param``-direction rules from ``docs/STYLE_GUIDE.md``.
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".
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
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.
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.
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.
43from __future__
import annotations
47from pathlib
import Path
49sys.path.insert(0, str(Path(__file__).resolve().parent))
51from doxy_lex
import blank_noncode
52from doxy_scope
import GENERATED_PROTOCOL_FILES
53from lint_targets
import first_party_paths
56REPO_ROOT = Path(__file__).resolve().parents[2]
60SOURCE_SUFFIXES = (
".c",
".h",
".cpp",
".hpp")
71PARAM_SCAN_FLOOR = 5000
76VALID_DIRECTIONS = frozenset({
"in",
"out",
"in,out"})
81_TAG_RE = {name: re.compile(
r"[@\\]" + name +
r"\b")
for name
in (
"file",
"brief",
"details")}
87_FILE_ARG_RE = re.compile(
r"[@\\]file[ \t]*(?P<rest>[^\r\n]*)")
90_PARAM_RE = re.compile(
r"[@\\]param(?P<bracket>[ \t]*\[[^\]]*\])?")
96DETAILS_MISSING =
"DETAILS_MISSING"
99Row = tuple[str, int, str, str]
102def _doc_comments(raw: str) -> list[tuple[int, str]]:
103 """Return ``(offset, text)`` for every Doxygen comment in ``raw``.
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.
109 _code, comments = blank_noncode(raw)
110 return [(start, raw[start:end])
for start, end, style
in comments
if style
is not None]
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
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):
126def _file_arg(block: str) -> str:
127 """The argument given to ``@file``, or ``""`` when it has none."""
128 match = _FILE_ARG_RE.search(block)
131 tokens = match.group(
"rest").split()
132 return tokens[0]
if tokens
else ""
135def _file_arg_resolves(rel: str, arg: str) -> bool:
136 """Whether ``@file <arg>`` names the file it sits in.
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.
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.
148 value = arg.replace(
"\\",
"/").strip()
151 return rel == value
or rel.endswith(
"/" + value)
or Path(rel).name == value
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)
158 return [(rel, 1,
"FILE_BLOCK_MISSING",
"no file-header Doxygen block carrying @file")]
159 offset, block = found
160 line = _line_of(raw, offset)
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"))
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.
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.
182 for offset, block
in docs:
183 for match
in _PARAM_RE.finditer(block):
185 line = _line_of(raw, offset + match.start())
186 bracket = match.group(
"bracket")
189 (rel, line,
"PARAM_NO_DIRECTION",
"plain @param: no [in]/[out]/[in,out]")
192 direction = re.sub(
r"\s+",
"", bracket).strip(
"[]")
193 if direction
not in VALID_DIRECTIONS:
195 (rel, line,
"PARAM_BAD_DIRECTION", f
"@param[{direction}] is not a direction")
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
208def scan() -> tuple[list[Row], list[str], int]:
209 """Audit the whole first-party C set.
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.
221 for rel
in first_party_paths(SOURCE_SUFFIXES):
222 if rel
in GENERATED_PROTOCOL_FILES:
224 path = REPO_ROOT / rel
225 if not path.is_file():
228 file_rows, seen = audit_text(rel, path.read_text(encoding=
"utf-8", errors=
"replace"))
229 rows.extend(file_rows)
231 return rows, read, params
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:
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."
242 if params < PARAM_SCAN_FLOOR:
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."
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")
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")
266 rows.sort(key=
lambda row: (row[0], row[1]))
270 f
"doxy_audit --style: violations=0 (PASS) over {len(paths)} files, "
271 f
"{params} @param tags; strict @details enforcement"
274 print(f
"doxy_audit --style: violations={len(violations)} (FAIL)")
276 print(
"Offenders (file:line rule -- detail):")
277 _print_rows(violations)
279 print(
"docs/STYLE_GUIDE.md 'File-header Doxygen block' and 'Function")
280 print(
"documentation' state these rules; this gate is what enforces them.")