ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_scope.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Which files ``check_annotations.py`` analyses, and what module each belongs to.
4
5Every other annotation module asks this one "is this path mine to judge?".
6Keeping that question in one place matters more than it looks: the rules
7disagree about scope on purpose -- the linkage rule judges all first-party
8code, NASA Rule 3 judges firmware only, and RA8_PRIV is defined over module
9boundaries -- and each of those predicates has been wrong at least once by
10being restated at a call site instead of asked for here.
11
12The repo root is a variable, not a constant
13-------------------------------------------
14``run_selftest()`` builds a synthetic tree in a temporary directory and needs
15every scope predicate to resolve against *that* root rather than the real
16checkout. It used to do this by rebinding a module-global ``REPO_ROOT`` in
17``check_annotations``. That worked only while every predicate lived in the
18same module: once the checker is split, a rebinding in one module is invisible
19to the ``from ... import REPO_ROOT`` copies in the others, and the selftest
20would quietly assert against the real tree -- passing, but proving nothing.
21
22So the root lives here, behind :func:`repo_root`, and is overridden through
23:func:`override_repo_root`. Consumers call the function; there is no name to
24import a stale copy of.
25"""
26
27from __future__ import annotations
28
29import contextlib
30import pathlib
31from collections.abc import Iterator
32
33from lint_coverage_rules import PATH_CLASS
34
35#: The real checkout root. Never read directly outside `repo_root()` -- the
36#: selftest override would not be visible through a bare import of it.
37_REAL_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
38
39#: Active root; `override_repo_root()` swaps this for the duration of a block.
40_repo_root = _REAL_REPO_ROOT
41
42#: Every first-party source root. CLAUDE.md ("Scope") holds `tools/` to the
43#: same bar as the firmware -- "a file being a host tool or just an emulator
44#: is NOT a reason to relax the rules" -- but `tools/` was absent here, so
45#: ra8_emulator, mdl, ra8_viewer and the rest were never annotation-checked
46#: at all. `scripts/` holds no C. Vendored SOUP under either canonical
47#: third-party root is dropped by is_excluded(), not by omission from this
48#: tuple.
49SCAN_DIRS = ("libs", "examples", "tests", "port", "tools", "apps")
50
51EXCLUDED_PATH_PARTS = {
52 "build",
53 "_deps",
54 "third_party",
55 "build-cov",
56 "build-bench",
57 "build-scan",
58 "build-mcdc",
59}
60
61SOURCE_SUFFIXES = {".c", ".cpp"}
62
63#: Directory names that only ever hold build output. Distinct from
64#: EXCLUDED_PATH_PARTS, which also drops vendored trees: those must stay
65#: off the *analysis* list but stay on the *include* path.
66BUILD_OUTPUT_PARTS = frozenset(
67 {"build", "_deps", "build-cov", "build-bench", "build-scan", "build-mcdc"}
68)
69
70#: Classification assigned by the lint-coverage registry to reproducible,
71#: machine-owned source. Reusing that exact registry keeps every gate on the
72#: same allow-list: a neighboring ``*.pb-c.c`` remains hand-authored C until it
73#: receives its own reviewed generator and reproducibility contract.
74GENERATED_SOURCE_CLASS = "generated-source"
75
76#: Source regions compiled only by the host toolchain. ``apps/`` cannot be
77#: exempted as a root: ``apps/board/`` is firmware and ``apps/shared_libs/`` is
78#: linked into firmware. Only the hosted product form is host-only.
79HOST_ONLY_PREFIXES = ("tests/", "tools/", "apps/host/")
80
81#: Roots whose immediate child directory is one module for RA8_PRIV purposes.
82#: `libs/<module>` is the obvious one. `tools/<tool>` is the same shape: each
83#: tool is one module split across several TUs with its own `*_internal.h`
84#: (ra8_fmt says so in that header's own file comment), and ra8_emulator calling
85#: ra8_fmt's private helper is the same boundary violation as one library
86#: calling another's. Without this, an RA8_PRIV tag under tools/ is decorative
87#: -- module_of() returned None, the rule hit `if callee_mod is None: continue`
88#: and never compared anything.
89MODULE_ROOTS = ("libs", "tools", "apps")
90
91#: How deep below its root a module's own directory sits. `libs/<module>`
92#: and `tools/<tool>` are one level down. `apps/` is TWO, and the level it
93#: skips is deliberately not part of the module identity: under `apps/` the
94#: first component is a BUILD FORM of a product, not a library.
95#: `apps/host/mdl` is the host CLI form,
96#: `apps/board/threadx_modules/mdl` can be its loadable on-device form, and
97#: `apps/shared_libs/mdl` is the portable core BOTH forms link. Those are
98#: packagings of ONE module: they share the `mdl_` symbol namespace, and a
99#: form's composition root exists precisely to drive the core's promoted
100#: RA8_PRIV seams. So the key is `apps/<product>` and the category is
101#: dropped from it, which makes every form of one product the same module
102#: and any OTHER product a different one.
103#:
104#: Keying on the category instead -- which is what a flat depth of 1 did --
105#: was wrong in both directions at once: two unrelated products sharing a
106#: category could reach into each other's internals unreported, and the day
107#: the portable core moved to `apps/shared_libs/` it reported cross-module
108#: calls that are the composition root doing its job.
109#:
110#: The one-way rule this does NOT relax, because it is a different rule
111#: entirely: `apps/shared_libs` must never include from a form. That is enforced
112#: by the core configuring, building and testing standalone -- it has no
113#: form on its include path at all -- not by this key.
114APP_BOARD_FORM = "board"
115
116
117def repo_root() -> pathlib.Path:
118 """Return the root every scope predicate resolves against."""
119 return _repo_root
120
121
122@contextlib.contextmanager
123def override_repo_root(root: pathlib.Path) -> Iterator[None]:
124 """Resolve scope against ``root`` for the duration of the block.
125
126 Used only by the selftest, which parses a synthetic tree in a temporary
127 directory. Restores the previous root even when the body raises, so a
128 failing assertion cannot leave the process judging the real tree against
129 a directory that no longer exists.
130 """
131 global _repo_root # noqa: PLW0603 # single-homed override; see module docstring
132 previous = _repo_root
133 _repo_root = root
134 try:
135 yield
136 finally:
137 _repo_root = previous
138
139
140def is_build_output(path: pathlib.Path) -> bool:
141 """True when ``path`` sits inside a build-output directory."""
142 return any(part in BUILD_OUTPUT_PARTS for part in path.parts)
143
144
145def is_generated_source(path: pathlib.Path) -> bool:
146 """Return whether lint coverage classifies this exact path as generated.
147
148 ``PATH_CLASS`` is deliberately exact-path based. The annotation checker
149 therefore ignores the two pinned protoc-c outputs, whose regenerated
150 identifiers cannot satisfy project spelling rules, without creating a
151 blanket exemption for future protobuf or other generated-looking files.
152 """
153 candidate = path if path.is_absolute() else repo_root() / path
154 try:
155 relative = candidate.resolve().relative_to(repo_root().resolve()).as_posix()
156 except (ValueError, OSError):
157 return False
158 return PATH_CLASS.get(relative) == GENERATED_SOURCE_CLASS
159
160
161def is_excluded(path: pathlib.Path) -> bool:
162 """True when ``path`` is build output, vendored, or exact generated source."""
163 return any(part in EXCLUDED_PATH_PARTS for part in path.parts) or is_generated_source(path)
164
165
166def _root_part(path: str) -> str | None:
167 """Return ``path``'s first repo-relative path component, or None."""
168 if not path:
169 return None
170 try:
171 rel = pathlib.Path(path).resolve().relative_to(repo_root())
172 except (ValueError, OSError):
173 return None
174 if not rel.parts or is_excluded(rel):
175 return None
176 return rel.parts[0]
177
178
179def is_first_party(path: str) -> bool:
180 """True when ``path`` is hand-written source this project owns.
181
182 Definitions reached through the include path are not automatically in
183 scope. Parsing the ``.cpp`` translation units as C++ pulls in
184 libstdc++, whose headers define hundreds of non-static inline
185 functions; vendored trees under either canonical third-party root are
186 SOUP. Only files under the scan roots are ours to hold to the linkage
187 rule.
188 """
189 return _root_part(path) in SCAN_DIRS
190
191
192def is_test_path(path: str) -> bool:
193 """True when ``path`` is a host unit-test translation unit."""
194 return "/tests/" in path.replace("\\", "/")
195
196
197def is_host_only_path(path: str) -> bool:
198 """True when ``path`` is host-only code, i.e. not part of any firmware image.
199
200 NASA Power of 10 Rule 3 is a claim about *firmware*: CLAUDE.md states it
201 as "zero dynamic memory after initialization (zero malloc/free in
202 firmware)". The hazard it guards -- heap fragmentation and an allocator
203 failing unpredictably in a long-running image with no operator -- does not
204 exist for a host program that runs for a moment on Linux and exits.
205
206 So the rule's real question is "is this translation unit firmware", and
207 this predicate is where that gets decided. It used to be decided by a bare
208 ``"/tests/" in path`` substring at the one call site that needed it. That
209 was the right intent expressed too narrowly: when `tools/` came into scope
210 it added 216 findings telling a CPU emulator and a libcurl downloader not
211 to call ``malloc``, none of which a developer can act on. A gate that
212 cries wolf gets switched off, so the predicate is stated in terms of what
213 actually distinguishes the code -- which toolchain compiles it -- and is
214 matched on the repo-relative root rather than by substring, so a directory
215 named ``tests`` nested anywhere else cannot silently claim the exemption.
216
217 This narrows nothing for firmware: `libs/`, `port/`, `examples/`,
218 `apps/shared_libs/`, and `apps/board/` remain held to Rule 3. They carry
219 zero ``RA8_NASA_RULE_3_OK`` waivers tree-wide because the firmware
220 genuinely does not allocate.
221 """
222 try:
223 rel = pathlib.Path(path).resolve().relative_to(repo_root()).as_posix()
224 except (ValueError, OSError):
225 return False
226 return rel.startswith(HOST_ONLY_PREFIXES)
227
228
229def module_of(path: str) -> str | None:
230 """Return the owning module of a path, or None when it is outside one.
231
232 ``libs/<module>/...`` and ``tools/<tool>/...`` name their module one level
233 below the root. Apps drop their build-form components: host and shared
234 products are ``apps/<form>/<product>``, while board products are
235 ``apps/board/<form>/<product>``.
236 """
237 parts = pathlib.Path(path).parts
238 for root in MODULE_ROOTS:
239 try:
240 idx = parts.index(root)
241 except ValueError:
242 continue
243 depth = 1
244 if root == "apps":
245 tail = parts[idx + 1 :]
246 depth = 3 if tail and tail[0] == APP_BOARD_FORM else 2
247 # Require something below the module directory: a loose file sitting
248 # directly in a category is not a product and must not name one.
249 if idx + depth < len(parts) - 1:
250 return f"{root}/{parts[idx + depth]}"
251 if idx + 1 < len(parts):
252 return f"{root}/{parts[idx + 1]}"
253 return None
254
255
256def relative(path: str) -> str:
257 """Return ``path`` relative to the repo root when it lies inside it."""
258 with contextlib.suppress(ValueError):
259 return str(pathlib.Path(path).resolve().relative_to(repo_root()))
260 return path
261
262
263def discover_translation_units() -> list[pathlib.Path]:
264 """Return every .c/.cpp file under SCAN_DIRS, excluding vendored trees."""
265 out: list[pathlib.Path] = []
266 for top in SCAN_DIRS:
267 root = repo_root() / top
268 if not root.is_dir():
269 continue
270 out.extend(
271 path
272 for path in root.rglob("*")
273 if path.suffix in SOURCE_SUFFIXES and not is_excluded(path)
274 )
275 return sorted(out)