ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_c23_headers.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_c23_headers.py -- enforce C23 typed enums and #pragma once headers.
5
6Two rules that CLAUDE.md mandates but that had, until now, no automated
7checker -- they were convention only, so drift was invisible until a human
8happened to read the file:
9
101. **C23 typed enums.** Every ``enum`` / ``typedef enum`` in first-party code
11 MUST specify an explicit underlying type: ``enum : <type> { ... }`` (for
12 example ``typedef enum : uint8_t { ... } name_t;``). A bare ``enum {`` or a
13 tagged ``enum Name {`` with no ``: <type>`` FAILS. A fixed underlying type
14 pins the enum's size for ABI stability and debugger compatibility -- the
15 reason CLAUDE.md's "Constants and Macros" section makes it mandatory.
16
172. **#pragma once headers.** First-party headers (``.h`` / ``.hpp``) MUST use
18 ``#pragma once`` rather than a classic ``#ifndef`` / ``#define`` /
19 ``#endif`` include guard.
20
21Detection notes:
22
23* Only enum *definitions* are flagged -- a definition is ``enum`` followed by
24 an optional tag and then ``{``. A *reference* (``enum xz_ret ret``, a
25 ``sizeof(enum foo)``, a forward ``enum foo;``) has some other token after the
26 tag and is deliberately left alone. C++ scoped enums (``enum class``) do not
27 occur in this C tree and are not special-cased.
28* The header-guard rule is expressed as a *positive* requirement: a header
29 passes iff it contains a real ``#pragma once`` directive. This is
30 deliberately simpler and far less false-positive-prone than trying to
31 pattern-match an ``#ifndef`` / ``#define`` / ``#endif`` *triple*: an
32 ``#ifndef`` on its own is an ordinary feature-test (``#ifndef __cplusplus``)
33 and must not be mistaken for an include guard. Requiring ``#pragma once``
34 catches every guarded header (it lacks the pragma) without ever having to
35 decide whether a given ``#ifndef`` is a guard.
36* Comments and string / character literals are blanked before scanning, so an
37 ``enum`` or an ``#ifndef`` written inside a comment or a string is never
38 flagged, and a ``#pragma once`` mentioned inside a comment does not satisfy
39 the rule.
40
41Per-line / per-file opt-out: append ``C23HDR-OK: <reason>`` to waive a finding
42(mirrors the ``MAGIC-OK`` / ``CITES-OK`` / ``AI-OK`` markers the sibling gates
43use). For the enum rule the marker must sit on the offending ``enum`` line; for
44the header rule it may sit anywhere in the file. The one standing use is
45``port/mbedtls/inc/tf_psa_crypto_config.h``, whose ``#ifndef
46PSA_CRYPTO_CONFIG_H`` guard is not an ordinary include guard at all but a
47vendor-ABI sentinel: the vendored Mbed TLS SOUP tests ``#if
48defined(PSA_CRYPTO_CONFIG_H)`` to confirm the crypto config file was supplied,
49so the guard macro must keep being defined.
50
51Vendored trees (``libs/third_party/``), generated tables (``libs/ra8_fonts/``) and
52build output are skipped wholesale.
53
54Usage:
55 python3 scripts/checks/check_c23_headers.py # staged files
56 python3 scripts/checks/check_c23_headers.py FILE [FILE ...] # explicit list
57 python3 scripts/checks/check_c23_headers.py --all # all tracked
58 python3 scripts/checks/check_c23_headers.py --selftest # self-check
59
60Returns 0 on clean, 1 on findings, 2 on usage / enumeration error.
61"""
62
63from __future__ import annotations
64
65import argparse
66import re
67import subprocess
68import sys
69from pathlib import Path
70
71sys.path.insert(0, str(Path(__file__).resolve().parent))
72
73from lint_targets import is_build_output_path
74
75REPO_ROOT = Path(__file__).resolve().parents[2]
76
77# Path fragments that drop a file from the scan. Vendored SOUP and generated
78# font tables are exempt tree-wide per CLAUDE.md; build output is handled by
79# ``is_build_output_path``. Matches the sibling gates' EXCLUDE_FRAGMENTS.
80EXCLUDE_FRAGMENTS = (
81 "libs/third_party/",
82 "apps/shared_libs/third_party/",
83 "libs/ra8_fonts/",
84)
85
86# The typed-enum rule applies to every first-party C/C++ translation unit and
87# header; the pragma-once rule applies only to headers.
88ENUM_EXTENSIONS = frozenset({".c", ".h", ".cpp", ".hpp"})
89HEADER_EXTENSIONS = frozenset({".h", ".hpp"})
90
91# Per-line / per-file opt-out marker (written reason required by convention).
92OPT_OUT = "C23HDR-OK"
93
94# An enum introduced by the keyword ``enum``, then an OPTIONAL tag identifier,
95# then the first following non-space token. That token disambiguates:
96# ``{`` -> this is a DEFINITION body, and (having no ``:`` before it) it is
97# UNTYPED -> a violation.
98# ``:`` -> a fixed underlying type follows -> typed, compliant.
99# else -> a reference / use / forward declaration -> not our concern.
100# DOTALL lets the leading ``\s*`` swallow newlines so a ``typedef enum`` whose
101# opening ``{`` sits on the next line is still recognised as one definition.
102_ENUM_RE = re.compile(
103 r"\benum\b\s*(?:[A-Za-z_]\w*\s*)?(?P<next>.)",
104 re.DOTALL,
105)
106
107# A real ``#pragma once`` directive (in code, after comment/string blanking).
108_PRAGMA_ONCE_RE = re.compile(r"^\s*#\s*pragma\s+once\b", re.MULTILINE)
109
110
111def _strip_comments_and_strings(text: str) -> str:
112 """Blank comment and literal bytes to spaces, preserving every position.
113
114 Newlines survive so line numbers computed against the blanked view remain
115 valid for the original source. A naive char-by-char state machine is used
116 rather than a regex because nested-looking quote / comment interactions
117 make a single regex fragile.
118
119 Args:
120 text: The original source text of one file.
121
122 Returns:
123 The same text with the interior of every ``//`` / ``/* */`` comment and
124 every string / char literal replaced by spaces (newlines kept).
125 """
126 out: list[str] = []
127 i = 0
128 n = len(text)
129 while i < n:
130 c = text[i]
131 nxt = text[i + 1] if i + 1 < n else ""
132 if c == "/" and nxt == "/":
133 while i < n and text[i] != "\n":
134 i += 1
135 continue
136 if c == "/" and nxt == "*":
137 out.append(" ")
138 i += 2
139 while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
140 out.append("\n" if text[i] == "\n" else " ")
141 i += 1
142 if i < n:
143 out.append(" ")
144 i += 2
145 continue
146 if c in {'"', "'"}:
147 quote = c
148 out.append(" ")
149 i += 1
150 while i < n and text[i] != quote:
151 if text[i] == "\\" and i + 1 < n:
152 out.append(" ")
153 i += 2
154 continue
155 out.append("\n" if text[i] == "\n" else " ")
156 i += 1
157 if i < n:
158 out.append(" ")
159 i += 1
160 continue
161 out.append(c)
162 i += 1
163 return "".join(out)
164
165
166def _line_of(text: str, offset: int) -> int:
167 """Return the 1-based line number of a character offset in ``text``.
168
169 Args:
170 text: The text the offset indexes into.
171 offset: A 0-based character offset.
172
173 Returns:
174 The 1-based line number containing ``offset``.
175 """
176 return text.count("\n", 0, offset) + 1
177
178
179def enum_violations(text: str) -> list[tuple[int, str]]:
180 """Find every untyped ``enum`` definition in one file's text.
181
182 Only enum definitions (``enum [tag] {``) are considered; references and
183 forward declarations are skipped. A definition whose ``{`` is preceded by a
184 ``: <type>`` clause is compliant and is not reported.
185
186 Args:
187 text: The original source text of one file.
188
189 Returns:
190 A list of ``(line_no, snippet)`` for each untyped enum definition, with
191 opt-out (``C23HDR-OK``) lines already removed.
192 """
193 stripped = _strip_comments_and_strings(text)
194 orig_lines = text.splitlines()
195 violations: list[tuple[int, str]] = []
196 for match in _ENUM_RE.finditer(stripped):
197 if match.group("next") != "{":
198 continue # ``:`` is typed; any other token is a reference/use
199 line_no = _line_of(stripped, match.start())
200 raw = orig_lines[line_no - 1] if line_no - 1 < len(orig_lines) else ""
201 if OPT_OUT in raw:
202 continue
203 violations.append((line_no, raw.strip()))
204 return violations
205
206
207def header_lacks_pragma_once(text: str) -> bool:
208 """Return whether a header's text is missing a real ``#pragma once``.
209
210 The check runs against the comment/string-blanked view so a ``#pragma
211 once`` written inside a comment does not count. A file carrying the
212 ``C23HDR-OK`` opt-out anywhere is treated as compliant.
213
214 Args:
215 text: The original source text of a header file.
216
217 Returns:
218 True when the header has no ``#pragma once`` directive and is not
219 waived; False otherwise.
220 """
221 if OPT_OUT in text:
222 return False
223 stripped = _strip_comments_and_strings(text)
224 return _PRAGMA_ONCE_RE.search(stripped) is None
225
226
227def find_violations(path: Path) -> list[tuple[int, str]]:
228 """Report every C23 typed-enum / pragma-once violation in one file.
229
230 The enum rule is applied to any in-scope C/C++ file; the pragma-once rule
231 is applied only to headers.
232
233 Args:
234 path: The file to scan.
235
236 Returns:
237 A list of ``(line_no, message)`` findings; an unreadable file yields an
238 empty list rather than raising. A header missing ``#pragma once`` is
239 reported at line 1.
240 """
241 try:
242 text = path.read_text(encoding="utf-8", errors="replace")
243 except OSError:
244 return []
245 findings: list[tuple[int, str]] = []
246 if path.suffix.lower() in ENUM_EXTENSIONS:
247 for line_no, snippet in enum_violations(text):
248 findings.append(
249 (line_no, f"untyped enum -- add `: <type>` (e.g. `enum : uint8_t`): {snippet}")
250 )
251 if path.suffix.lower() in HEADER_EXTENSIONS and header_lacks_pragma_once(text):
252 findings.append((1, "header has no `#pragma once` (classic include guards are banned)"))
253 findings.sort()
254 return findings
255
256
257def _is_excluded(rel: str) -> bool:
258 """Return whether a repo-relative path is outside the scan scope.
259
260 Args:
261 rel: A repo-relative path string.
262
263 Returns:
264 True for vendored / generated / build-output paths, False otherwise.
265 """
266 return is_build_output_path(rel) or any(frag in rel for frag in EXCLUDE_FRAGMENTS)
267
268
269def _in_scope(path: Path) -> bool:
270 """Return whether a path is a first-party file this gate scans.
271
272 Args:
273 path: The candidate file path (absolute or repo-relative).
274
275 Returns:
276 True when the suffix is one the gate handles and the path is not
277 excluded.
278 """
279 if path.suffix.lower() not in ENUM_EXTENSIONS:
280 return False
281 rel = str(path.relative_to(REPO_ROOT)) if path.is_relative_to(REPO_ROOT) else str(path)
282 return not _is_excluded(rel)
283
284
285def _git_lines(args: list[str]) -> list[str]:
286 """Run a git command under the repo root and return its stdout lines.
287
288 Args:
289 args: The git sub-command and its arguments (without the ``git`` prefix).
290
291 Returns:
292 The non-empty stdout lines. Exits with status 2 if git fails.
293 """
294 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool
295 ["git", *args], # noqa: S607 -- git is a fixed dev-tool name
296 cwd=REPO_ROOT,
297 capture_output=True,
298 text=True,
299 check=False,
300 )
301 if proc.returncode != 0:
302 sys.stderr.write(proc.stderr)
303 sys.stderr.write(f"check_c23_headers.py: FATAL -- `git {args[0]}` failed\n")
304 sys.exit(2)
305 return [ln for ln in proc.stdout.splitlines() if ln]
306
307
308def staged_files() -> list[Path]:
309 """Return the in-scope files staged for the in-progress commit.
310
311 Returns:
312 Absolute paths of added/copied/modified/renamed staged files that are
313 in scope and still present on disk.
314 """
315 names = _git_lines(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
316 out: list[Path] = []
317 for name in names:
318 path = REPO_ROOT / name
319 if path.is_file() and _in_scope(path):
320 out.append(path)
321 return out
322
323
324def all_files() -> list[Path]:
325 """Return every tracked first-party file in scope, for the ``--all`` sweep.
326
327 Returns:
328 Absolute paths of tracked ``.c`` / ``.h`` / ``.cpp`` / ``.hpp`` files
329 that are not vendored, generated, or build output.
330 """
331 names = _git_lines(["ls-files", "--", "*.c", "*.h", "*.cpp", "*.hpp"])
332 return [REPO_ROOT / name for name in names if _in_scope(REPO_ROOT / name)]
333
334
335def _enum_cases() -> list[tuple[bool, str]]:
336 """Return the enum-rule self-check cases as ``(failed, message)`` pairs.
337
338 Each case maps a fragment through :func:`enum_violations` and records
339 whether the observed behaviour differs from the specified behaviour: the
340 untyped/tagged-untyped definitions must fire; typed forms, references and
341 commented-out enums must stay silent; the opt-out must be honoured.
342
343 Returns:
344 One ``(failed, message)`` tuple per assertion; ``failed`` is True when
345 the detector behaved wrongly.
346 """
347
348 def fires(fragment: str) -> bool:
349 return bool(enum_violations(fragment))
350
351 return [
352 (not fires("typedef enum { k_a = 0 } foo_t;"), "untyped `typedef enum {` not flagged"),
353 (not fires("enum bearer { k_a = 1 };"), "untyped tagged `enum bearer {` not flagged"),
354 (fires("typedef enum : uint8_t { k_a = 0 } foo_t;"), "typed `enum : uint8_t {` flagged"),
355 (fires("enum bearer : uint16_t { k_a = 1 };"), "typed tagged `enum ... :` flagged"),
356 (fires("static int map(enum xz_ret ret) { return ret; }"), "enum parameter ref flagged"),
357 (fires("size_t n = sizeof(enum foo);"), "enum sizeof reference flagged"),
358 (fires("/* typedef enum { k_a } t; */"), "untyped enum in a comment flagged"),
359 (not fires("enum { k_x = 1 }; /* real */"), "untyped enum w/ trailing comment missed"),
360 (fires("enum { k_x = 1 }; /* C23HDR-OK: reason */"), "enum opt-out not honoured"),
361 ]
362
363
364def _header_cases() -> list[tuple[bool, str]]:
365 """Return the header-rule self-check cases as ``(failed, message)`` pairs.
366
367 An ``#ifndef`` include-guarded header must fire; a ``#pragma once`` header
368 must not; a ``#pragma once`` buried in a comment must not satisfy the rule;
369 the opt-out must be honoured.
370
371 Returns:
372 One ``(failed, message)`` tuple per assertion; ``failed`` is True when
373 the detector behaved wrongly.
374 """
375 guarded = "#ifndef FOO_H\n#define FOO_H\nint foo(void);\n#endif\n"
376 lacks = header_lacks_pragma_once
377 return [
378 (not lacks(guarded), "`#ifndef` include-guarded header not flagged"),
379 (lacks("#pragma once\nint foo(void);\n"), "`#pragma once` header wrongly flagged"),
380 (not lacks("/* #pragma once */\nint foo(void);\n"), "commented `#pragma once` accepted"),
381 (lacks(guarded + "/* C23HDR-OK: reason */\n"), "header opt-out not honoured"),
382 ]
383
384
385def _selftest() -> int:
386 """Assert both rules fire on the real defect and stay quiet on legal forms.
387
388 Returns:
389 0 when every direction of both rules behaves as specified, 1 otherwise.
390 """
391 print("check_c23_headers.py --selftest")
392 failures = [msg for failed, msg in _enum_cases() + _header_cases() if failed]
393 if failures:
394 for msg in failures:
395 print(f" FAIL: {msg}", file=sys.stderr)
396 print(f"check_c23_headers.py --selftest: {len(failures)} failure(s)", file=sys.stderr)
397 return 1
398 print("check_c23_headers.py --selftest: OK")
399 return 0
400
401
402def _report(candidates: list[Path]) -> int:
403 """Scan ``candidates`` and print any findings.
404
405 Args:
406 candidates: The in-scope files to scan.
407
408 Returns:
409 1 if any violation was found, 0 otherwise.
410 """
411 total = 0
412 for path in candidates:
413 rel = path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path
414 for line, message in find_violations(path):
415 print(f"{rel}:{line}: {message}", file=sys.stderr)
416 total += 1
417 if total:
418 print(
419 f"\n{total} C23 header/enum violation(s). Every enum needs an explicit "
420 "underlying type (`enum : uint8_t { ... }`) and every header must use "
421 f"`#pragma once`. A genuine exception carries a `{OPT_OUT}: <reason>` "
422 "marker.",
423 file=sys.stderr,
424 )
425 return 1
426 print(f"check_c23_headers.py: {len(candidates)} file(s) scanned, 0 findings.")
427 return 0
428
429
430def main(argv: list[str]) -> int:
431 """Enforce C23 typed-enum underlying types and pragma-once headers.
432
433 ``--selftest`` proves the detector in both directions; ``--all`` sweeps the
434 tree; an explicit file list scans just those files; otherwise the staged
435 set is scanned (how the pre-commit hook stays fast).
436
437 Args:
438 argv: The full process argument vector (``sys.argv``).
439
440 Returns:
441 0 clean, 1 on findings, 2 on usage / enumeration error.
442 """
443 parser = argparse.ArgumentParser(description="C23 typed-enum + pragma-once checker")
444 parser.add_argument("--all", action="store_true", help="scan all tracked files")
445 parser.add_argument("--selftest", action="store_true", help="assert both rules, both ways")
446 parser.add_argument("files", nargs="*", type=Path, help="explicit file list (e.g. staged)")
447 args = parser.parse_args(argv[1:])
448
449 if args.selftest:
450 return _selftest()
451 if args.all:
452 candidates = all_files()
453 elif args.files:
454 candidates = [p if p.is_absolute() else REPO_ROOT / p for p in args.files]
455 candidates = [p for p in candidates if p.is_file() and _in_scope(p)]
456 else:
457 candidates = staged_files()
458
459 return _report(candidates)
460
461
462if __name__ == "__main__":
463 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298