ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_loopbound.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Per-loop bound markers, paired to the loop they describe.
4
5`RA8_LOOP_BOUND` / `RA8_LOOP_BOUND_RUNTIME` (in ``ra8_attributes.h``) bind a
6NASA Power-of-10 Rule 2 bound to ONE specific loop. Unlike the ``RA8_*``
7annotation macros they are not ``[[clang::annotate]]`` attributes -- they lower
8to a ``static_assert`` and a symbol reference, real C valid in statement
9position under every toolchain. The compiler already enforces that the ceiling
10is a positive compile-time constant (or that the runtime ceiling symbol
11exists); this module enforces the other half: that a marker is actually
12attached to a loop, and that nobody reintroduces the broken predecessor.
13
14Why textual, not libclang
15-------------------------
16The rest of ``check_annotations.py`` reasons over the AST, but these markers
17are gone by the time libclang sees the code: ``RA8_LOOP_BOUND(k_foo)`` has
18already expanded to ``static_assert(...)``, and the ``RA8_LOOP_BOUND`` token is
19nowhere in the tree. The property under check -- "this marker sits on the line
20above a ``for`` / ``while`` / ``do``" -- is a source-text adjacency, so it is
21checked on source text. Comments, strings and preprocessor ``#define`` lines
22are stripped first so a marker named inside a Doxygen example or the macro's
23own definition is not mistaken for a use.
24
25The two failure directions
26---------------------------
27This is the exact defect class the marker exists to end -- a bound annotation
28that binds to nothing -- so both directions are fatal, and the selftest
29(:func:`run_loopbound_selftest`) asserts each:
30
31* **mis-attached** -- an ``RA8_LOOP_BOUND`` / ``RA8_LOOP_BOUND_RUNTIME`` marker
32 whose next code line is not a loop. The bound describes no loop.
33* **stale statement form** -- a legacy ``RA8_BOUNDED_LOOP(x);`` in statement
34 position (immediately above a loop). That annotation was a hard clang error
35 and a silent GCC no-op that bound to no loop at all; it is the very thing
36 #382 replaced, and it must never come back. (The function-level
37 ``RA8_BOUNDED_LOOP`` annotation -- immediately above a *declaration* -- is
38 legitimate and is left alone: its next code line is a function signature, not
39 a loop.)
40"""
41
42from __future__ import annotations
43
44import pathlib
45import re
46from collections.abc import Callable
47
48from annot_model import Violation
49from annot_scope import SCAN_DIRS, is_excluded, repo_root
50
51#: Suffixes scanned for loop-bound markers. Headers are included because an
52#: inline function in a header can carry a bounded loop too.
53_LOOPBOUND_SUFFIXES = frozenset({".c", ".cpp", ".h", ".hpp"})
54
55#: The per-loop marker that asserts a compile-time-constant ceiling.
56_RE_STATIC = re.compile(r"\bRA8_LOOP_BOUND\s*\‍(")
57
58#: The per-loop marker for a runtime / linker-symbol ceiling.
59_RE_RUNTIME = re.compile(r"\bRA8_LOOP_BOUND_RUNTIME\s*\‍(")
60
61#: The legacy function-level annotation. Banned in statement position.
62_RE_LEGACY = re.compile(r"\bRA8_BOUNDED_LOOP\s*\‍(")
63
64#: A source line that begins a loop, once leading whitespace is removed.
65_RE_LOOP_START = re.compile(r"^(for|while|do)\b")
66
67#: The one rule key this module reports under.
68_RULE = "ra8_loop_bound"
69
70#: One C token at a time, longest-match-first: a block comment, a line
71#: comment, a string literal, a char literal, or any single other character.
72#: Every alternative but the last is multi-character, so a one-character match
73#: is by construction ordinary code and everything longer is a comment or
74#: literal to blank out. ``re.DOTALL`` lets the block-comment and the final
75#: ``.`` span newlines, so the reconstruction stays line-for-line with the input.
76_TOKEN_RE = re.compile(
77 r"/\*.*?\*/" # block comment
78 r"|//[^\n]*" # line comment
79 r'|"(?:\\.|[^"\\\n])*"' # string literal
80 r"|'(?:\\.|[^'\\\n])*'" # char literal
81 r"|.", # any other single character
82 re.DOTALL,
83)
84
85
86def _blanked(token: str) -> str:
87 """Replace ``token`` with spaces, but keep its newlines (line alignment)."""
88 return "".join("\n" if ch == "\n" else " " for ch in token)
89
90
91def strip_code(text: str) -> list[str]:
92 """Return ``text``'s lines with comments and string/char literals blanked.
93
94 A marker or loop keyword that appears only inside a comment or a string is
95 thereby not seen as code -- which is what keeps the macro's own definition,
96 Doxygen examples and message strings from reading as uses. Newlines are
97 preserved, so the returned list is 1:1 with the source lines.
98 """
99 rebuilt = "".join(
100 m.group(0) if len(m.group(0)) == 1 else _blanked(m.group(0))
101 for m in _TOKEN_RE.finditer(text)
102 )
103 return rebuilt.split("\n")
104
105
106def _next_code_index(code: list[str], start: int) -> int | None:
107 """Return the index of the first non-blank code line after ``start``."""
108 for j in range(start + 1, len(code)):
109 if code[j].strip():
110 return j
111 return None
112
113
114def _is_loop(code_line: str) -> bool:
115 """True when ``code_line`` (comments stripped) begins a loop statement."""
116 return bool(_RE_LOOP_START.match(code_line.strip()))
117
118
119def scan_source(path: str, text: str) -> list[Violation]:
120 """Return every loop-bound violation in one file's source ``text``.
121
122 Pure: no filesystem, no libclang, so the selftest drives it directly.
123 """
124 code = strip_code(text)
125 out: list[Violation] = []
126 for idx, code_line in enumerate(code):
127 stripped = code_line.strip()
128 # Preprocessor lines (the macro definitions themselves) are not uses.
129 if stripped.startswith("#"):
130 continue
131 line_no = idx + 1
132 nxt = _next_code_index(code, idx)
133 next_is_loop = nxt is not None and _is_loop(code[nxt])
134
135 if _RE_RUNTIME.search(code_line) or _RE_STATIC.search(code_line):
136 if not next_is_loop:
137 out.append(
138 Violation(
139 _RULE,
140 path,
141 line_no,
142 "RA8_LOOP_BOUND marker is not immediately followed by a "
143 "for/while/do loop -- the bound binds to nothing; place it "
144 "directly above the loop it describes",
145 )
146 )
147 elif _RE_LEGACY.search(code_line) and next_is_loop:
148 out.append(
149 Violation(
150 _RULE,
151 path,
152 line_no,
153 "RA8_BOUNDED_LOOP used in statement position (immediately above a "
154 "loop) -- that annotation binds to no loop (a clang error and a "
155 "silent GCC no-op). Use RA8_LOOP_BOUND / RA8_LOOP_BOUND_RUNTIME",
156 )
157 )
158 return out
159
160
161def discover_loopbound_files() -> list[pathlib.Path]:
162 """Return every first-party .c/.cpp/.h/.hpp under SCAN_DIRS, minus SOUP/build."""
163 out: list[pathlib.Path] = []
164 for top in SCAN_DIRS:
165 root = repo_root() / top
166 if not root.is_dir():
167 continue
168 out.extend(
169 p for p in root.rglob("*") if p.suffix in _LOOPBOUND_SUFFIXES and not is_excluded(p)
170 )
171 return sorted(out)
172
173
174def enforce_loop_bounds(files: list[pathlib.Path], *, require_nonempty: bool) -> list[Violation]:
175 """Scan ``files`` for loop-bound marker discipline.
176
177 ``require_nonempty`` guards the whole-tree gate against a scan that silently
178 reads nothing: an empty file list there means the discovery glob came apart,
179 which would report a clean tree having looked at zero files -- the exact
180 do-nothing-gate failure this checker exists to prevent.
181 """
182 scanned = 0
183 out: list[Violation] = []
184 for path in files:
185 if path.suffix not in _LOOPBOUND_SUFFIXES:
186 continue
187 try:
188 text = path.read_text(errors="ignore")
189 except OSError:
190 continue
191 scanned += 1
192 out.extend(scan_source(str(path), text))
193 if require_nonempty and scanned == 0:
194 out.append(
195 Violation(
196 _RULE,
197 str(repo_root()),
198 0,
199 "loop-bound scan found no source files -- the discovery glob is "
200 "broken; the check would report a clean tree having read nothing",
201 )
202 )
203 return out
204
205
206def _sf_names(violations: list[Violation]) -> set[int]:
207 """Return the set of violation line numbers, for selftest assertions."""
208 return {v.line for v in violations if v.rule == _RULE}
209
210
211def _sf_new_marker(fires: Callable[[str, str], bool]) -> list[str]:
212 """Direction 1: a NEW marker attached to a loop is clean; detached, it fires."""
213 failures: list[str] = []
214 good_static = (
215 "void f(void) {\n RA8_LOOP_BOUND(k_cap);\n for (int i = 0; i < 4; i++) { g(); }\n}\n"
216 )
217 if fires("good_static.c", good_static):
218 failures.append(
219 "loop-bound false positive: RA8_LOOP_BOUND directly above a for-loop "
220 "was reported as mis-attached"
221 )
222 good_runtime = (
223 "void f(void) {\n"
224 " RA8_LOOP_BOUND_RUNTIME(g_end);\n"
225 " while (p < &g_end) { *p = 0; p++; }\n"
226 "}\n"
227 )
228 if fires("good_runtime.c", good_runtime):
229 failures.append(
230 "loop-bound false positive: RA8_LOOP_BOUND_RUNTIME directly above a "
231 "while-loop was reported as mis-attached"
232 )
233 bad_detached = (
234 "void f(void) {\n"
235 " RA8_LOOP_BOUND(k_cap);\n"
236 " x = 5;\n"
237 " for (int i = 0; i < 4; i++) { g(); }\n"
238 "}\n"
239 )
240 if not fires("bad_detached.c", bad_detached):
241 failures.append(
242 "loop-bound went toothless: RA8_LOOP_BOUND with a statement between it "
243 "and the loop (mis-attached) was NOT reported"
244 )
245 bad_no_loop = "void f(void) {\n RA8_LOOP_BOUND(k_cap);\n return;\n}\n"
246 if not fires("bad_no_loop.c", bad_no_loop):
247 failures.append(
248 "loop-bound went toothless: RA8_LOOP_BOUND with no following loop "
249 "(a marker present with no loop) was NOT reported"
250 )
251 return failures
252
253
254def _sf_legacy(fires: Callable[[str, str], bool]) -> list[str]:
255 """Direction 2: legacy RA8_BOUNDED_LOOP is clean on a decl, fires on a loop."""
256 failures: list[str] = []
257 legacy_decl = (
258 "RA8_BOUNDED_LOOP(k_polls)\n"
259 "static int worker(int n)\n"
260 "{\n"
261 " for (int i = 0; i < n; i++) { g(); }\n"
262 " return 0;\n"
263 "}\n"
264 )
265 if fires("legacy_decl.c", legacy_decl):
266 failures.append(
267 "loop-bound false positive: function-level RA8_BOUNDED_LOOP above a "
268 "declaration is the legitimate use and must not be reported"
269 )
270 legacy_stmt = (
271 "void f(void) {\n RA8_BOUNDED_LOOP(k_cap);\n for (int i = 0; i < 4; i++) { g(); }\n}\n"
272 )
273 if not fires("legacy_stmt.c", legacy_stmt):
274 failures.append(
275 "loop-bound went toothless: legacy RA8_BOUNDED_LOOP in statement "
276 "position above a loop (a loop lacking a real bound) was NOT reported"
277 )
278 return failures
279
280
281def _sf_non_uses(fires: Callable[[str, str], bool]) -> list[str]:
282 """A marker named only in a comment, string, or #define is not a use."""
283 failures: list[str] = []
284 non_uses = (
285 '#define RA8_LOOP_BOUND(c) static_assert((c) > 0, "x")\n'
286 "/* RA8_LOOP_BOUND(k_cap); shown in a comment */\n"
287 'const char* s = "RA8_LOOP_BOUND(k_cap);";\n'
288 "void f(void) { return; }\n"
289 )
290 if fires("non_uses.c", non_uses):
291 failures.append(
292 "loop-bound false positive: a marker inside a #define / comment / "
293 "string literal was treated as a real use"
294 )
295 return failures
296
297
298def run_loopbound_selftest() -> list[str]:
299 """Assert both failure directions and both clean shapes. Returns failures.
300
301 Called from ``annot_selftest.run_selftest`` so the one ``--selftest`` proves
302 this check the same way it proves the AST rules: the rule fires on the
303 broken fixture AND stays quiet on the correct one, because a rule can be
304 "fixed" by defanging it and no single-direction test can tell the difference.
305
306 Split by fixture family (:func:`_sf_new_marker`, :func:`_sf_legacy`,
307 :func:`_sf_non_uses`) so each stays within the 60-line NASA P10 Rule 4 cap.
308 """
309
310 def fires(name: str, src: str) -> bool:
311 return bool(_sf_names(scan_source(name, src)))
312
313 return _sf_new_marker(fires) + _sf_legacy(fires) + _sf_non_uses(fires)