3"""Which files ``check_annotations.py`` analyses, and what module each belongs to.
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.
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.
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.
27from __future__
import annotations
31from collections.abc
import Iterator
33from lint_coverage_rules
import PATH_CLASS
37_REAL_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
40_repo_root = _REAL_REPO_ROOT
49SCAN_DIRS = (
"libs",
"examples",
"tests",
"port",
"tools",
"apps")
51EXCLUDED_PATH_PARTS = {
61SOURCE_SUFFIXES = {
".c",
".cpp"}
66BUILD_OUTPUT_PARTS = frozenset(
67 {
"build",
"_deps",
"build-cov",
"build-bench",
"build-scan",
"build-mcdc"}
74GENERATED_SOURCE_CLASS =
"generated-source"
79HOST_ONLY_PREFIXES = (
"tests/",
"tools/",
"apps/host/")
89MODULE_ROOTS = (
"libs",
"tools",
"apps")
114APP_BOARD_FORM =
"board"
117def repo_root() -> pathlib.Path:
118 """Return the root every scope predicate resolves against."""
122@contextlib.contextmanager
123def override_repo_root(root: pathlib.Path) -> Iterator[
None]:
124 """Resolve scope against ``root`` for the duration of the block.
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.
132 previous = _repo_root
137 _repo_root = previous
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)
145def is_generated_source(path: pathlib.Path) -> bool:
146 """Return whether lint coverage classifies this exact path as generated.
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.
153 candidate = path
if path.is_absolute()
else repo_root() / path
155 relative = candidate.resolve().relative_to(repo_root().resolve()).as_posix()
156 except (ValueError, OSError):
158 return PATH_CLASS.get(relative) == GENERATED_SOURCE_CLASS
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)
166def _root_part(path: str) -> str |
None:
167 """Return ``path``'s first repo-relative path component, or None."""
171 rel = pathlib.Path(path).resolve().relative_to(repo_root())
172 except (ValueError, OSError):
174 if not rel.parts
or is_excluded(rel):
179def is_first_party(path: str) -> bool:
180 """True when ``path`` is hand-written source this project owns.
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
189 return _root_part(path)
in SCAN_DIRS
192def is_test_path(path: str) -> bool:
193 """True when ``path`` is a host unit-test translation unit."""
194 return "/tests/" in path.replace(
"\\",
"/")
197def is_host_only_path(path: str) -> bool:
198 """True when ``path`` is host-only code, i.e. not part of any firmware image.
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.
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.
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.
223 rel = pathlib.Path(path).resolve().relative_to(repo_root()).as_posix()
224 except (ValueError, OSError):
226 return rel.startswith(HOST_ONLY_PREFIXES)
229def module_of(path: str) -> str |
None:
230 """Return the owning module of a path, or None when it is outside one.
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>``.
237 parts = pathlib.Path(path).parts
238 for root
in MODULE_ROOTS:
240 idx = parts.index(root)
245 tail = parts[idx + 1 :]
246 depth = 3
if tail
and tail[0] == APP_BOARD_FORM
else 2
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]}"
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()))
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():
272 for path
in root.rglob(
"*")
273 if path.suffix
in SOURCE_SUFFIXES
and not is_excluded(path)