3"""Per-loop bound markers, paired to the loop they describe.
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.
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.
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:
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
42from __future__
import annotations
46from collections.abc
import Callable
48from annot_model
import Violation
49from annot_scope
import SCAN_DIRS, is_excluded, repo_root
53_LOOPBOUND_SUFFIXES = frozenset({
".c",
".cpp",
".h",
".hpp"})
56_RE_STATIC = re.compile(
r"\bRA8_LOOP_BOUND\s*\(")
59_RE_RUNTIME = re.compile(
r"\bRA8_LOOP_BOUND_RUNTIME\s*\(")
62_RE_LEGACY = re.compile(
r"\bRA8_BOUNDED_LOOP\s*\(")
65_RE_LOOP_START = re.compile(
r"^(for|while|do)\b")
68_RULE =
"ra8_loop_bound"
76_TOKEN_RE = re.compile(
79 r'|"(?:\\.|[^"\\\n])*"'
80 r"|'(?:\\.|[^'\\\n])*'"
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)
91def strip_code(text: str) -> list[str]:
92 """Return ``text``'s lines with comments and string/char literals blanked.
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.
100 m.group(0)
if len(m.group(0)) == 1
else _blanked(m.group(0))
101 for m
in _TOKEN_RE.finditer(text)
103 return rebuilt.split(
"\n")
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)):
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()))
119def scan_source(path: str, text: str) -> list[Violation]:
120 """Return every loop-bound violation in one file's source ``text``.
122 Pure: no filesystem, no libclang, so the selftest drives it directly.
124 code = strip_code(text)
125 out: list[Violation] = []
126 for idx, code_line
in enumerate(code):
127 stripped = code_line.strip()
129 if stripped.startswith(
"#"):
132 nxt = _next_code_index(code, idx)
133 next_is_loop = nxt
is not None and _is_loop(code[nxt])
135 if _RE_RUNTIME.search(code_line)
or _RE_STATIC.search(code_line):
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",
147 elif _RE_LEGACY.search(code_line)
and next_is_loop:
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",
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():
169 p
for p
in root.rglob(
"*")
if p.suffix
in _LOOPBOUND_SUFFIXES
and not is_excluded(p)
174def enforce_loop_bounds(files: list[pathlib.Path], *, require_nonempty: bool) -> list[Violation]:
175 """Scan ``files`` for loop-bound marker discipline.
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.
183 out: list[Violation] = []
185 if path.suffix
not in _LOOPBOUND_SUFFIXES:
188 text = path.read_text(errors=
"ignore")
192 out.extend(scan_source(str(path), text))
193 if require_nonempty
and scanned == 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",
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}
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] = []
215 "void f(void) {\n RA8_LOOP_BOUND(k_cap);\n for (int i = 0; i < 4; i++) { g(); }\n}\n"
217 if fires(
"good_static.c", good_static):
219 "loop-bound false positive: RA8_LOOP_BOUND directly above a for-loop "
220 "was reported as mis-attached"
224 " RA8_LOOP_BOUND_RUNTIME(g_end);\n"
225 " while (p < &g_end) { *p = 0; p++; }\n"
228 if fires(
"good_runtime.c", good_runtime):
230 "loop-bound false positive: RA8_LOOP_BOUND_RUNTIME directly above a "
231 "while-loop was reported as mis-attached"
235 " RA8_LOOP_BOUND(k_cap);\n"
237 " for (int i = 0; i < 4; i++) { g(); }\n"
240 if not fires(
"bad_detached.c", bad_detached):
242 "loop-bound went toothless: RA8_LOOP_BOUND with a statement between it "
243 "and the loop (mis-attached) was NOT reported"
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):
248 "loop-bound went toothless: RA8_LOOP_BOUND with no following loop "
249 "(a marker present with no loop) was NOT reported"
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] = []
258 "RA8_BOUNDED_LOOP(k_polls)\n"
259 "static int worker(int n)\n"
261 " for (int i = 0; i < n; i++) { g(); }\n"
265 if fires(
"legacy_decl.c", legacy_decl):
267 "loop-bound false positive: function-level RA8_BOUNDED_LOOP above a "
268 "declaration is the legitimate use and must not be reported"
271 "void f(void) {\n RA8_BOUNDED_LOOP(k_cap);\n for (int i = 0; i < 4; i++) { g(); }\n}\n"
273 if not fires(
"legacy_stmt.c", legacy_stmt):
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"
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] = []
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"
290 if fires(
"non_uses.c", non_uses):
292 "loop-bound false positive: a marker inside a #define / comment / "
293 "string literal was treated as a real use"
298def run_loopbound_selftest() -> list[str]:
299 """Assert both failure directions and both clean shapes. Returns failures.
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.
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.
310 def fires(name: str, src: str) -> bool:
311 return bool(_sf_names(scan_source(name, src)))
313 return _sf_new_marker(fires) + _sf_legacy(fires) + _sf_non_uses(fires)