3"""The function gate: every function carries the tags CLAUDE.md requires.
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.
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.
19from __future__
import annotations
22from dataclasses
import dataclass
23from pathlib
import Path
25from doxy_lex
import find_preceding_doxy, strip_comments
26from doxy_scope
import repo_root
29REQUIRED_SCALAR = [
"@brief",
"@details",
"@return",
"@retval",
"@note",
"@since"]
31REQUIRED_MIN2 = [
"@pre",
"@post"]
34NASA_RULE5_MIN_PRE_POST = 2
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*)?"
52_HEADER_FUNC_RE = re.compile(
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*)?"
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",
99_COPY_RE = re.compile(
r"@copy(doc|details|brief)\b")
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]+)"')
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]*\]\]"
119_NOLINT_NEXT_RE = re.compile(
120 r"(?:/\*[^\n]*\bNOLINTNEXTLINE\b[^\n]*\*/|//[^\n]*\bNOLINTNEXTLINE\b[^\n]*)\s*\Z"
127_BARE_TYPE_KEYWORDS = frozenset(
145def _split_params(args_text: str) -> list[str]:
146 """Split a parameter list on top-level commas only.
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.
152 s = args_text.strip()
153 if s
in {
"",
"void"}:
155 s = re.sub(
r"__attribute__\s*\(\([^)]*\)\)",
"", s)
156 params: list[str] = []
160 if ch
in {
"(",
"[",
"{"}:
163 elif ch
in {
")",
"]",
"}"}:
166 elif ch ==
"," and depth == 0:
167 params.append(
"".join(cur).strip())
172 params.append(
"".join(cur).strip())
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",
"..."}:
180 stripped = re.sub(
r"\[[^\]]*\]",
"", param).strip()
182 fp = re.search(
r"\(\s*\*\s*([A-Za-z_][A-Za-z_0-9]*)\s*\)", stripped)
184 return fp.group(1),
False
185 toks = re.findall(
r"[A-Za-z_][A-Za-z_0-9]*", stripped)
188 if len(toks) == 1
and toks[0]
in _BARE_TYPE_KEYWORDS:
189 return f
"arg{position}",
True
190 return toks[-1],
False
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)
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))
209def is_returning_void(ret: str) -> bool:
210 """Whether a return-type string denotes plain ``void``.
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.
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.
221 The trailing-word test also accepts a qualified spelling such as
226 r = re.sub(
r"\s+",
" ", r)
228 r = re.sub(
r"\b(static|inline|extern|__attribute__\(\([^)]*\)\))\b",
"", r).strip()
233 return r ==
"void" or r.endswith(
" void")
236def _is_declaration(m: re.Match) -> bool:
237 """Reject the call expressions ``FUNC_RE`` also matches.
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 `;`.
244 if m.group(
"name")
in NON_FUNC_NAMES:
246 ret, args = m.group(
"ret"), m.group(
"args")
247 if "return" in ret.split()
or re.search(
r"\btypedef\b", ret):
251 if "=" in args
or ")" in args:
254 ret_stripped = ret.strip()
255 if ret_stripped
in (
"*",
"&")
or re.fullmatch(
r"[*&\s]+", ret_stripped):
258 return not m.group(0).lstrip().startswith((
"*",
"&"))
261@dataclass(frozen=True)
263 """The one file under audit, in the two views the waivers need.
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).
274@dataclass(frozen=True)
275class _FunctionSignature:
276 """Function identity needed to associate a definition with a contract."""
280 parameter_types: tuple[str, ...]
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()
290 name, synthetic = _param_name_info(text, position)
291 if name
is not None and not synthetic:
296 rf
"\(\s*\*\s*{re.escape(name)}\s*\)",
301 text = re.sub(rf
"\b{re.escape(name)}\b",
"", text, count=1)
305 text = re.sub(
r"\[[^\]]*\]",
"*", text)
306 return re.sub(
r"\s+",
"", text)
309def _function_signature(m: re.Match) -> _FunctionSignature:
310 """Return a name-, type-, and linkage-aware signature for ``m``."""
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")))
319 is_static=bool(re.search(
r"\bstatic\b", ret)),
323def _has_matching_peer(
324 src_no_comments: str,
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:
335 if after
and peer.start() <= m.start():
337 if not after
and peer.start() >= m.start():
339 if _function_signature(peer) == signature:
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") !=
";":
350 if not _is_declaration(prototype)
or _function_signature(prototype) != signature:
352 _block, has_block = _preceding_block(raw, src_no_comments, prototype)
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") !=
"{":
365 if not candidate.group(0).lstrip().startswith(
"[["):
367 if _has_bare_matching_prototype(raw, src_no_comments, candidate):
368 matches.append(candidate)
369 return sorted(matches, key=
lambda match: match.start())
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") !=
"{":
378 if _function_signature(definition) != signature:
380 block, has_block = _preceding_block(views.raw, views.stripped, definition)
383 args = parse_args(definition.group(
"args"))
384 if not _missing_from_block(block, args, definition.group(
"ret"))
or _COPY_RE.search(block):
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()
395 resolved.relative_to(root)
398 if resolved.parent != source_dir
or not resolved.is_file()
or resolved.suffix !=
".h":
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:
412 name = directive.group(
"name")
413 if name
in {
"if",
"ifdef",
"ifndef"}:
414 conditional_depth += 1
417 conditional_depth = max(0, conditional_depth - 1)
419 if name !=
"include" or conditional_depth != 0:
421 include = _QUOTED_INCLUDE_VALUE_RE.match(directive.group(
"body"))
424 header = _resolve_adjacent_include(path, include.group(
"path"))
425 if header
is not None and header
not in seen:
427 headers.append(header)
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):
437 offset = sum(len(line)
for line
in raw_lines[: line_no - 1])
438 block, has_block = find_preceding_doxy(raw, offset)
441 suppression = _NOLINT_NEXT_RE.search(raw[:offset])
442 if suppression
is None:
444 return find_preceding_doxy(raw, suppression.start())
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):
452 header_raw = header.read_text(encoding=
"utf-8", errors=
"replace")
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") !=
";":
459 block, has_block = _preceding_block(header_raw, header_stripped, declaration)
462 args = parse_args(declaration.group(
"args"))
463 if not _missing_from_block(block, args, declaration.group(
"ret"))
or _COPY_RE.search(
466 contracts.add(_function_signature(declaration))
474 documented_headers: set[_FunctionSignature],
478 """True when this declaration legitimately needs no block of its own.
480 Three waivers, each closing a contradiction rather than lowering the bar:
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``.
496 if views.path.suffix ==
".c" and m.group(
"term") ==
"{":
497 if _function_signature(m)
in documented_headers:
499 has_bare_local_prototype = _has_bare_matching_prototype(
504 if has_bare_local_prototype
and _matching_definition_has_contract(views, m):
506 if not re.search(
r"\bstatic\b", ret)
and not has_bare_local_prototype:
509 m.group(
"term") ==
";"
511 and _has_matching_peer(views.stripped, m, term=
"{", after=
True)
517 block.startswith(
"/*")
518 and not block.startswith(
"/**")
520 any(marker
in block
for marker
in _HEADER_STUB_MARKERS)
521 or "see implementation for details" in block.lower()
524 return is_header_stub
or bool(_COPY_RE.search(block))
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"]
535def _missing_from_block(block: str, args: list[str], ret: str) -> list[str]:
536 """Every required tag the block does not carry."""
538 if "@brief" not in block:
539 missing.append(
"@brief")
540 if "@details" not in block:
541 missing.append(
"@details")
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")
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):
569 if any(t
in (
"@return",
"@retval")
or t.startswith((
"@pre",
"@post"))
for t
in missing):
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)."""
577 raw = path.read_text(encoding=
"utf-8", errors=
"replace")
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()
585 raw_lines = raw.splitlines(keepends=
True)
586 rel = str(path.relative_to(repo_root()))
589 for m
in _audit_matches(raw, src_no_comments):
590 if not _is_declaration(m):
592 line_no = src_no_comments.count(
"\n", 0, m.start()) + 1
593 if line_no - 1 >= len(raw_lines):
596 block, has_block = _preceding_block(raw, src_no_comments, m)
598 name, ret = m.group(
"name"), m.group(
"ret")
606 rows.append((rel, line_no, name, [],
"ok"))
609 args = parse_args(m.group(
"args"))
611 _missing_from_block(block, args, ret)
613 else _missing_without_block(args, ret)
616 rows.append((rel, line_no, name, [],
"ok"))
618 rows.append((rel, line_no, name, missing, _severity(missing)))