3@file audit_function_docs.py
4@brief Audits C and Python function documentation coverage across the repository.
6This script enforces the repository's function-level documentation contract for:
8- public C declarations in `include/`,
9- C definitions in `src/` and `tests/c/`,
10- Python functions in `picurv_cli/`, `generators/`, and `tests/`.
12It is intentionally lightweight. The C side uses signature scanning instead of a
13full parser, while the Python side uses `ast`. It checks both coverage and a
14minimum usefulness contract: a comment must say what the function does, rather
15than merely labelling it as a helper or implementation.
18from __future__
import annotations
23from dataclasses
import dataclass
24from pathlib
import Path
27REPO_ROOT = Path(__file__).resolve().parents[2]
29C_HEADER_DIRS = (REPO_ROOT /
"include",)
30C_SOURCE_DIRS = (REPO_ROOT /
"src", REPO_ROOT /
"tests" /
"c")
32 REPO_ROOT /
"picurv_cli",
33 REPO_ROOT /
"generators",
37 REPO_ROOT /
"picurv_cli" /
"picurv",
38 REPO_ROOT /
"generators" /
"grid.gen",
39 REPO_ROOT /
"generators" /
"profile.gen",
40 REPO_ROOT /
"generators" /
"ic.gen",
41 REPO_ROOT /
"generators" /
"plot.gen",
44C_DECL_START_RE = re.compile(
45 r"^\s*(?!typedef\b)(?!if\b)(?!for\b)(?!while\b)(?!switch\b)(?!return\b)(?!else\b)"
46 r"(?:extern\s+)?(?:static\s+)?(?:inline\s+)?(?:const\s+)?(?:unsigned\s+|signed\s+)?"
47 r"(?:[A-Za-z_][A-Za-z0-9_]*\s+)+(?:\*\s*)*"
48 r"([A-Za-z_][A-Za-z0-9_]*)\s*\("
50C_PARAM_RE = re.compile(
r"@param(?:\[[^\]]+\])?\s+([A-Za-z_][A-Za-z0-9_]*)")
51GENERIC_DESCRIPTION_RE = re.compile(
52 r"(?:a |an |the )?(?:public interface|helper function|implementation of|internal helper|"
53 r"routine|function|test helper|utility function|helper routine)\.?$",
56STUB_IMPLEMENTATION_RE = re.compile(
57 r"(?:internal )?helper implementation\s*:", re.IGNORECASE
59GENERIC_PUBLIC_PARAM_RE = re.compile(
60 r"@param(?:\[[^\]]+\])?\s+[A-Za-z_][A-Za-z0-9_]*\s+"
61 r"(?:parameter|argument)\b.*\bpassed to\b|"
62 r"@param(?:\[[^\]]+\])?\s+[A-Za-z_][A-Za-z0-9_]*\s+(?:output:|the)\s*$",
63 re.IGNORECASE | re.MULTILINE,
69 @brief Report whether a function comment contains a non-stub description.
70 @param[in] comment Attached C comment or Python docstring to examine.
71 @return `True` when the description is not a known placeholder or label.
73 The audit deliberately uses conservative, transparent heuristics instead of
74 attempting to judge prose. It rejects descriptions that only classify a
75 symbol (for example, "helper function") and comments with no prose beyond
76 Doxygen tags. Reviewers remain responsible for domain correctness.
79 brief = re.search(
r"@brief\s+([^\n*]+)", comment, re.IGNORECASE)
80 prose = brief.group(1)
if brief
else comment
81 prose = re.sub(
r"@(?:param|return|file|details|note|warning)\b[^\n]*",
"", prose)
82 prose = re.sub(
r"[/!*`#]",
" ", prose)
83 words = re.findall(
r"[A-Za-z][A-Za-z0-9_-]*", prose)
86 normalized = prose.strip()
88 GENERIC_DESCRIPTION_RE.fullmatch(normalized)
is None
89 and STUB_IMPLEMENTATION_RE.match(normalized)
is None
93@dataclass(frozen=True)
96 @brief Represents one audit failure.
97 @param[in] path Repository-relative path containing the failure.
98 @param[in] line 1-based source line associated with the failure.
99 @param[in] symbol Function symbol being audited.
100 @param[in] message Human-readable failure description.
111 @brief Returns all C or header files below the configured directories.
112 @param[in] directories Root directories to scan.
113 @return Sorted list of matching file paths.
116 files: list[Path] = []
117 for directory
in directories:
118 if not directory.exists():
120 files.extend(sorted(path
for path
in directory.rglob(
"*")
if path.suffix
in {
".c",
".h"}))
126 @brief Returns all Python source files covered by the audit.
127 @return Sorted list of Python-backed source files.
130 files: set[Path] = set()
131 for directory
in PYTHON_DIRS:
132 if not directory.exists():
134 files.update(path
for path
in directory.rglob(
"*.py"))
136 for path
in PYTHON_EXTRA_FILES:
145 @brief Reads a text file into a list of lines.
146 @param[in] path Path to read.
147 @return File contents split into lines without trailing newline markers.
150 return path.read_text(encoding=
"utf-8", errors=
"ignore").splitlines()
155 @brief Returns a repository-relative path string.
156 @param[in] path Absolute or repository-local path.
157 @return POSIX-style repository-relative path.
160 return path.relative_to(REPO_ROOT).as_posix()
164 require_doxygen: bool) -> tuple[int, int] |
None:
166 @brief Finds the documentation block immediately attached to a declaration or definition.
167 @param[in] lines File content lines.
168 @param[in] start_line 0-based line index where the symbol begins.
169 @param[in] require_doxygen Require a Doxygen `/**` block instead of allowing a
170 regular `/*` implementation comment.
171 @return `(start, end)` line indices for the attached block, or `None`.
174 probe = start_line - 1
175 while probe >= 0
and lines[probe].strip() ==
"":
178 if probe < 0
or "*/" not in lines[probe]:
179 if probe >= 0
and not require_doxygen
and lines[probe].lstrip().startswith(
"//"):
181 while probe >= 0
and lines[probe].lstrip().startswith(
"//"):
183 return probe + 1, end
187 marker =
"/**" if require_doxygen
else "/*"
188 while probe >= 0
and marker
not in lines[probe]:
199 @brief Splits a C signature parameter list into parameter names.
200 @param[in] signature Full function signature text.
201 @return Ordered list of parameter names excluding `void` and variadics.
204 start = signature.find(
"(")
205 end = signature.rfind(
")")
206 if start < 0
or end < 0
or end <= start:
209 raw = signature[start + 1:end]
210 params: list[str] = []
212 current: list[str] = []
214 if char ==
"," and depth == 0:
215 params.append(
"".join(current).strip())
224 params.append(
"".join(current).strip())
226 names: list[str] = []
228 if not param
or param ==
"void" or param ==
"...":
231 clean = re.sub(
r"\b(const|volatile|restrict|extern|static|register|inline)\b",
"", param)
232 clean = clean.strip()
233 match = re.search(
r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[[^\]]*\]\s*)*$", clean)
235 names.append(match.group(1))
242 @brief Extracts the declared C return type prefix for one signature.
243 @param[in] signature Full function signature text.
244 @param[in] symbol Function name contained in the signature.
245 @return Normalized return-type prefix.
248 prefix = signature.split(symbol, 1)[0]
249 return " ".join(prefix.split())
254 @brief Reports whether a Doxygen `@return` tag is required for a C symbol.
255 @param[in] return_type Normalized return-type prefix.
256 @return `True` when the symbol does not return `void`.
259 stripped = return_type.replace(
"extern ",
"").replace(
"static ",
"").replace(
"inline ",
"").strip()
260 return not stripped.startswith(
"void")
265 @brief Collects C signatures from a header or source file.
266 @param[in] path File to scan.
267 @param[in] require_terminator Expected signature terminator, either `;` or `{`.
268 @return List of `(start_line, symbol, signature_text)` tuples.
272 signatures: list[tuple[int, str, str]] = []
274 in_block_comment =
False
276 while line_index < len(lines):
277 stripped = lines[line_index].lstrip()
279 if "*/" in lines[line_index]:
280 in_block_comment =
False
284 if "/*" in lines[line_index]:
285 if "*/" not in lines[line_index]:
286 in_block_comment =
True
290 if stripped.startswith((
"#",
"/*",
"*",
"//"))
or "(" not in lines[line_index]:
294 match = C_DECL_START_RE.match(lines[line_index])
299 symbol = match.group(1)
300 start_line = line_index
301 signature = lines[line_index].rstrip()
302 while line_index + 1 < len(lines)
and require_terminator
not in signature
and ";" not in signature:
304 signature +=
" " + lines[line_index].strip()
306 if require_terminator ==
";" and ";" in signature:
307 signatures.append((start_line, symbol, signature))
308 elif require_terminator ==
"{" and "{" in signature
and ";" not in signature.split(
"{", 1)[0]:
309 signatures.append((start_line, symbol, signature))
318 @brief Audits public C declarations in one header file.
319 @param[in] path Header file to scan.
320 @return Findings emitted for the header.
323 findings: list[AuditFinding] = []
327 if block_range
is None:
331 block =
"\n".join(lines[block_range[0]:block_range[1] + 1])
332 if "@brief" not in block:
338 "stub @brief; describe the function's result, state change, or numerical role",
341 if GENERIC_PUBLIC_PARAM_RE.search(block):
345 "generic @param; describe the input, output, ownership, or numerical meaning",
350 documented_params = set(C_PARAM_RE.findall(block))
351 if set(declared_params) != documented_params:
357 f
"documented @param names {sorted(documented_params)} do not match declaration {declared_params}",
369 @brief Audits function definitions in one C source file.
370 @param[in] path Source file to scan.
371 @param[in] public_symbols Symbols with canonical public-header documentation.
372 @return Findings emitted for the source file.
375 findings: list[AuditFinding] = []
382 if block_range
is None:
386 block =
"\n".join(lines[block_range[0]:block_range[1] + 1])
394 "stub implementation comment; explain the function's computation or state change",
403 @brief Returns the meaningful Python parameter names for one function node.
404 @param[in] node Function AST node.
405 @return Ordered list of parameters expected in `@param` tags.
408 names = [arg.arg
for arg
in node.args.posonlyargs + node.args.args + node.args.kwonlyargs]
409 names = [name
for name
in names
if name
not in {
"self",
"cls"}]
410 if node.args.vararg
is not None:
411 names.append(node.args.vararg.arg)
412 if node.args.kwarg
is not None:
413 names.append(node.args.kwarg.arg)
419 @brief Reports whether one Python function should document a return value.
420 @param[in] node Function AST node.
421 @return `True` when the function returns a non-`None` value.
424 for child
in ast.walk(node):
425 if isinstance(child, ast.Return)
and child.value
is not None:
426 if isinstance(child.value, ast.Constant)
and child.value.value
is None:
434 @brief Audits Python function docstrings in one file.
435 @param[in] path Python source file to scan.
436 @return Findings emitted for the Python file.
439 findings: list[AuditFinding] = []
440 source = path.read_text(encoding=
"utf-8")
441 tree = ast.parse(source, filename=str(path))
443 for node
in ast.walk(tree):
444 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
447 docstring = ast.get_docstring(node)
448 if docstring
is None:
452 if "@brief" not in docstring:
458 "stub @brief; explain the function's result, state change, or validation role",
463 documented_params = set(re.findall(
r"@param(?:\[[^\]]+\])?\s+([A-Za-z_][A-Za-z0-9_]*)", docstring))
464 if set(declared_params) != documented_params:
470 f
"documented @param names {sorted(documented_params)} do not match declaration {declared_params}",
482 @brief Runs the full repository documentation audit.
483 @return Sorted list of all findings emitted by the audit.
486 findings: list[AuditFinding] = []
490 for path
in header_files
493 for path
in header_files:
496 if path.suffix ==
".c":
501 return sorted(findings, key=
lambda item: (item.path, item.line, item.symbol, item.message))
506 @brief Prints findings in a grep-friendly format.
507 @param[in] findings Findings to render.
510 for finding
in findings:
511 print(f
"{finding.path}:{finding.line}: {finding.symbol}: {finding.message}")
516 @brief Runs the repository function documentation audit from the command line.
517 @return Process exit status.
523 print(f
"\nFound {len(findings)} documentation issue(s).", file=sys.stderr)
526 print(
"Function documentation audit passed.")
530if __name__ ==
"__main__":
531 raise SystemExit(
main())
Represents one audit failure.
list[str] _python_parameter_names(ast.FunctionDef|ast.AsyncFunctionDef node)
Returns the meaningful Python parameter names for one function node.
list[Path] _iter_c_files(tuple[Path,...] directories)
Returns all C or header files below the configured directories.
list[str] _read_lines(Path path)
Reads a text file into a list of lines.
tuple[int, int]|None _find_attached_comment_block(list[str] lines, int start_line, bool require_doxygen)
Finds the documentation block immediately attached to a declaration or definition.
list[AuditFinding] _audit_python_file(Path path)
Audits Python function docstrings in one file.
str _c_return_type(str signature, str symbol)
Extracts the declared C return type prefix for one signature.
str _relative_path(Path path)
Returns a repository-relative path string.
list[Path] _iter_python_files()
Returns all Python source files covered by the audit.
bool _return_tag_required(str return_type)
Reports whether a Doxygen @return tag is required for a C symbol.
list[AuditFinding] _audit_c_source(Path path, set[str] public_symbols)
Audits function definitions in one C source file.
list[str] _split_c_parameters(str signature)
Splits a C signature parameter list into parameter names.
bool _python_requires_return(ast.FunctionDef|ast.AsyncFunctionDef node)
Reports whether one Python function should document a return value.
None _print_findings(list[AuditFinding] findings)
Prints findings in a grep-friendly format.
list[tuple[int, str, str]] _collect_c_signatures(Path path, str require_terminator)
Collects C signatures from a header or source file.
list[AuditFinding] _collect_findings()
Runs the full repository documentation audit.
list[AuditFinding] _audit_c_header(Path path)
Audits public C declarations in one header file.
bool _has_specific_description(str comment)
Report whether a function comment contains a non-stub description.
int main()
Runs the repository function documentation audit from the command line.