4"""check_c23_headers.py -- enforce C23 typed enums and #pragma once headers.
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:
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.
172. **#pragma once headers.** First-party headers (``.h`` / ``.hpp``) MUST use
18 ``#pragma once`` rather than a classic ``#ifndef`` / ``#define`` /
19 ``#endif`` include guard.
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
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.
51Vendored trees (``libs/third_party/``), generated tables (``libs/ra8_fonts/``) and
52build output are skipped wholesale.
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
60Returns 0 on clean, 1 on findings, 2 on usage / enumeration error.
63from __future__
import annotations
69from pathlib
import Path
71sys.path.insert(0, str(Path(__file__).resolve().parent))
73from lint_targets
import is_build_output_path
75REPO_ROOT = Path(__file__).resolve().parents[2]
82 "apps/shared_libs/third_party/",
88ENUM_EXTENSIONS = frozenset({
".c",
".h",
".cpp",
".hpp"})
89HEADER_EXTENSIONS = frozenset({
".h",
".hpp"})
102_ENUM_RE = re.compile(
103 r"\benum\b\s*(?:[A-Za-z_]\w*\s*)?(?P<next>.)",
108_PRAGMA_ONCE_RE = re.compile(
r"^\s*#\s*pragma\s+once\b", re.MULTILINE)
111def _strip_comments_and_strings(text: str) -> str:
112 """Blank comment and literal bytes to spaces, preserving every position.
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.
120 text: The original source text of one file.
123 The same text with the interior of every ``//`` / ``/* */`` comment and
124 every string / char literal replaced by spaces (newlines kept).
131 nxt = text[i + 1]
if i + 1 < n
else ""
132 if c ==
"/" and nxt ==
"/":
133 while i < n
and text[i] !=
"\n":
136 if c ==
"/" and nxt ==
"*":
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 " ")
150 while i < n
and text[i] != quote:
151 if text[i] ==
"\\" and i + 1 < n:
155 out.append(
"\n" if text[i] ==
"\n" else " ")
166def _line_of(text: str, offset: int) -> int:
167 """Return the 1-based line number of a character offset in ``text``.
170 text: The text the offset indexes into.
171 offset: A 0-based character offset.
174 The 1-based line number containing ``offset``.
176 return text.count(
"\n", 0, offset) + 1
179def enum_violations(text: str) -> list[tuple[int, str]]:
180 """Find every untyped ``enum`` definition in one file's text.
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.
187 text: The original source text of one file.
190 A list of ``(line_no, snippet)`` for each untyped enum definition, with
191 opt-out (``C23HDR-OK``) lines already removed.
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") !=
"{":
199 line_no = _line_of(stripped, match.start())
200 raw = orig_lines[line_no - 1]
if line_no - 1 < len(orig_lines)
else ""
203 violations.append((line_no, raw.strip()))
207def header_lacks_pragma_once(text: str) -> bool:
208 """Return whether a header's text is missing a real ``#pragma once``.
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.
215 text: The original source text of a header file.
218 True when the header has no ``#pragma once`` directive and is not
219 waived; False otherwise.
223 stripped = _strip_comments_and_strings(text)
224 return _PRAGMA_ONCE_RE.search(stripped)
is None
227def find_violations(path: Path) -> list[tuple[int, str]]:
228 """Report every C23 typed-enum / pragma-once violation in one file.
230 The enum rule is applied to any in-scope C/C++ file; the pragma-once rule
231 is applied only to headers.
234 path: The file to scan.
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
242 text = path.read_text(encoding=
"utf-8", errors=
"replace")
245 findings: list[tuple[int, str]] = []
246 if path.suffix.lower()
in ENUM_EXTENSIONS:
247 for line_no, snippet
in enum_violations(text):
249 (line_no, f
"untyped enum -- add `: <type>` (e.g. `enum : uint8_t`): {snippet}")
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)"))
257def _is_excluded(rel: str) -> bool:
258 """Return whether a repo-relative path is outside the scan scope.
261 rel: A repo-relative path string.
264 True for vendored / generated / build-output paths, False otherwise.
266 return is_build_output_path(rel)
or any(frag
in rel
for frag
in EXCLUDE_FRAGMENTS)
269def _in_scope(path: Path) -> bool:
270 """Return whether a path is a first-party file this gate scans.
273 path: The candidate file path (absolute or repo-relative).
276 True when the suffix is one the gate handles and the path is not
279 if path.suffix.lower()
not in ENUM_EXTENSIONS:
281 rel = str(path.relative_to(REPO_ROOT))
if path.is_relative_to(REPO_ROOT)
else str(path)
282 return not _is_excluded(rel)
285def _git_lines(args: list[str]) -> list[str]:
286 """Run a git command under the repo root and return its stdout lines.
289 args: The git sub-command and its arguments (without the ``git`` prefix).
292 The non-empty stdout lines. Exits with status 2 if git fails.
294 proc = subprocess.run(
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")
305 return [ln
for ln
in proc.stdout.splitlines()
if ln]
308def staged_files() -> list[Path]:
309 """Return the in-scope files staged for the in-progress commit.
312 Absolute paths of added/copied/modified/renamed staged files that are
313 in scope and still present on disk.
315 names = _git_lines([
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR"])
318 path = REPO_ROOT / name
319 if path.is_file()
and _in_scope(path):
324def all_files() -> list[Path]:
325 """Return every tracked first-party file in scope, for the ``--all`` sweep.
328 Absolute paths of tracked ``.c`` / ``.h`` / ``.cpp`` / ``.hpp`` files
329 that are not vendored, generated, or build output.
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)]
335def _enum_cases() -> list[tuple[bool, str]]:
336 """Return the enum-rule self-check cases as ``(failed, message)`` pairs.
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.
344 One ``(failed, message)`` tuple per assertion; ``failed`` is True when
345 the detector behaved wrongly.
348 def fires(fragment: str) -> bool:
349 return bool(enum_violations(fragment))
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"),
364def _header_cases() -> list[tuple[bool, str]]:
365 """Return the header-rule self-check cases as ``(failed, message)`` pairs.
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.
372 One ``(failed, message)`` tuple per assertion; ``failed`` is True when
373 the detector behaved wrongly.
375 guarded =
"#ifndef FOO_H\n#define FOO_H\nint foo(void);\n#endif\n"
376 lacks = header_lacks_pragma_once
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"),
385def _selftest() -> int:
386 """Assert both rules fire on the real defect and stay quiet on legal forms.
389 0 when every direction of both rules behaves as specified, 1 otherwise.
391 print(
"check_c23_headers.py --selftest")
392 failures = [msg
for failed, msg
in _enum_cases() + _header_cases()
if failed]
395 print(f
" FAIL: {msg}", file=sys.stderr)
396 print(f
"check_c23_headers.py --selftest: {len(failures)} failure(s)", file=sys.stderr)
398 print(
"check_c23_headers.py --selftest: OK")
402def _report(candidates: list[Path]) -> int:
403 """Scan ``candidates`` and print any findings.
406 candidates: The in-scope files to scan.
409 1 if any violation was found, 0 otherwise.
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)
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>` "
426 print(f
"check_c23_headers.py: {len(candidates)} file(s) scanned, 0 findings.")
430def main(argv: list[str]) -> int:
431 """Enforce C23 typed-enum underlying types and pragma-once headers.
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).
438 argv: The full process argument vector (``sys.argv``).
441 0 clean, 1 on findings, 2 on usage / enumeration error.
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:])
452 candidates = all_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)]
457 candidates = staged_files()
459 return _report(candidates)
462if __name__ ==
"__main__":
463 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.