ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_goto_setjmp.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""check_no_goto_setjmp.py -- NASA Power-of-10 Rule 1 textual backstop.
5
6NASA/JPL Power-of-10 Rule 1 forbids unstructured control flow: no ``goto``,
7no ``setjmp`` / ``longjmp``, and no recursion. Three of those four constructs
8-- ``goto``, ``setjmp``, ``longjmp`` -- are single keywords / library calls
9that a textual scan can find reliably, so this gate closes them off with a
10parser-independent sweep of first-party C/C++.
11
12Why a dedicated gate. Until now ``goto`` / ``setjmp`` were enforced only
13indirectly, via the MISRA cppcheck ratchet (Rule 15.1 forbids ``goto``,
14Rule 21.4 forbids ``<setjmp.h>``). That ratchet runs cppcheck at
15``--std=c11`` because the pinned cppcheck (2.13) cannot parse C23 -- the
16codebase's ``enum : uint8_t`` typed enums and ``[[...]]`` attributes raise
17``syntaxError`` and the affected translation units are only partially parsed
18(see ``scripts/checks/misra_check_inner.sh`` and ADR-0002). A construct on a
19line cppcheck skipped is a construct the ratchet never rules on. A textual
20scan does not depend on a parse, so it covers the whole tree uniformly and
21closes that blind spot for these three tokens.
22
23Recursion is deliberately OUT of scope here: detecting it needs a call graph,
24which is covered separately by ``scripts/checks/annot_rules.py``
25(``RA8_NO_RECURSION``) and MISRA Rule 17.2.
26
27The three tokens are matched only in CODE positions. Occurrences inside
28comments and string / character literals are prose, not control flow, and are
29never flagged -- the tree has a ``goto`` inside a comment in
30``libs/ra8_hal/src/ra8_flash_config.c`` and ``setjmp`` / ``longjmp`` named in
31the prose of ``libs/ra8_core/inc/ra8_err.h`` and
32``libs/ra8_core/src/ra8_exception.c``, none of which is a violation.
33
34Scope is the firmware and host-tool tree -- ``libs/``, ``src/``,
35``examples/``, ``port/``, ``tools/``. ``tests/`` is deliberately out of
36scope: the host unit-test harness legitimately uses ``setjmp`` / ``longjmp``
37to trap the firmware's ``[[noreturn]]`` fatal paths under test, which is test
38scaffolding rather than firmware control flow (the same reason ``tests/`` is
39exempt from MC/DC re-test and the magic-number gate). Vendored trees under
40``libs/third_party/``, generated data under ``libs/ra8_fonts/``, and build output
41are skipped wholesale.
42
43Usage::
44
45 check_no_goto_setjmp.py # scan staged files (pre-commit)
46 check_no_goto_setjmp.py FILE [FILE ...] # scan an explicit file list
47 check_no_goto_setjmp.py --all # scan every tracked source file
48 check_no_goto_setjmp.py --selftest # self-check the detector
49
50Returns 0 on clean, 1 on findings, 2 on usage error.
51"""
52
53from __future__ import annotations
54
55import argparse
56import pathlib
57import re
58import subprocess
59import sys
60from collections.abc import Iterable
61
62sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
63
64from lint_targets import is_build_output_path
65
66REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
67
68# First-party roots this gate is responsible for: the firmware libraries, the
69# shared internals, the applications, the ThreadX port glue, and the host
70# tools. Vendored / generated trees are subtracted below; a build directory
71# under any of them is subtracted by is_build_output_path (the shared #377
72# definition).
73#
74# `tests/` is deliberately NOT a root. NASA Power-of-10 Rule 1 governs the
75# firmware; the host unit-test harness under tests/ legitimately uses
76# setjmp/longjmp to trap the firmware's [[noreturn]] fatal paths
77# (internal_ra8_fatal_error and friends) so a test can observe an abort without
78# aborting the test process. That is test scaffolding, not firmware control
79# flow -- the same reason tests/ is exempt from MC/DC re-test and the
80# magic-number gate, and why sibling check_no_null.py scopes to
81# libs/port/examples only.
82ROOT_DIRS = ("libs", "examples", "port", "tools", "apps")
83
84# Path fragments that exclude a file from the scan. Vendored SOUP and generated
85# font tables are exempt -- their control flow is the upstream maintainer's
86# call, and CLAUDE.md already names both trees. Matches the sibling gates'
87# EXCLUDE_FRAGMENTS.
88EXCLUDE_FRAGMENTS = (
89 "libs/third_party/",
90 "apps/shared_libs/third_party/",
91 "libs/ra8_fonts/",
92)
93
94EXTENSIONS = {".c", ".h", ".cpp", ".hpp"}
95
96# The banned control-flow tokens. \b anchors each to a full identifier so a
97# name that merely CONTAINS one is left alone: `goto_label` (trailing word
98# char), `sigsetjmp` / `_setjmp` (leading word char), and `longjmp_buf` are not
99# matches. The three tokens are matched only after comments and string / char
100# literals are blanked, so this fires in code positions exclusively.
101BANNED_RE = re.compile(r"\b(goto|setjmp|longjmp)\b")
102
103
104# Strip C/C++ inline comments and string / char literals from a single line so
105# a banned token inside one is not matched. Naive but adequate; the multi-line
106# block-comment state is threaded by the caller. Mirrors check_no_null.py's
107# helper of the same purpose.
108def _strip_noncode(line: str) -> str: # noqa: PLR0912 # char-by-char state machine, splitting hurts readability
109 out: list[str] = []
110 i = 0
111 in_str = False
112 in_chr = False
113 in_bc = False
114 n = len(line)
115 while i < n:
116 c = line[i]
117 nxt = line[i + 1] if i + 1 < n else ""
118 if in_bc:
119 if c == "*" and nxt == "/":
120 in_bc = False
121 i += 2
122 continue
123 i += 1
124 continue
125 if in_str:
126 if c == "\\" and i + 1 < n:
127 i += 2
128 continue
129 if c == '"':
130 in_str = False
131 i += 1
132 continue
133 if in_chr:
134 if c == "\\" and i + 1 < n:
135 i += 2
136 continue
137 if c == "'":
138 in_chr = False
139 i += 1
140 continue
141 if c == "/" and nxt == "/":
142 break
143 if c == "/" and nxt == "*":
144 in_bc = True
145 i += 2
146 continue
147 if c == '"':
148 in_str = True
149 i += 1
150 continue
151 if c == "'":
152 in_chr = True
153 i += 1
154 continue
155 out.append(c)
156 i += 1
157 return "".join(out)
158
159
160def scan_text(text: str) -> list[tuple[int, str, str]]:
161 """Report every code-position ``goto`` / ``setjmp`` / ``longjmp`` in ``text``.
162
163 Comments (both ``//`` and multi-line ``/* ... */``) and string / character
164 literals are blanked before the token match, so a banned word appearing in
165 prose is not reported -- that is the whole point of the gate, since the tree
166 legitimately names these tokens in comments.
167
168 Args:
169 text: The full source text of one translation unit.
170
171 Returns:
172 One ``(line_no, token, line_text)`` tuple per finding, in file order.
173 """
174 findings: list[tuple[int, str, str]] = []
175 in_block_comment = False
176 for n, raw in enumerate(text.splitlines(), 1):
177 cur = raw
178 # Continue an open /* ... */ from a previous line.
179 if in_block_comment:
180 end = cur.find("*/")
181 if end == -1:
182 continue
183 cur = cur[end + 2 :]
184 in_block_comment = False
185 # A /* on this line that does not close opens a block comment; keep the
186 # code before it and swallow the rest.
187 bo = cur.find("/*")
188 if bo != -1 and cur.find("*/", bo + 2) == -1:
189 cur = cur[:bo]
190 in_block_comment = True
191 code = _strip_noncode(cur)
192 findings.extend((n, m.group(1), raw.strip()) for m in BANNED_RE.finditer(code))
193 return findings
194
195
196def find_violations(path: pathlib.Path) -> list[tuple[int, str, str]]:
197 """Report every banned control-flow token in one file.
198
199 An unreadable file yields an empty list rather than raising, matching the
200 sibling gates: a file that cannot be read is not evidence of a violation.
201
202 Args:
203 path: The source file to scan.
204
205 Returns:
206 One ``(line_no, token, line_text)`` tuple per finding.
207 """
208 try:
209 text = path.read_text(encoding="utf-8", errors="replace")
210 except OSError:
211 return []
212 return scan_text(text)
213
214
215def needs_check(path: pathlib.Path) -> bool:
216 """Whether ``path`` is a first-party C/C++ file subject to this gate.
217
218 Args:
219 path: Candidate path, absolute or repo-relative.
220
221 Returns:
222 True when the suffix is C/C++, the path is under a first-party root,
223 and it is neither vendored / generated nor build output.
224 """
225 if path.suffix.lower() not in EXTENSIONS:
226 return False
227 text = str(path).replace("\\", "/")
228 if is_build_output_path(text):
229 return False
230 if any(frag in text for frag in EXCLUDE_FRAGMENTS):
231 return False
232 rel = path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path
233 return rel.parts[0] in ROOT_DIRS if rel.parts else False
234
235
236def _git_lines(args: list[str]) -> list[str]:
237 """Run a ``git`` command under the repo root and return its stdout lines.
238
239 Args:
240 args: Argument vector following ``git`` (e.g. ``["ls-files"]``).
241
242 Returns:
243 Non-empty stripped stdout lines; empty on any git failure.
244 """
245 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool
246 ["git", *args], # noqa: S607 -- trusted: fixed git argv
247 cwd=REPO_ROOT,
248 capture_output=True,
249 text=True,
250 check=False,
251 )
252 if proc.returncode != 0:
253 sys.stderr.write(proc.stderr)
254 return []
255 return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
256
257
258def iter_tracked_files() -> Iterable[pathlib.Path]:
259 """Yield every checkable tracked file, for the ``--all`` sweep.
260
261 Enumerating via ``git ls-files`` keeps gitignored build artifacts out and
262 picks up a new first-party root the day it is added -- there is no allowlist
263 to forget.
264
265 Returns:
266 An iterable of absolute paths that pass ``needs_check``.
267 """
268 for rel in _git_lines(["ls-files", "--cached", "--others", "--exclude-standard"]):
269 path = REPO_ROOT / rel
270 if needs_check(path):
271 yield path
272
273
274def iter_staged_files() -> Iterable[pathlib.Path]:
275 """Yield every checkable staged file, for the default pre-commit sweep.
276
277 Returns:
278 An iterable of absolute paths of added/copied/modified/renamed staged
279 files that pass ``needs_check``.
280 """
281 for rel in _git_lines(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]):
282 path = REPO_ROOT / rel
283 if needs_check(path):
284 yield path
285
286
287# ---------------------------------------------------------------------------
288# Selftest
289#
290# Both directions are asserted against in-memory fixtures fed through the same
291# scan_text() the gate uses: a real goto / setjmp / longjmp in code MUST fire,
292# and a token that appears only inside a comment or string literal -- plus
293# correct code that avoids all three -- MUST stay silent. A detector that
294# quietly stopped matching would report a clean tree, which is worse than no
295# gate at all.
296# ---------------------------------------------------------------------------
297
298_BAD_FIXTURE = """\
299void f(int *buf)
300{
301 goto done;
302done:
303 setjmp(buf);
304 longjmp(buf, 1);
305}
306"""
307
308_GOOD_FIXTURE = """\
309/* This routine once used a goto done; jump -- rewritten as a loop. */
310// setjmp / longjmp are banned by NASA Power-of-10 Rule 1.
311static const char *s_note = "no goto, setjmp, or longjmp here";
312
313void f(int *buf)
314{
315 (void)buf;
316 for (int goto_count = 0; goto_count < 4; ++goto_count) {
317 continue;
318 }
319}
320"""
321
322
323def selftest() -> int:
324 """Prove the detector fires on real violations and is silent on prose.
325
326 Returns:
327 0 when both directions hold, 1 when either fails.
328 """
329 failures: list[str] = []
330
331 fired = {tok for _, tok, _ in scan_text(_BAD_FIXTURE)}
332 failures.extend(
333 f" must-fire: bad fixture did not report `{token}`"
334 for token in ("goto", "setjmp", "longjmp")
335 if token not in fired
336 )
337
338 quiet = scan_text(_GOOD_FIXTURE)
339 failures.extend(
340 f" must-stay-quiet: good fixture reported `{tok}` at line {ln}" for ln, tok, _ in quiet
341 )
342
343 if failures:
344 sys.stderr.write("check_no_goto_setjmp.py --selftest: FAILED\n")
345 sys.stderr.write("\n".join(failures) + "\n")
346 return 1
347
348 print("check_no_goto_setjmp.py --selftest: OK (fires on code, silent on comments/strings).")
349 return 0
350
351
352def main() -> int:
353 """Fail on any code-position ``goto`` / ``setjmp`` / ``longjmp``.
354
355 With no arguments the staged files are scanned, which is how the pre-commit
356 hook stays fast; ``--all`` sweeps every tracked source file; an explicit
357 file list scans exactly those. ``--selftest`` self-checks the detector.
358
359 Returns:
360 1 listing each finding, 0 when clean, 2 on a usage error.
361 """
362 parser = argparse.ArgumentParser(description=__doc__)
363 parser.add_argument("--all", action="store_true", help="scan all tracked source files")
364 parser.add_argument("--selftest", action="store_true", help="self-check the detector and exit")
365 parser.add_argument(
366 "files", nargs="*", type=pathlib.Path, help="explicit file list (e.g. staged files)"
367 )
368 args = parser.parse_args()
369
370 if args.selftest:
371 return selftest()
372
373 if args.all:
374 candidates = list(iter_tracked_files())
375 elif args.files:
376 candidates = [p for p in args.files if needs_check(p)]
377 else:
378 candidates = list(iter_staged_files())
379
380 total = 0
381 for path in candidates:
382 rel = path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path
383 for line, token, snippet in find_violations(path):
384 print(
385 f"{rel}:{line}: `{token}` is banned "
386 f"(NASA Power-of-10 Rule 1: no goto/setjmp/longjmp): {snippet}",
387 file=sys.stderr,
388 )
389 total += 1
390
391 if total:
392 print(
393 f"\n{total} banned control-flow token(s) found. NASA Power-of-10 "
394 "Rule 1 forbids goto, setjmp, and longjmp. Restructure the control "
395 "flow; the tokens are allowed only inside comments and string "
396 "literals.",
397 file=sys.stderr,
398 )
399 return 1
400 print(f"check_no_goto_setjmp.py: {len(candidates)} file(s) scanned, 0 findings.")
401 return 0
402
403
404if __name__ == "__main__":
405 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298