4"""Gate: no magic numbers -- every integer literal belongs in a named enum.
6Floating-point constants (which C enums cannot hold) use a
7``const``. Macros are NOT an acceptable home for an integer constant
8(CLAUDE.md "Constants and Macros": enums always, macros only for code
9de-duplication, conditional compilation, or build-configuration flags).
11The project's `.clang-tidy` already configures
12``readability-magic-numbers``, but clang-tidy only checks files that are
13present in the build's ``compile_commands.json``. ``clang_tidy.sh`` runs
14against the host unit-test build, which drops every ARM-cross-compiled
15translation unit and -- crucially -- contains **no** example ``src/main.c``
16at all. The result was that every ``examples/<tier>/.../<app>/src/main.c``
17was invisible to the magic-number check (a bare ``ra8_delay_ms(500U)``
18sailed straight through both the pre-commit hook and CI).
20This checker is a backstop that walks the source text directly so the
21rule is enforced for **every** ``.c`` file under ``libs/``, ``port/``,
22and ``examples/`` regardless of which compile database it
23ended up in. It is the magic-number analogue of
24``check_function_size.py``.
26Detection rules (kept deliberately aligned with the ``.clang-tidy``
27``readability-magic-numbers`` configuration):
29* Numeric literals (decimal, ``0x`` hex, ``0b`` binary, octal, and
30 floating-point, with optional ``u``/``l``/``f`` suffixes) are flagged.
31* The ignored-value set matches ``.clang-tidy``:
32 integers ``0;1;2;3;4;6;8;16;32`` and floats ``0.0;1.0``.
33* Literals inside an ``enum`` body are the *allowed* home for a constant
34 and are never flagged.
35* Preprocessor directives (``#define``, ``#if`` ...) are skipped here --
36 but note that an object-like ``#define FOO 500`` is itself a policy
37 violation (integer constants must be enums); the pre-commit hook's
38 "bare numeric #define" gate is what flags that pattern.
39* Numbers embedded in identifiers (``uint8_t``, ``crc32``, ``s_buf2``)
40 are not literals and are never flagged.
41* Comments and string / character literals are stripped before scanning.
42* clang-tidy ``NOLINT`` suppressions are honoured: a
43 ``NOLINT`` / ``NOLINTNEXTLINE`` / ``NOLINTBEGIN`` .. ``NOLINTEND`` that
44 is bare or lists ``readability-magic-numbers`` (or the
45 ``cppcoreguidelines-avoid-magic-numbers`` alias) suppresses the same
46 lines a clang-tidy run would. A backstop for clang-tidy must respect
47 clang-tidy's own waiver mechanism -- the codebase uses it heavily to
48 exempt the crypto, JPEG-codec, and BLE-host translation units.
50Per-line opt-out: append ``MAGIC-OK: <reason>`` to a line to suppress
51it (mirrors the ``LEGACY-OK`` / ``WAVE-OK`` / ``AI-OK`` opt-outs used by
52the sibling gates). Use it sparingly and only with a written reason;
53prefer a scoped ``NOLINT`` where a whole block is genuinely exempt.
57 check_magic_numbers.py # scan the whole tree
58 check_magic_numbers.py path/to/file.c ... # scan listed files
60Exit 0 if no magic numbers remain, exit 1 (with a diagnostic table) if
64from __future__
import annotations
72from collections.abc
import Iterable
73from pathlib
import Path
75sys.path.insert(0, str(Path(__file__).resolve().parent))
77from lint_targets
import is_build_output_path
79REPO_ROOT = Path(__file__).resolve().parents[2]
93BOOT_BOILERPLATE = frozenset(
94 {
"vector_table.c",
"system_init.c",
"secure_exception.c",
"trustzone_init.c"}
105 "apps/shared_libs/third_party/",
113IGNORED_INT = {0, 1, 2, 3, 4, 6, 8, 16, 32}
117IGNORED_FLOAT = {0.0, 1.0}
128SOURCE_SUFFIXES = (
".c",
".h")
132_OPT_OUT_COMMENT_RE = re.compile(
r"(?P<comment>//|/\*+<?)\s*MAGIC-OK\s*:\s*(?P<reason>.*)$")
140 "readability-magic-numbers",
141 "cppcoreguidelines-avoid-magic-numbers",
144_NOLINT_BEGIN_RE = re.compile(
r"NOLINTBEGIN(?:\(([^)]*)\))?")
145_NOLINT_END_RE = re.compile(
r"NOLINTEND(?:\(([^)]*)\))?")
146_NOLINT_NEXT_RE = re.compile(
r"NOLINTNEXTLINE(?:\(([^)]*)\))?")
147_NOLINT_INLINE_RE = re.compile(
r"NOLINT(?!BEGIN|END|NEXTLINE)(?:\(([^)]*)\))?")
150def _nolint_applies(arg: str |
None) -> bool:
151 """Whether a NOLINT marker suppresses the magic-number check specifically.
153 A bare ``NOLINT`` with no argument list suppresses EVERY check, so it
154 counts here too -- reading it as "suppresses some other check" would let
155 a blanket suppression silently cover this one.
157 if arg
is None or arg.strip() ==
"":
159 return any(name
in arg
for name
in MAGIC_CHECK_NAMES)
167 (?P<hex>0[xX][0-9a-fA-F]+(?:[uUlL]*))
168 | (?P<bin>0[bB][01]+(?:[uUlL]*))
169 | (?P<flt>(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?[fF])
170 | (?P<dec_flt>(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?[lL]?)
171 | (?P<dec>\d+(?:[uUlL]*))
179_IDENT_BEFORE = re.compile(
r"[0-9A-Za-z_.]")
190_DATA_ROW_RE = re.compile(
r"^[\s{}\[\],0-9a-fA-FxXuUlL.+\-]*$")
196_DECL_HINT = re.compile(
197 r"\b(static|const|extern|volatile|register|struct|union|enum|"
198 r"unsigned|signed|void|char|short|int|long|float|double|bool|"
199 r"[A-Za-z_][A-Za-z0-9_]*_t)\b"
207_CONST_FLOAT_DEF = re.compile(
r"\bconst\b")
208_FLOAT_TYPE = re.compile(
r"\b(float|double)\b")
211def _strip_comments_and_strings(text: str) -> str:
212 """Blank comment and literal bytes to spaces, preserving every position.
214 Newlines survive, so line AND column numbers reported against the blanked
215 view remain valid for the original source -- this gate reports columns,
216 not just lines, so length preservation is required and not merely tidy.
223 nxt = text[i + 1]
if i + 1 < n
else ""
224 if c ==
"/" and nxt ==
"/":
225 while i < n
and text[i] !=
"\n":
228 if c ==
"/" and nxt ==
"*":
231 while i < n
and not (text[i] ==
"*" and i + 1 < n
and text[i + 1] ==
"/"):
232 out.append(
"\n" if text[i] ==
"\n" else " ")
242 while i < n
and text[i] != quote:
243 if text[i] ==
"\\" and i + 1 < n:
247 out.append(
"\n" if text[i] ==
"\n" else " ")
258def _literal_value(match: re.Match) -> tuple[bool, float]:
259 """Decode a numeric-literal match to ``(is_float, value)``.
261 Returns ``(False, NaN)`` for anything un-parseable, and the caller treats
262 NaN as "do not flag" -- an unrecognised literal form is not evidence of a
265 Suffix stripping is type-aware: ``f`` is never stripped from a hex
266 literal, where it is a digit (0xAF) rather than a float suffix.
269 group = match.lastgroup
273 body = raw.rstrip(
"uUlL")
274 return False, float(int(body, 16))
276 body = raw.rstrip(
"uUlL")
277 return False, float(int(body, 2))
278 if group
in (
"flt",
"dec_flt"):
279 body = raw.rstrip(
"fFlL")
281 return True, float(body)
283 return False, float(
"nan")
284 body = raw.rstrip(
"uUlL")
287 for base
in (0, 8, 10):
289 return False, float(int(body, base))
292 return False, float(
"nan")
295def _is_array_dimension(code_line: str, start: int, end: int) -> bool:
296 """Whether a literal is an array dimension rather than a magic number.
298 True only when it is the SOLE content of a ``[ ... ]`` on a declaration
299 line. Matching clang-tidy's treatment here is deliberate: two gates
300 disagreeing about the same literal is worse than either rule alone.
303 while i >= 0
and code_line[i].isspace():
305 if i < 0
or code_line[i] !=
"[":
308 while j < len(code_line)
and code_line[j].isspace():
310 if j >= len(code_line)
or code_line[j] !=
"]":
312 return bool(_DECL_HINT.search(code_line))
315def _is_ignored(is_float: bool, value: float) -> bool:
316 if math.isnan(value):
319 return value
in IGNORED_FLOAT
320 return int(value)
in IGNORED_INT
323class _SuppressionState:
324 """Tracks clang-tidy NOLINT scope across the lines of one file.
326 Honours the project's own readability-magic-numbers waivers, so a literal
327 this gate would flag but clang-tidy is already told to ignore is not
328 reported twice under two different names.
331 def __init__(self) -> None:
332 self.in_region =
False
333 self.skip_next =
False
335 def suppresses(self, raw: str) -> bool:
336 """True when ``raw`` is inside, or is itself, a NOLINT suppression."""
337 m_beg = _NOLINT_BEGIN_RE.search(raw)
338 if m_beg
and _nolint_applies(m_beg.group(1)):
339 self.in_region =
True
340 m_end = _NOLINT_END_RE.search(raw)
341 if m_end
and _nolint_applies(m_end.group(1)):
342 self.in_region =
False
343 if self.in_region
or m_beg
or m_end:
346 self.skip_next =
False
348 m_nl = _NOLINT_NEXT_RE.search(raw)
349 if m_nl
and _nolint_applies(m_nl.group(1)):
350 self.skip_next =
True
352 m_inl = _NOLINT_INLINE_RE.search(raw)
353 return bool(m_inl
and _nolint_applies(m_inl.group(1)))
357 """Tracks brace depth inside an enum body.
359 Literals inside an enum ARE the named constants this gate exists to
360 require, so the body is skipped rather than reported.
363 def __init__(self) -> None:
367 def inside(self, code_line: str) -> bool:
368 """Advance the tracker over ``code_line``; True if it is enum body."""
369 if self.depth == 0
and re.search(
r"\benum\b", code_line):
371 if not (self.pending
or self.depth > 0):
373 opens = code_line.count(
"{")
374 closes = code_line.count(
"}")
375 if self.pending
and opens > 0:
377 self.depth += opens - closes
379 self.depth = max(self.depth + opens - closes, 0)
380 return self.depth > 0
383def _line_literals(code_line: str) -> list[tuple[int, str]]:
384 """Return (column, literal) for every magic number on one line of code."""
385 out: list[tuple[int, str]] = []
386 for m
in _NUM_RE.finditer(code_line):
388 if start > 0
and _IDENT_BEFORE.match(code_line[start - 1]):
390 if _is_array_dimension(code_line, start, m.end()):
392 is_float, value = _literal_value(m)
393 if _is_ignored(is_float, value):
395 if is_float
and _CONST_FLOAT_DEF.search(code_line)
and _FLOAT_TYPE.search(code_line):
397 out.append((start + 1, m.group(0)))
401def _trailing_c_comment(orig_line: str) -> str:
402 """Return the real trailing C comment, ignoring comment-like strings."""
406 while index < len(orig_line):
407 char = orig_line[index]
410 elif quote
and char ==
"\\":
412 elif quote
and char == quote:
414 elif not quote
and char
in {
'"',
"'"}:
416 elif not quote
and orig_line.startswith(
"//", index):
417 return orig_line[index:]
418 elif not quote
and orig_line.startswith(
"/*", index):
419 end = orig_line.find(
"*/", index + 2)
423 if not orig_line[end:].strip():
424 return orig_line[index:end]
430def _has_reasoned_opt_out(orig_line: str) -> bool:
431 """Accept only a trailing C comment carrying ``MAGIC-OK: <reason>``."""
432 match = _OPT_OUT_COMMENT_RE.fullmatch(_trailing_c_comment(orig_line))
435 reason = match.group(
"reason").strip()
436 if match.group(
"comment").startswith(
"/*"):
437 if not reason.endswith(
"*/"):
439 reason = reason[:-2].strip()
443def _skip_line(code_line: str, orig_line: str, enums: _EnumState) -> bool:
444 """True when this line cannot hold a reportable literal."""
445 if enums.inside(code_line):
447 stripped = code_line.lstrip()
449 if stripped.startswith(
"#"):
452 if stripped
and _DATA_ROW_RE.match(code_line):
455 return _has_reasoned_opt_out(orig_line)
458def _scan_file(path: Path) -> list[tuple[int, int, str]]:
459 """Return a list of (line, column, literal) for every magic number in `path`."""
461 text = path.read_text()
462 except (OSError, UnicodeDecodeError):
465 code_lines = _strip_comments_and_strings(text).splitlines()
466 orig_lines = text.splitlines()
468 violations: list[tuple[int, int, str]] = []
469 nolint = _SuppressionState()
472 for idx, code_line
in enumerate(code_lines):
473 raw = orig_lines[idx]
if idx < len(orig_lines)
else ""
474 if nolint.suppresses(raw):
476 if _skip_line(code_line, raw, enums):
478 violations.extend((idx + 1, col, lit)
for col, lit
in _line_literals(code_line))
483def _is_excluded(path: Path) -> bool:
485 return is_build_output_path(p)
or any(frag
in p
for frag
in EXCLUDE_FRAGMENTS)
488def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
489 """Resolve the list of files to scan from CLI arguments."""
490 args = [a
for a
in arg_paths
if a
not in (
"--check",
"--all")]
495 if not p.is_absolute():
498 out.extend(p.rglob(
"*.c"))
499 out.extend(p.rglob(
"*.h"))
500 elif p.suffix
in SOURCE_SUFFIXES:
502 return [p
for p
in out
if not _is_excluded(p)]
524 git_tool = shutil.which(
"git")
or "git"
525 proc = subprocess.run(
526 [git_tool,
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"--",
"*.c",
"*.h"],
532 if proc.returncode != 0:
533 sys.stderr.write(proc.stderr)
534 sys.stderr.write(f
"git ls-files failed (exit {proc.returncode})\n")
537 for line
in proc.stdout.splitlines():
542 if c.name
in BOOT_BOILERPLATE:
545 return [p
for p
in out
if not _is_excluded(p)]
548def _run_selftest() -> int:
549 """Prove both halves of the reasoned ``MAGIC-OK`` contract."""
551int bare = 50; /* MAGIC-OK */
552int blank = 51; /* MAGIC-OK: */
553int reasoned = 52; /* MAGIC-OK: fixed fixture protocol value */
554const char *text = "MAGIC-OK: string text"; int string_only = 53;
555int lookalike = 54; /* MAGIC-OKAY: not the marker */
556int line_reason = 55; // MAGIC-OK: documented line-comment exception
557int fake_line_comment = 56; const char *line = "// MAGIC-OK: string bypass";
558int fake_block_comment = 57; const char *block = "/* MAGIC-OK: string bypass */";
560 failures: list[str] = []
561 with tempfile.TemporaryDirectory(prefix=
"ra8-magic-selftest-")
as temp:
562 fixture = Path(temp) /
"fixture.c"
563 fixture.write_text(source, encoding=
"ascii")
564 findings = {literal
for _, _, literal
in _scan_file(fixture)}
565 expected = {
"50",
"51",
"53",
"54",
"56",
"57"}
566 if findings != expected:
568 "reason contract mismatch: "
569 f
"expected findings {sorted(expected)}, got {sorted(findings)}"
571 if _has_reasoned_opt_out(
"int x = 56; /* MAGIC-OK: missing terminator"):
572 failures.append(
"unterminated block comment accepted as a reasoned opt-out")
574 print(
"check_magic_numbers.py selftest: FAILED", file=sys.stderr)
575 for failure
in failures:
576 print(f
" - {failure}", file=sys.stderr)
578 print(
"check_magic_numbers.py selftest: PASS (reason required; false markers rejected)")
582def main(argv: list[str]) -> int:
583 """Fail on integer literals that should be named typed enums.
585 Reports column as well as line, which is why the whole scan runs over a
586 length-preserving blanked view of the source rather than a stripped one.
588 Returns 1 listing each literal, 0 when clean or when argv filtered to
591 if argv[1:] == [
"--selftest"]:
592 return _run_selftest()
593 if "--selftest" in argv[1:]:
594 print(
"--selftest cannot be combined with scan paths", file=sys.stderr)
597 targets = _enumerate_targets(argv[1:])
599 print(
"check_magic_numbers.py: no files to scan", file=sys.stderr)
602 findings: list[tuple[str, int, int, str]] = []
604 for line_no, col, literal
in _scan_file(path):
605 rel = path.relative_to(REPO_ROOT)
if path.is_relative_to(REPO_ROOT)
else path
606 findings.append((str(rel), line_no, col, literal))
609 print(f
"check_magic_numbers.py: {len(targets)} file(s) scanned, no magic numbers found.")
614 f
"check_magic_numbers.py: {len(findings)} magic number(s) found "
615 "-- every integer literal must be a named typed enum (use a const "
616 "only for floating-point; macros are not allowed for constants):\n",
619 print(
" file:line:col literal", file=sys.stderr)
620 for path, line_no, col, literal
in findings:
621 print(f
" {path}:{line_no}:{col} {literal}", file=sys.stderr)
623 "\nReplace each literal with a named value. Genuine exceptions may "
624 f
"carry a trailing `{OPT_OUT}: <reason>` comment on the line.",
630if __name__ ==
"__main__":
631 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.