ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
doxy_scope.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Which files ``doxy_audit.py`` reads, in each of its two scopes.
4
5The function gate covers first-party C under ``libs/``, ``port/``, ``tools/``,
6and ``apps/``, excluding nested test directories. The member gate is wider
7still because it also covers ``examples/`` and ``tests/``. The function debt
8that existed when tools entered the scope is frozen in the function baseline;
9excluding a tool until it became perfect made new gaps in that tool invisible
10and contradicted the repository-wide policy.
11
12Each scope also owns its own vacuity floor, and :func:`function_files` /
13:func:`member_files` are the materialising accessors every caller must use.
14A generator that yields nothing costs an auditor nothing to consume and makes
15it print ``gaps=0 (PASS)`` -- a perfectly documented tree and a completely
16broken walk are the same output. The floors live beside the scope lists they
17guard because that is the pair that has to stay consistent; both exit 2, so
18"the scan broke" stays distinguishable from "the tree is undocumented".
19"""
20
21from __future__ import annotations
22
23import contextlib
24import os
25import sys
26from collections.abc import Iterator
27from pathlib import Path
28
29from lint_targets import is_build_output_path
30
31#: The real checkout root. Never read directly outside `repo_root()` -- the
32#: selftest override would not be visible through a bare import of it.
33_REAL_REPO_ROOT = Path(__file__).resolve().parents[2]
34
35#: Active root; `override_repo_root()` swaps this for the duration of a block.
36_repo_root = _REAL_REPO_ROOT
37
38
39def repo_root() -> Path:
40 """Return the root every path in this auditor is reported relative to."""
41 return _repo_root
42
43
44@contextlib.contextmanager
45def override_repo_root(root: Path) -> Iterator[None]:
46 """Report paths relative to ``root`` for the duration of the block.
47
48 Used only by the selftest, which audits a synthetic tree in a temporary
49 directory. It used to rebind a module-global ``repo_root()`` instead, which
50 stopped working the moment the auditor was split across modules: the other
51 modules hold their own imported copy, so the rebinding would be invisible
52 and the suite would silently assert against the real checkout.
53 """
54 global _repo_root # noqa: PLW0603 # single-homed override; see docstring
55 previous = _repo_root
56 _repo_root = root
57 try:
58 yield
59 finally:
60 _repo_root = previous
61
62
63SCAN_DIRS = ["libs", "port", "tools", "apps"]
64
65# ``generated`` joins the exclusions so machine-emitted tool code (e.g.
66# tools/vela/generated/) is exempt exactly as vendored SOUP is: it is not
67# hand-authored, so the hand-documentation bar does not apply to it. No
68# directory named ``generated`` exists under libs/ or port/, so adding it
69# here changes only what the tools/ root contributes.
70EXCLUDE_PARTS = {"third_party", "generated", "build", ".git", "tests", "test"}
71
72# Top-level dirs scanned by the report-only member audit (--members). Wider
73# than SCAN_DIRS on purpose: the member/enum/macro documentation bar is
74# repo-wide (CLAUDE.md "these standards apply to EVERY first-party file"), so
75# the fallout report must cover examples/, tools/, and tests/ too. Vendored
76# SOUP under either canonical third-party root and generated tables under
77# libs/ra8_fonts/ stay exempt, the same as the function audit.
78MEMBER_SCAN_DIRS = ["libs", "port", "examples", "tools", "apps", "tests"]
79MEMBER_EXCLUDE_PARTS = {"third_party", "ra8_fonts", "build", "build-cov", "_deps", ".git"}
80
81# Exact files emitted by protoc-c from the reviewed schema. Generated protocol
82# code is compiled and tested, but requiring hand-authored Doxygen on every
83# generated declaration would be both unstable and overwritten on regeneration.
84# Keep this as an exact allow-list: neighboring handwritten RPC code remains in
85# both audits.
86GENERATED_PROTOCOL_FILES = {
87 "libs/ra8_c6link/inc/ra8_media_download.pb-c.h",
88 "libs/ra8_c6link/src/ra8_media_download.pb-c.c",
89}
90
91# A tree this size cannot legitimately collapse to a handful of files. If
92# either walk returns less than its floor, something broke (an unreachable
93# repo root, a renamed SCAN_DIRS entry) and reporting zero gaps would be a
94# lie -- an undocumented function cannot be found in a file nobody opened.
95# Measured 2026-07-28: 885 files in the function scope, 2116 in the member
96# scope. Same trip-wire as lint_targets.TRACKED_FLOOR.
97FUNCTION_FILE_FLOOR = 700
98MEMBER_FILE_FLOOR = 1700
99
100# Minimum path depth to form a two-segment module label (e.g. "libs/ra8_hal").
101MODULE_PATH_MIN_DEPTH = 2
102
103
104def _is_generated_protocol_file(path: Path) -> bool:
105 """Return whether ``path`` is one exact reviewed protoc-c output."""
106 try:
107 relative = path.resolve().relative_to(repo_root().resolve()).as_posix()
108 except ValueError:
109 return False
110 return relative in GENERATED_PROTOCOL_FILES
111
112
113def _is_build_output(path: Path) -> bool:
114 """Return whether ``path`` is inside any recognized generated build tree."""
115 try:
116 relative = path.resolve().relative_to(repo_root().resolve()).as_posix()
117 except ValueError:
118 return False
119 return is_build_output_path(relative)
120
121
122def iter_function_files() -> Iterator[Path]:
123 """Yield every first-party .c/.h the FUNCTION gate audits.
124
125 Both the strict gate and the report generator walked this identically
126 inline; sharing one iterator means the report can never describe a
127 different set of files than the gate enforces over. ``tools/`` is a full
128 top-level root, so adding a first-party tool cannot silently place it
129 outside the documentation gate.
130 """
131 for top in SCAN_DIRS:
132 root = repo_root() / top
133 if not root.is_dir():
134 continue
135 for dirpath, dirnames, filenames in os.walk(root):
136 dirnames[:] = [
137 d
138 for d in dirnames
139 if d not in EXCLUDE_PARTS and not _is_build_output(Path(dirpath) / d)
140 ]
141 for fn in filenames:
142 if not fn.endswith((".c", ".h")):
143 continue
144 p = Path(dirpath) / fn
145 if any(part in EXCLUDE_PARTS for part in p.relative_to(repo_root()).parts):
146 continue
147 if _is_build_output(p) or _is_generated_protocol_file(p):
148 continue
149 yield p
150
151
152def _iter_member_files(explicit: list[str]) -> Iterator[Path]:
153 """Yield first-party .c/.h paths for the member audit.
154
155 ``explicit`` is a list of user-supplied paths; if non-empty those exact
156 files are audited, otherwise MEMBER_SCAN_DIRS is walked.
157 """
158 if explicit:
159 for raw_path in explicit:
160 p = Path(raw_path)
161 # Accept CWD/absolute paths first, then resolve repo-relative input.
162 candidate = p if p.is_file() else repo_root() / raw_path
163 if (
164 candidate.is_file()
165 and not _is_build_output(candidate)
166 and not _is_generated_protocol_file(candidate)
167 ):
168 yield candidate
169 return
170 for top in MEMBER_SCAN_DIRS:
171 root = repo_root() / top
172 if not root.is_dir():
173 continue
174 for dirpath, dirnames, filenames in os.walk(root):
175 dirnames[:] = [
176 d
177 for d in dirnames
178 if d not in MEMBER_EXCLUDE_PARTS and not _is_build_output(Path(dirpath) / d)
179 ]
180 for fn in filenames:
181 if not fn.endswith((".c", ".h")):
182 continue
183 p = Path(dirpath) / fn
184 if any(part in MEMBER_EXCLUDE_PARTS for part in p.relative_to(repo_root()).parts):
185 continue
186 if _is_build_output(p) or _is_generated_protocol_file(p):
187 continue
188 yield p
189
190
191def _fatal_below_floor(kind: str, count: int, floor: int) -> None:
192 """Abort with exit 2 when a scope walk enumerated fewer files than its floor.
193
194 Args:
195 kind: Which scope collapsed, named as the caller's flag would spell it
196 (e.g. ``"function"`` or ``"member"``).
197 count: How many files the walk actually yielded.
198 floor: The measured floor that count must reach.
199
200 Raises:
201 SystemExit: Always, with code 2, when ``count`` is below ``floor``.
202 """
203 if count >= floor:
204 return
205 sys.stderr.write(
206 f"doxy_audit.py: FATAL -- only {count} file(s) in the {kind} scope, "
207 f"floor is {floor}.\n"
208 " A collapsed scope reports a documented tree because it read nothing.\n"
209 )
210 sys.exit(2)
211
212
213def function_files() -> list[Path]:
214 """Every file the FUNCTION scope audits, materialised and floor-checked.
215
216 The accessor exists so no caller can consume :func:`iter_function_files`
217 lazily and never notice that it yielded nothing: a generator that produces
218 no items looks exactly like a fully documented tree to the auditor
219 downstream.
220
221 Returns:
222 The function-scope paths, in walk order.
223
224 Raises:
225 SystemExit: With code 2 when fewer than FUNCTION_FILE_FLOOR files were
226 found -- a collapsed walk must fail, never read as clean.
227 """
228 files = list(iter_function_files())
229 _fatal_below_floor("function", len(files), FUNCTION_FILE_FLOOR)
230 return files
231
232
233def member_files(explicit: list[str]) -> list[Path]:
234 """Every file the MEMBER scope audits, materialised and floor-checked.
235
236 The floor applies to the repo-wide walk only. An ``explicit`` list is a
237 deliberately narrowed scope supplied on the command line -- it is allowed
238 to be one file, or to filter to none -- while a collapsed walk is a broken
239 enumeration reporting a documented tree.
240
241 Args:
242 explicit: User-supplied paths; empty means walk MEMBER_SCAN_DIRS.
243
244 Returns:
245 The member-scope paths, in walk order.
246
247 Raises:
248 SystemExit: With code 2 when the repo-wide walk found fewer than
249 MEMBER_FILE_FLOOR files.
250 """
251 files = list(_iter_member_files(explicit))
252 if not explicit:
253 _fatal_below_floor("member", len(files), MEMBER_FILE_FLOOR)
254 return files
255
256
257def _top_dir(rel: str) -> str:
258 """Top-level directory of a repo-relative path (e.g. "libs")."""
259 return rel.split("/", 1)[0]