PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
audit_function_docs.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""!
3@file audit_function_docs.py
4@brief Audits C and Python function documentation coverage across the repository.
5
6This script enforces the repository's function-level documentation contract for:
7
8- public C declarations in `include/`,
9- C definitions in `src/` and `tests/c/`,
10- Python functions in `scripts/`, `tests/`, and Python-backed executable scripts.
11
12It is intentionally lightweight. The C side uses signature scanning instead of a
13full parser, while the Python side uses `ast`.
14"""
15
16from __future__ import annotations
17
18import ast
19import re
20import sys
21from dataclasses import dataclass
22from pathlib import Path
23
24
25REPO_ROOT = Path(__file__).resolve().parents[1]
26
27C_HEADER_DIRS = (REPO_ROOT / "include",)
28C_SOURCE_DIRS = (REPO_ROOT / "src", REPO_ROOT / "tests" / "c")
29PYTHON_DIRS = (REPO_ROOT / "scripts", REPO_ROOT / "tests")
30PYTHON_EXTRA_FILES = (
31 REPO_ROOT / "scripts" / "picurv",
32 REPO_ROOT / "scripts" / "grid.gen",
33)
34
35C_DECL_START_RE = re.compile(
36 r"^\s*(?!typedef\b)(?!if\b)(?!for\b)(?!while\b)(?!switch\b)(?!return\b)(?!else\b)"
37 r"(?:extern\s+)?(?:static\s+)?(?:inline\s+)?(?:const\s+)?(?:unsigned\s+|signed\s+)?"
38 r"(?:[A-Za-z_][A-Za-z0-9_]*\s+)+(?:\*\s*)*"
39 r"([A-Za-z_][A-Za-z0-9_]*)\s*\‍("
40)
41C_PARAM_RE = re.compile(r"@param(?:\[[^\]]+\])?\s+([A-Za-z_][A-Za-z0-9_]*)")
42
43
44@dataclass(frozen=True)
46 """!
47 @brief Represents one audit failure.
48 @param[in] path Repository-relative path containing the failure.
49 @param[in] line 1-based source line associated with the failure.
50 @param[in] symbol Function symbol being audited.
51 @param[in] message Human-readable failure description.
52 """
53
54 path: str
55 line: int
56 symbol: str
57 message: str
58
59
60def _iter_c_files(directories: tuple[Path, ...]) -> list[Path]:
61 """!
62 @brief Returns all C or header files below the configured directories.
63 @param[in] directories Root directories to scan.
64 @return Sorted list of matching file paths.
65 """
66
67 files: list[Path] = []
68 for directory in directories:
69 if not directory.exists():
70 continue
71 files.extend(sorted(path for path in directory.rglob("*") if path.suffix in {".c", ".h"}))
72 return sorted(files)
73
74
75def _iter_python_files() -> list[Path]:
76 """!
77 @brief Returns all Python source files covered by the audit.
78 @return Sorted list of Python-backed source files.
79 """
80
81 files: set[Path] = set()
82 for directory in PYTHON_DIRS:
83 if not directory.exists():
84 continue
85 files.update(path for path in directory.rglob("*.py"))
86
87 for path in PYTHON_EXTRA_FILES:
88 if path.exists():
89 files.add(path)
90
91 return sorted(files)
92
93
94def _read_lines(path: Path) -> list[str]:
95 """!
96 @brief Reads a text file into a list of lines.
97 @param[in] path Path to read.
98 @return File contents split into lines without trailing newline markers.
99 """
100
101 return path.read_text(encoding="utf-8", errors="ignore").splitlines()
102
103
104def _relative_path(path: Path) -> str:
105 """!
106 @brief Returns a repository-relative path string.
107 @param[in] path Absolute or repository-local path.
108 @return POSIX-style repository-relative path.
109 """
110
111 return path.relative_to(REPO_ROOT).as_posix()
112
113
114def _find_attached_doxygen_block(lines: list[str], start_line: int) -> tuple[int, int] | None:
115 """!
116 @brief Finds the Doxygen block immediately attached to a declaration or definition.
117 @param[in] lines File content lines.
118 @param[in] start_line 0-based line index where the symbol begins.
119 @return `(start, end)` line indices for the attached block, or `None`.
120 """
121
122 probe = start_line - 1
123 while probe >= 0 and lines[probe].strip() == "":
124 probe -= 1
125
126 if probe < 0 or "*/" not in lines[probe]:
127 return None
128
129 end = probe
130 while probe >= 0 and "/**" not in lines[probe]:
131 probe -= 1
132
133 if probe < 0:
134 return None
135
136 return probe, end
137
138
139def _split_c_parameters(signature: str) -> list[str]:
140 """!
141 @brief Splits a C signature parameter list into parameter names.
142 @param[in] signature Full function signature text.
143 @return Ordered list of parameter names excluding `void` and variadics.
144 """
145
146 start = signature.find("(")
147 end = signature.rfind(")")
148 if start < 0 or end < 0 or end <= start:
149 return []
150
151 raw = signature[start + 1:end]
152 params: list[str] = []
153 depth = 0
154 current: list[str] = []
155 for char in raw:
156 if char == "," and depth == 0:
157 params.append("".join(current).strip())
158 current = []
159 continue
160 current.append(char)
161 if char in "([{":
162 depth += 1
163 elif char in ")]}":
164 depth -= 1
165 if current:
166 params.append("".join(current).strip())
167
168 names: list[str] = []
169 for param in params:
170 if not param or param == "void" or param == "...":
171 continue
172
173 clean = re.sub(r"\b(const|volatile|restrict|extern|static|register|inline)\b", "", param)
174 clean = clean.strip()
175 match = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[[^\]]*\]\s*)*$", clean)
176 if match:
177 names.append(match.group(1))
178
179 return names
180
181
182def _c_return_type(signature: str, symbol: str) -> str:
183 """!
184 @brief Extracts the declared C return type prefix for one signature.
185 @param[in] signature Full function signature text.
186 @param[in] symbol Function name contained in the signature.
187 @return Normalized return-type prefix.
188 """
189
190 prefix = signature.split(symbol, 1)[0]
191 return " ".join(prefix.split())
192
193
194def _return_tag_required(return_type: str) -> bool:
195 """!
196 @brief Reports whether a Doxygen `@return` tag is required for a C symbol.
197 @param[in] return_type Normalized return-type prefix.
198 @return `True` when the symbol does not return `void`.
199 """
200
201 stripped = return_type.replace("extern ", "").replace("static ", "").replace("inline ", "").strip()
202 return not stripped.startswith("void")
203
204
205def _collect_c_signatures(path: Path, require_terminator: str) -> list[tuple[int, str, str]]:
206 """!
207 @brief Collects C signatures from a header or source file.
208 @param[in] path File to scan.
209 @param[in] require_terminator Expected signature terminator, either `;` or `{`.
210 @return List of `(start_line, symbol, signature_text)` tuples.
211 """
212
213 lines = _read_lines(path)
214 signatures: list[tuple[int, str, str]] = []
215 line_index = 0
216 in_block_comment = False
217
218 while line_index < len(lines):
219 stripped = lines[line_index].lstrip()
220 if in_block_comment:
221 if "*/" in lines[line_index]:
222 in_block_comment = False
223 line_index += 1
224 continue
225
226 if "/*" in lines[line_index]:
227 if "*/" not in lines[line_index]:
228 in_block_comment = True
229 line_index += 1
230 continue
231
232 if stripped.startswith(("#", "/*", "*", "//")) or "(" not in lines[line_index]:
233 line_index += 1
234 continue
235
236 match = C_DECL_START_RE.match(lines[line_index])
237 if not match:
238 line_index += 1
239 continue
240
241 symbol = match.group(1)
242 start_line = line_index
243 signature = lines[line_index].rstrip()
244 while line_index + 1 < len(lines) and require_terminator not in signature and ";" not in signature:
245 line_index += 1
246 signature += " " + lines[line_index].strip()
247
248 if require_terminator == ";" and ";" in signature:
249 signatures.append((start_line, symbol, signature))
250 elif require_terminator == "{" and "{" in signature and ";" not in signature.split("{", 1)[0]:
251 signatures.append((start_line, symbol, signature))
252
253 line_index += 1
254
255 return signatures
256
257
258def _audit_c_header(path: Path) -> list[AuditFinding]:
259 """!
260 @brief Audits public C declarations in one header file.
261 @param[in] path Header file to scan.
262 @return Findings emitted for the header.
263 """
264
265 findings: list[AuditFinding] = []
266 lines = _read_lines(path)
267 for start_line, symbol, signature in _collect_c_signatures(path, ";"):
268 block_range = _find_attached_doxygen_block(lines, start_line)
269 if block_range is None:
270 findings.append(AuditFinding(_relative_path(path), start_line + 1, symbol, "missing attached Doxygen block"))
271 continue
272
273 block = "\n".join(lines[block_range[0]:block_range[1] + 1])
274 if "@brief" not in block:
275 findings.append(AuditFinding(_relative_path(path), start_line + 1, symbol, "missing @brief tag"))
276
277 declared_params = _split_c_parameters(signature)
278 documented_params = set(C_PARAM_RE.findall(block))
279 if set(declared_params) != documented_params:
280 findings.append(
282 _relative_path(path),
283 start_line + 1,
284 symbol,
285 f"documented @param names {sorted(documented_params)} do not match declaration {declared_params}",
286 )
287 )
288
289 if _return_tag_required(_c_return_type(signature, symbol)) and "@return" not in block:
290 findings.append(AuditFinding(_relative_path(path), start_line + 1, symbol, "missing @return tag"))
291
292 return findings
293
294
295def _audit_c_source(path: Path) -> list[AuditFinding]:
296 """!
297 @brief Audits function definitions in one C source file.
298 @param[in] path Source file to scan.
299 @return Findings emitted for the source file.
300 """
301
302 findings: list[AuditFinding] = []
303 lines = _read_lines(path)
304 for start_line, symbol, _signature in _collect_c_signatures(path, "{"):
305 block_range = _find_attached_doxygen_block(lines, start_line)
306 if block_range is None:
307 findings.append(AuditFinding(_relative_path(path), start_line + 1, symbol, "missing attached Doxygen block"))
308 continue
309
310 block = "\n".join(lines[block_range[0]:block_range[1] + 1])
311 if "@brief" not in block:
312 findings.append(AuditFinding(_relative_path(path), start_line + 1, symbol, "missing @brief tag"))
313
314 return findings
315
316
317def _python_parameter_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
318 """!
319 @brief Returns the meaningful Python parameter names for one function node.
320 @param[in] node Function AST node.
321 @return Ordered list of parameters expected in `@param` tags.
322 """
323
324 names = [arg.arg for arg in node.args.posonlyargs + node.args.args + node.args.kwonlyargs]
325 names = [name for name in names if name not in {"self", "cls"}]
326 if node.args.vararg is not None:
327 names.append(node.args.vararg.arg)
328 if node.args.kwarg is not None:
329 names.append(node.args.kwarg.arg)
330 return names
331
332
333def _python_requires_return(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
334 """!
335 @brief Reports whether one Python function should document a return value.
336 @param[in] node Function AST node.
337 @return `True` when the function returns a non-`None` value.
338 """
339
340 for child in ast.walk(node):
341 if isinstance(child, ast.Return) and child.value is not None:
342 if isinstance(child.value, ast.Constant) and child.value.value is None:
343 continue
344 return True
345 return False
346
347
348def _audit_python_file(path: Path) -> list[AuditFinding]:
349 """!
350 @brief Audits Python function docstrings in one file.
351 @param[in] path Python source file to scan.
352 @return Findings emitted for the Python file.
353 """
354
355 findings: list[AuditFinding] = []
356 source = path.read_text(encoding="utf-8")
357 tree = ast.parse(source, filename=str(path))
358
359 for node in ast.walk(tree):
360 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
361 continue
362
363 docstring = ast.get_docstring(node)
364 if docstring is None:
365 findings.append(AuditFinding(_relative_path(path), node.lineno, node.name, "missing Python docstring"))
366 continue
367
368 if "@brief" not in docstring:
369 findings.append(AuditFinding(_relative_path(path), node.lineno, node.name, "missing @brief tag"))
370
371 declared_params = _python_parameter_names(node)
372 documented_params = set(re.findall(r"@param(?:\[[^\]]+\])?\s+([A-Za-z_][A-Za-z0-9_]*)", docstring))
373 if set(declared_params) != documented_params:
374 findings.append(
376 _relative_path(path),
377 node.lineno,
378 node.name,
379 f"documented @param names {sorted(documented_params)} do not match declaration {declared_params}",
380 )
381 )
382
383 if _python_requires_return(node) and "@return" not in docstring:
384 findings.append(AuditFinding(_relative_path(path), node.lineno, node.name, "missing @return tag"))
385
386 return findings
387
388
389def _collect_findings() -> list[AuditFinding]:
390 """!
391 @brief Runs the full repository documentation audit.
392 @return Sorted list of all findings emitted by the audit.
393 """
394
395 findings: list[AuditFinding] = []
396 for path in _iter_c_files(C_HEADER_DIRS):
397 findings.extend(_audit_c_header(path))
398 for path in _iter_c_files(C_SOURCE_DIRS):
399 if path.suffix == ".c":
400 findings.extend(_audit_c_source(path))
401 for path in _iter_python_files():
402 findings.extend(_audit_python_file(path))
403
404 return sorted(findings, key=lambda item: (item.path, item.line, item.symbol, item.message))
405
406
407def _print_findings(findings: list[AuditFinding]) -> None:
408 """!
409 @brief Prints findings in a grep-friendly format.
410 @param[in] findings Findings to render.
411 """
412
413 for finding in findings:
414 print(f"{finding.path}:{finding.line}: {finding.symbol}: {finding.message}")
415
416
417def main() -> int:
418 """!
419 @brief Runs the repository function documentation audit from the command line.
420 @return Process exit status.
421 """
422
423 findings = _collect_findings()
424 if findings:
425 _print_findings(findings)
426 print(f"\nFound {len(findings)} documentation issue(s).", file=sys.stderr)
427 return 1
428
429 print("Function documentation audit passed.")
430 return 0
431
432
433if __name__ == "__main__":
434 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.
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[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[AuditFinding] _audit_c_source(Path path)
Audits function definitions in one C source file.
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.
tuple[int, int]|None _find_attached_doxygen_block(list[str] lines, int start_line)
Finds the Doxygen block immediately attached to a declaration or definition.
list[AuditFinding] _audit_c_header(Path path)
Audits public C declarations in one header file.
int main()
Runs the repository function documentation audit from the command line.