ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
doxy_functions.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The function gate: every function carries the tags CLAUDE.md requires.
4
5Recognising a function is most of the work, and it is done with a regex over
6comment-stripped source rather than a parse. That is a deliberate trade --
7see the ``doxy_audit`` docstring on why this stayed regex-driven while
8``check_doc_attachment.py`` took the libclang dependency -- and it is why
9:func:`_is_declaration` exists: the regex over-matches call expressions, and
10each rejection below corresponds to a real construct in this tree that was
11once audited as if it were a function.
12
13The waivers in :func:`_waiver_row` are equally load-bearing. Two of them exist
14because this gate and ``check_doc_attachment.py`` would otherwise contradict
15each other -- demanding a block here that the other rejects as a duplicate --
16and no source file could satisfy both at once.
17"""
18
19from __future__ import annotations
20
21import re
22from dataclasses import dataclass
23from pathlib import Path
24
25from doxy_lex import find_preceding_doxy, strip_comments
26from doxy_scope import repo_root
27
28# Required tags for every function (per CLAUDE.md)
29REQUIRED_SCALAR = ["@brief", "@details", "@return", "@retval", "@note", "@since"]
30# @pre and @post require a minimum of 2 each (NASA Rule 5)
31REQUIRED_MIN2 = ["@pre", "@post"]
32
33# NASA Power of 10 Rule 5 mandates at least 2 preconditions and 2 postconditions.
34NASA_RULE5_MIN_PRE_POST = 2
35
36FUNC_RE = re.compile(
37 # return-type tokens (allow pointers, qualifiers, attributes)
38 r"^[ \t]*"
39 r"(?P<ret>(?:(?:static|inline|extern|const|volatile|register|signed|unsigned|"
40 r"struct|union|enum|__attribute__\s*\‍(\‍([^)]*\‍)\‍)|[A-Za-z_][A-Za-z_0-9]*)\s+|\*\s*)+)"
41 r"(?P<name>[A-Za-z_][A-Za-z_0-9]*)\s*"
42 r"\‍((?P<args>[^;{]*)\‍)\s*"
43 r"(?:__attribute__\s*\‍(\‍([^)]*\‍)\‍)\s*)?"
44 r"(?P<term>[{;])",
45 re.MULTILINE,
46)
47
48# Contracts and same-file definitions may place a C23 attribute before the
49# return type. Keep that broader spelling local to exact association: widening
50# the historical repository audit parser would turn unrelated pre-existing
51# header debt into a migration regression.
52_HEADER_FUNC_RE = re.compile(
53 r"^[ \t]*"
54 r"(?P<ret>(?:(?:\‍[\‍[[^\‍]\n]*\‍]\‍]|static|inline|extern|const|volatile|register|signed|unsigned|"
55 r"struct|union|enum|__attribute__\s*\‍(\‍([^)]*\‍)\‍)|[A-Za-z_][A-Za-z_0-9]*)\s+|\*\s*)+)"
56 r"(?P<name>[A-Za-z_][A-Za-z_0-9]*)\s*"
57 r"\‍((?P<args>[^;{]*)\‍)\s*"
58 r"(?:__attribute__\s*\‍(\‍([^)]*\‍)\‍)\s*)?"
59 r"(?P<term>[{;])",
60 re.MULTILINE,
61)
62
63# Things that look like function decls but aren't
64NON_FUNC_NAMES = {
65 "if",
66 "for",
67 "while",
68 "switch",
69 "return",
70 "sizeof",
71 "typeof",
72 "do",
73 "else",
74 "case",
75 "goto",
76 "static_assert",
77 "_Static_assert",
78 "alignof",
79 "_Alignof",
80 "defined",
81 # Inline-asm misparse: `__asm__ volatile("...")` looks like a function
82 # named `volatile` to the regex; it has no real prototype to document.
83 "volatile",
84}
85
86#: Comment texts that mark a definition as documented in its declaring header.
87#: The .c stub deliberately uses a single asterisk so doxygen ignores the
88#: block, silencing "multiple @param documentation sections" warnings.
89_HEADER_STUB_MARKERS = (
90 "see header for full description",
91 "see surrounding code and HUM citations",
92 "See the public header for the documented contract",
93 "see header for the documented contract",
94)
95
96#: @copydoc / @copydetails / @copybrief satisfy every required tag: doxygen
97#: literally substitutes the source block at render time, so restating the
98#: tags here would duplicate every @param / @retval in the generated HTML.
99_COPY_RE = re.compile(r"@copy(doc|details|brief)\b")
100
101# Contract association is deliberately narrower than C's full include model.
102# Only an unconditional, directly included header beside the definition may
103# own a private definition's contract. Inventing compiler search paths or
104# following inactive/transitive includes can silently waive a real gap.
105_PREPROCESSOR_RE = re.compile(r"^\s*#\s*(?P<name>[A-Za-z_][A-Za-z_0-9]*)(?P<body>.*)$")
106_QUOTED_INCLUDE_VALUE_RE = re.compile(r'^\s*"(?P<path>[^"\n]+)"')
107
108# Storage and repository visibility decorators are not part of the C function
109# type. Linkage is compared separately, so removing ``static`` here does not
110# allow a public declaration to satisfy a private definition.
111_RETURN_DECORATOR_RE = re.compile(
112 r"\b(?:static|inline|extern|register|RA8_INTERNAL|RA8_PRIV|RA8_WEAK)\b|"
113 r"__attribute__\s*\‍(\‍([^)]*\‍)\‍)|\‍[\‍[[^\‍]\n]*\‍]\‍]"
114)
115
116# A suppression for the declaration itself is metadata, not a documentation
117# attachment boundary. This spelling appears between the SysTick contract and
118# its attributed definition.
119_NOLINT_NEXT_RE = re.compile(
120 r"(?:/\*[^\n]*\bNOLINTNEXTLINE\b[^\n]*\*/|//[^\n]*\bNOLINTNEXTLINE\b[^\n]*)\s*\Z"
121)
122
123
124#: Bare type keywords. A parameter whose only identifier is one of these is
125#: unnamed (`void f(int)`), so it gets a positional stand-in rather than
126#: being documented under the name of its own type.
127_BARE_TYPE_KEYWORDS = frozenset(
128 {
129 "int",
130 "char",
131 "short",
132 "long",
133 "float",
134 "double",
135 "void",
136 "signed",
137 "unsigned",
138 "bool",
139 "size_t",
140 "ssize_t",
141 }
142)
143
144
145def _split_params(args_text: str) -> list[str]:
146 """Split a parameter list on top-level commas only.
147
148 Depth-aware because a function-pointer parameter or a nested initialiser
149 carries commas of its own -- `void f(int (*cb)(int, int), size_t n)` is
150 two parameters, not three.
151 """
152 s = args_text.strip()
153 if s in {"", "void"}:
154 return []
155 s = re.sub(r"__attribute__\s*\‍(\‍([^)]*\‍)\‍)", "", s)
156 params: list[str] = []
157 depth = 0
158 cur: list[str] = []
159 for ch in s:
160 if ch in {"(", "[", "{"}:
161 depth += 1
162 cur.append(ch)
163 elif ch in {")", "]", "}"}:
164 depth -= 1
165 cur.append(ch)
166 elif ch == "," and depth == 0:
167 params.append("".join(cur).strip())
168 cur = []
169 else:
170 cur.append(ch)
171 if cur:
172 params.append("".join(cur).strip())
173 return params
174
175
176def _param_name_info(param: str, position: int) -> tuple[str | None, bool]:
177 """Return a parameter name and whether it is a generated stand-in."""
178 if not param or param in {"void", "..."}:
179 return None, False
180 stripped = re.sub(r"\‍[[^\‍]]*\‍]", "", param).strip()
181 # Function pointer: the name sits inside the (*name) group.
182 fp = re.search(r"\‍(\s*\*\s*([A-Za-z_][A-Za-z_0-9]*)\s*\‍)", stripped)
183 if fp:
184 return fp.group(1), False
185 toks = re.findall(r"[A-Za-z_][A-Za-z_0-9]*", stripped)
186 if not toks:
187 return None, False
188 if len(toks) == 1 and toks[0] in _BARE_TYPE_KEYWORDS:
189 return f"arg{position}", True
190 return toks[-1], False
191
192
193def _param_name(param: str, position: int) -> str | None:
194 """Return the documented name of one parameter, or None when it has none."""
195 name, _synthetic = _param_name_info(param, position)
196 return name
197
198
199def parse_args(args_text: str) -> list[str]:
200 """Return the list of parameter names. ``(void)`` -> ``[]``."""
201 names: list[str] = []
202 for param in _split_params(args_text):
203 name = _param_name(param, len(names))
204 if name is not None:
205 names.append(name)
206 return names
207
208
209def is_returning_void(ret: str) -> bool:
210 """Whether a return-type string denotes plain ``void``.
211
212 Answers the question behind the "documents a return value it cannot have"
213 check, so it has to be tolerant of how the type was written: whitespace is
214 collapsed and ``static`` / ``inline`` / ``extern`` / ``__attribute__((...))``
215 are stripped before comparing.
216
217 A pointer return is never void, which is checked BEFORE the name match --
218 otherwise ``void *`` would read as void and every allocator in the tree
219 would be reported for documenting its return.
220
221 The trailing-word test also accepts a qualified spelling such as
222 ``const void``.
223 """
224 r = ret.strip()
225 # collapse whitespace
226 r = re.sub(r"\s+", " ", r)
227 # strip qualifiers
228 r = re.sub(r"\b(static|inline|extern|__attribute__\‍(\‍([^)]*\‍)\‍))\b", "", r).strip()
229 # pointer return is not void
230 if "*" in r:
231 return False
232 # exact "void"
233 return r == "void" or r.endswith(" void")
234
235
236def _is_declaration(m: re.Match) -> bool:
237 """Reject the call expressions ``FUNC_RE`` also matches.
238
239 Each rejection below is a real shape from this tree, not a hypothetical:
240 the ``*foo(off) = bar;`` inline-accessor register write, a `return f(x);`
241 statement, and a bare dereference of an accessor's return value all match
242 a regex looking for `<tokens> name(args) {` or `;`.
243 """
244 if m.group("name") in NON_FUNC_NAMES:
245 return False
246 ret, args = m.group("ret"), m.group("args")
247 if "return" in ret.split() or re.search(r"\btypedef\b", ret):
248 return False
249 # An assignment target on the LHS: the args group spilled past the real
250 # closing paren and now holds '=' or an extra ')'.
251 if "=" in args or ")" in args:
252 return False
253 # A return type that is *only* a dereference is a call expression.
254 ret_stripped = ret.strip()
255 if ret_stripped in ("*", "&") or re.fullmatch(r"[*&\s]+", ret_stripped):
256 return False
257 # Matched text starting `*name(` / `&name(` -- deref of an accessor.
258 return not m.group(0).lstrip().startswith(("*", "&"))
259
260
261@dataclass(frozen=True)
262class _SourceViews:
263 """The one file under audit, in the two views the waivers need.
264
265 ``raw`` still has its comments (that is where a deferring block lives);
266 ``stripped`` has them blanked (that is where a definition is found).
267 """
268
269 path: Path
270 raw: str
271 stripped: str
272
273
274@dataclass(frozen=True)
275class _FunctionSignature:
276 """Function identity needed to associate a definition with a contract."""
277
278 name: str
279 return_type: str
280 parameter_types: tuple[str, ...]
281 is_static: bool
282
283
284def _normalise_parameter_type(param: str, position: int) -> str:
285 """Canonicalise one parameter type without depending on its local name."""
286 text = re.sub(r"__attribute__\s*\‍(\‍([^)]*\‍)\‍)", "", param).strip()
287 if text == "...":
288 return text
289
290 name, synthetic = _param_name_info(text, position)
291 if name is not None and not synthetic:
292 # A function-pointer name is nested in ``(*name)``. The ordinary
293 # substitution below also works, but spelling this form explicitly
294 # keeps the pointer declarator intact when whitespace differs.
295 text = re.sub(
296 rf"\‍(\s*\*\s*{re.escape(name)}\s*\‍)",
297 "(*)",
298 text,
299 count=1,
300 )
301 text = re.sub(rf"\b{re.escape(name)}\b", "", text, count=1)
302
303 # An array parameter is adjusted to a pointer by C. Treat ``T a[]`` and
304 # ``T *a`` as the same signature while retaining every non-array token.
305 text = re.sub(r"\‍[[^\‍]]*\‍]", "*", text)
306 return re.sub(r"\s+", "", text)
307
308
309def _function_signature(m: re.Match) -> _FunctionSignature:
310 """Return a name-, type-, and linkage-aware signature for ``m``."""
311 ret = m.group("ret")
312 return _FunctionSignature(
313 name=m.group("name"),
314 return_type=re.sub(r"\s+", "", _RETURN_DECORATOR_RE.sub("", ret)),
315 parameter_types=tuple(
316 _normalise_parameter_type(param, position)
317 for position, param in enumerate(_split_params(m.group("args")))
318 ),
319 is_static=bool(re.search(r"\bstatic\b", ret)),
320 )
321
322
323def _has_matching_peer(
324 src_no_comments: str,
325 m: re.Match,
326 *,
327 term: str,
328 after: bool,
329) -> bool:
330 """Whether ``m`` has an exact declaration/definition peer in this file."""
331 signature = _function_signature(m)
332 for peer in _HEADER_FUNC_RE.finditer(src_no_comments):
333 if not _is_declaration(peer) or peer.group("term") != term:
334 continue
335 if after and peer.start() <= m.start():
336 continue
337 if not after and peer.start() >= m.start():
338 continue
339 if _function_signature(peer) == signature:
340 return True
341 return False
342
343
344def _has_bare_matching_prototype(raw: str, src_no_comments: str, m: re.Match) -> bool:
345 """Whether ``m`` has an exact earlier prototype without its own block."""
346 signature = _function_signature(m)
347 for prototype in _HEADER_FUNC_RE.finditer(src_no_comments):
348 if prototype.start() >= m.start() or prototype.group("term") != ";":
349 continue
350 if not _is_declaration(prototype) or _function_signature(prototype) != signature:
351 continue
352 _block, has_block = _preceding_block(raw, src_no_comments, prototype)
353 if not has_block:
354 return True
355 return False
356
357
358def _audit_matches(raw: str, src_no_comments: str) -> list[re.Match]:
359 """Return historical matches plus attributed definitions with a local prototype."""
360 matches = list(FUNC_RE.finditer(src_no_comments))
361 starts = {match.start() for match in matches}
362 for candidate in _HEADER_FUNC_RE.finditer(src_no_comments):
363 if candidate.start() in starts or candidate.group("term") != "{":
364 continue
365 if not candidate.group(0).lstrip().startswith("[["):
366 continue
367 if _has_bare_matching_prototype(raw, src_no_comments, candidate):
368 matches.append(candidate)
369 return sorted(matches, key=lambda match: match.start())
370
371
372def _matching_definition_has_contract(views: _SourceViews, m: re.Match) -> bool:
373 """Whether any exact same-file definition owns the complete contract."""
374 signature = _function_signature(m)
375 for definition in _HEADER_FUNC_RE.finditer(views.stripped):
376 if not _is_declaration(definition) or definition.group("term") != "{":
377 continue
378 if _function_signature(definition) != signature:
379 continue
380 block, has_block = _preceding_block(views.raw, views.stripped, definition)
381 if not has_block:
382 continue
383 args = parse_args(definition.group("args"))
384 if not _missing_from_block(block, args, definition.group("ret")) or _COPY_RE.search(block):
385 return True
386 return False
387
388
389def _resolve_adjacent_include(including: Path, include: str) -> Path | None:
390 """Resolve an include only when it names a header beside ``including``."""
391 root = repo_root().resolve()
392 source_dir = including.parent.resolve()
393 resolved = (source_dir / include).resolve()
394 try:
395 resolved.relative_to(root)
396 except ValueError:
397 return None
398 if resolved.parent != source_dir or not resolved.is_file() or resolved.suffix != ".h":
399 return None
400 return resolved
401
402
403def _included_project_headers(path: Path, raw: str) -> list[Path]:
404 """Return direct adjacent headers included outside preprocessor branches."""
405 headers: list[Path] = []
406 seen: set[Path] = set()
407 conditional_depth = 0
408 for line in strip_comments(raw).splitlines():
409 directive = _PREPROCESSOR_RE.match(line)
410 if directive is None:
411 continue
412 name = directive.group("name")
413 if name in {"if", "ifdef", "ifndef"}:
414 conditional_depth += 1
415 continue
416 if name == "endif":
417 conditional_depth = max(0, conditional_depth - 1)
418 continue
419 if name != "include" or conditional_depth != 0:
420 continue
421 include = _QUOTED_INCLUDE_VALUE_RE.match(directive.group("body"))
422 if include is None:
423 continue
424 header = _resolve_adjacent_include(path, include.group("path"))
425 if header is not None and header not in seen:
426 seen.add(header)
427 headers.append(header)
428 return headers
429
430
431def _preceding_block(raw: str, stripped: str, m: re.Match) -> tuple[str, bool]:
432 """Find the block attached to ``m`` across offset-changing comment stripping."""
433 line_no = stripped.count("\n", 0, m.start()) + 1
434 raw_lines = raw.splitlines(keepends=True)
435 if line_no - 1 >= len(raw_lines):
436 return "", False
437 offset = sum(len(line) for line in raw_lines[: line_no - 1])
438 block, has_block = find_preceding_doxy(raw, offset)
439 if has_block:
440 return block, True
441 suppression = _NOLINT_NEXT_RE.search(raw[:offset])
442 if suppression is None:
443 return "", False
444 return find_preceding_doxy(raw, suppression.start())
445
446
447def _documented_header_signatures(path: Path, raw: str) -> set[_FunctionSignature]:
448 """Collect complete contracts in direct, unconditional adjacent headers."""
449 contracts: set[_FunctionSignature] = set()
450 for header in _included_project_headers(path, raw):
451 try:
452 header_raw = header.read_text(encoding="utf-8", errors="replace")
453 except OSError:
454 continue
455 header_stripped = strip_comments(header_raw)
456 for declaration in _HEADER_FUNC_RE.finditer(header_stripped):
457 if not _is_declaration(declaration) or declaration.group("term") != ";":
458 continue
459 block, has_block = _preceding_block(header_raw, header_stripped, declaration)
460 if not has_block:
461 continue
462 args = parse_args(declaration.group("args"))
463 if not _missing_from_block(block, args, declaration.group("ret")) or _COPY_RE.search(
464 block
465 ):
466 contracts.add(_function_signature(declaration))
467 return contracts
468
469
470def _is_waived(
471 views: _SourceViews,
472 m: re.Match,
473 block: str,
474 documented_headers: set[_FunctionSignature],
475 *,
476 has_block: bool,
477) -> bool:
478 """True when this declaration legitimately needs no block of its own.
479
480 Three waivers, each closing a contradiction rather than lowering the bar:
481
482 * A non-static definition in a ``.c``. CLAUDE.md puts its authoritative
483 block on the header declaration, which the ordinary header audit checks
484 independently. Repeating the block on its definition would rot.
485 * A static definition whose exact declaration and complete contract are in
486 a directly and unconditionally included adjacent header. This supports
487 explicit private contract headers without inventing compiler search
488 paths or following inactive/transitive includes.
489 * A file-local forward prototype with an exact definition below. The
490 prototype is an ordering device, not a second contract. The definition
491 remains audited, including when it has public linkage, so a bare pair is
492 still reported once at the definition.
493 * A block that defers to the header, or substitutes one with ``@copydoc``.
494 """
495 ret = m.group("ret")
496 if views.path.suffix == ".c" and m.group("term") == "{":
497 if _function_signature(m) in documented_headers:
498 return True
499 has_bare_local_prototype = _has_bare_matching_prototype(
500 views.raw,
501 views.stripped,
502 m,
503 )
504 if has_bare_local_prototype and _matching_definition_has_contract(views, m):
505 return True
506 if not re.search(r"\bstatic\b", ret) and not has_bare_local_prototype:
507 return True
508 if (
509 m.group("term") == ";"
510 and not has_block
511 and _has_matching_peer(views.stripped, m, term="{", after=True)
512 ):
513 return True
514 if not has_block:
515 return False
516 is_header_stub = (
517 block.startswith("/*")
518 and not block.startswith("/**")
519 and (
520 any(marker in block for marker in _HEADER_STUB_MARKERS)
521 or "see implementation for details" in block.lower()
522 )
523 )
524 return is_header_stub or bool(_COPY_RE.search(block))
525
526
527def _missing_without_block(args: list[str], ret: str) -> list[str]:
528 """Every required tag, for a function carrying no block at all."""
529 missing = ["@brief", "@details", *(f"@param[{a}]" for a in args)]
530 if not is_returning_void(ret):
531 missing += ["@return", "@retval"]
532 return [*missing, "@pre", "@post", "@note", "@since"]
533
534
535def _missing_from_block(block: str, args: list[str], ret: str) -> list[str]:
536 """Every required tag the block does not carry."""
537 missing = []
538 if "@brief" not in block:
539 missing.append("@brief")
540 if "@details" not in block:
541 missing.append("@details")
542 for a in args:
543 # match @param[...] name OR @param name (any direction)
544 pat = re.compile(r"@param(?:\s*\‍[[^\‍]]*\‍])?\s+" + re.escape(a) + r"\b")
545 if not pat.search(block):
546 missing.append(f"@param[{a}]")
547 if not is_returning_void(ret):
548 if "@return" not in block and "@returns" not in block:
549 missing.append("@return")
550 if "@retval" not in block:
551 missing.append("@retval")
552 n_pre = len(re.findall(r"@pre\b", block))
553 n_post = len(re.findall(r"@post\b", block))
554 if n_pre < NASA_RULE5_MIN_PRE_POST:
555 missing.append(f"@pre(<2:{n_pre})")
556 if n_post < NASA_RULE5_MIN_PRE_POST:
557 missing.append(f"@post(<2:{n_post})")
558 if "@note" not in block:
559 missing.append("@note")
560 if "@since" not in block:
561 missing.append("@since")
562 return missing
563
564
565def _severity(missing: list[str]) -> str:
566 """Rank a gap: a missing @brief or @param is worse than a missing @since."""
567 if any(t == "@brief" or t.startswith("@param[") for t in missing):
568 return "high"
569 if any(t in ("@return", "@retval") or t.startswith(("@pre", "@post")) for t in missing):
570 return "medium"
571 return "low"
572
573
574def audit_file(path: Path) -> list[tuple[str, int, str, str, str]]:
575 """Return one row per function in ``path``: (file, line, name, missing, severity)."""
576 try:
577 raw = path.read_text(encoding="utf-8", errors="replace")
578 except OSError:
579 return []
580 src_no_comments = strip_comments(raw)
581 views = _SourceViews(path, raw, src_no_comments)
582 documented_headers = _documented_header_signatures(path, raw) if path.suffix == ".c" else set()
583 # strip_comments preserves newlines, so line numbers agree between the
584 # stripped and raw views and one offset conversion serves both.
585 raw_lines = raw.splitlines(keepends=True)
586 rel = str(path.relative_to(repo_root()))
587
588 rows = []
589 for m in _audit_matches(raw, src_no_comments):
590 if not _is_declaration(m):
591 continue
592 line_no = src_no_comments.count("\n", 0, m.start()) + 1
593 if line_no - 1 >= len(raw_lines):
594 continue
595 # Read the block from the ORIGINAL source, where comments still exist.
596 block, has_block = _preceding_block(raw, src_no_comments, m)
597
598 name, ret = m.group("name"), m.group("ret")
599 if _is_waived(
600 views,
601 m,
602 block,
603 documented_headers,
604 has_block=has_block,
605 ):
606 rows.append((rel, line_no, name, [], "ok"))
607 continue
608
609 args = parse_args(m.group("args"))
610 missing = (
611 _missing_from_block(block, args, ret)
612 if has_block
613 else _missing_without_block(args, ret)
614 )
615 if not missing:
616 rows.append((rel, line_no, name, [], "ok"))
617 continue
618 rows.append((rel, line_no, name, missing, _severity(missing)))
619
620 return rows