ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_null.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_null.py -- enforce the C23 nullptr-only rule on first-party code.
5
6Project policy: first-party C must use ``nullptr`` instead of ``NULL`` for
7null pointer constants. This script walks staged or all-tracked source files
8and rejects bare ``NULL`` tokens in code positions. It allows NULL in:
9
10 * comment lines and inline `/* ... */` / `// ...` comments
11 * string literals
12 * Doxygen annotation prose
13 * UX_NULL / similar vendor macros (USBX expects literal NULL)
14 * exact generated-source paths registered by the lint-coverage manifest
15
16Scope is DERIVED from git ls-files (#358), so tools/ -- host tooling held to
17the same C23 bar, and silently omitted by the old ROOT_DIRS tuple -- is now in
18scope, along with every future top-level directory. Vendored SOUP
19(libs/third_party/, libs/ra8_fonts/, port/threadx/, ...) is skipped wholesale, and
20one first-party tree is exempt for a stated reason (see EXEMPT_PREFIXES):
21tests/ (NULL is deliberate null-guard stimulus).
22
23Usage:
24 python3 scripts/checks/check_no_null.py FILE [FILE ...]
25 python3 scripts/checks/check_no_null.py --all
26
27Returns 0 on clean, 1 on findings, 2 on usage error.
28"""
29
30from __future__ import annotations
31
32import argparse
33import pathlib
34import re
35import sys
36import tempfile
37from collections.abc import Iterable
38
39sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
40
41from lint_coverage_rules import PATH_CLASS
42from lint_targets import first_party_paths, is_build_output_path
43from selftest_assert import expect, report
44
45REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
46
47EXTENSIONS = (".c", ".h", ".cpp", ".hpp")
48
49# Vendored SOUP the nullptr rule never governs. first_party_paths already drops
50# these for the --all sweep; needs_check re-checks them so an explicitly named
51# vendored file (a pre-commit staging edge case) is skipped as well.
52SOUP_PREFIXES = (
53 "libs/third_party/",
54 "apps/shared_libs/third_party/",
55 "libs/ra8_fonts/",
56 "tools/vela/generated/",
57 "port/threadx/",
58)
59
60# Scope recorded here, NOT as a directory allowlist (#358). Enumeration is
61# derived from git ls-files, so tools/ -- host tooling held to the same C23 bar
62# per CLAUDE.md, and silently omitted by the old ROOT_DIRS tuple -- and every
63# future top-level directory are in scope automatically. One first-party tree
64# is deliberately OUT, for a stated reason:
65EXEMPT_PREFIXES = (
66 # Unit tests pass NULL as deliberate stimulus to exercise null-pointer
67 # guards -- `TEST_ASSERT_EQ(k_ra8_err_null_ptr, fn(NULL, ...))`. Requiring
68 # nullptr there fights the test rather than the code, the same reason
69 # check_magic_numbers.py holds tests/ exempt.
70 "tests/",
71)
72
73# Generator-owned output is governed by reproducible regeneration/diff checks,
74# not handwritten C23 spelling rules. This is an exact manifest lookup: a
75# future file merely resembling generated output remains in scope.
76GENERATED_SOURCE_PATHS = frozenset(
77 path for path, classification in PATH_CLASS.items() if classification == "generated-source"
78)
79
80# bare NULL token in a code context. \bNULL\b matches the identifier;
81# we strip comments and strings first so this only fires in code.
82NULL_RE = re.compile(r"\bNULL\b")
83
84
85# Strip C/C++ inline comments and string/char literals so we don't
86# match NULL inside them. Naive but adequate for this use case.
87def _strip_noncode(line: str) -> str: # noqa: PLR0912, PLR0915 # char-by-char state machine, splitting hurts readability
88 out = []
89 i = 0
90 in_str = False
91 in_chr = False
92 in_lc = False
93 in_bc = False
94 n = len(line)
95 while i < n:
96 c = line[i]
97 nxt = line[i + 1] if i + 1 < n else ""
98 if in_lc:
99 break
100 if in_bc:
101 if c == "*" and nxt == "/":
102 in_bc = False
103 i += 2
104 continue
105 i += 1
106 continue
107 if in_str:
108 if c == "\\" and i + 1 < n:
109 i += 2
110 continue
111 if c == '"':
112 in_str = False
113 i += 1
114 continue
115 if in_chr:
116 if c == "\\" and i + 1 < n:
117 i += 2
118 continue
119 if c == "'":
120 in_chr = False
121 i += 1
122 continue
123 if c == "/" and nxt == "/":
124 break
125 if c == "/" and nxt == "*":
126 in_bc = True
127 i += 2
128 continue
129 if c == '"':
130 in_str = True
131 i += 1
132 continue
133 if c == "'":
134 in_chr = True
135 i += 1
136 continue
137 out.append(c)
138 i += 1
139 return "".join(out)
140
141
142# Identifiers that LOOK like NULL but are actually allowed: vendor macros
143# the project doesn't own (USBX expects literal NULL in its API contract,
144# ThreadX-FileX-NetXDuo similar). We accept a token if its full
145# identifier is in this set or it has one of these suffixes.
146ALLOWED_TOKENS = {"UX_NULL", "TX_NULL", "FX_NULL", "NX_NULL"}
147
148
149def find_violations(path: pathlib.Path) -> list[tuple[int, str]]:
150 """Report every use of ``NULL`` in one file.
151
152 C23 spells the null pointer constant ``nullptr``, which is typed; ``NULL``
153 is a macro that expands to an untyped 0 and so silently satisfies an
154 integer parameter. That is the defect this catches, not the spelling.
155
156 Returns ``(line_no, line_text)`` per finding; an unreadable file yields an
157 empty list rather than raising.
158 """
159 violations: list[tuple[int, str]] = []
160 try:
161 text = path.read_text(encoding="utf-8", errors="replace")
162 except OSError:
163 return violations
164 in_block_comment = False
165 for n, raw in enumerate(text.splitlines(), 1):
166 # Multi-line block comment continuation handling.
167 cur = raw
168 if in_block_comment:
169 end = cur.find("*/")
170 if end == -1:
171 continue
172 cur = cur[end + 2 :]
173 in_block_comment = False
174 # Detect a /* on this line that doesn't close.
175 bo = cur.find("/*")
176 if bo != -1 and cur.find("*/", bo + 2) == -1:
177 cur = cur[:bo]
178 in_block_comment = True
179 code = _strip_noncode(cur)
180 if "NULL" not in code:
181 continue
182 # Skip allowed tokens.
183 # Replace allowed tokens with a placeholder so the regex doesn't fire.
184 scrubbed = code
185 for tok in ALLOWED_TOKENS:
186 scrubbed = scrubbed.replace(tok, "_OK_")
187 if NULL_RE.search(scrubbed):
188 violations.append((n, raw.strip()))
189 return violations
190
191
192def _in_scope(rel: str) -> bool:
193 """Whether repo-relative ``rel`` is first-party C the nullptr rule governs.
194
195 Pure and total, so the selftest asserts scope on synthetic paths without
196 touching the tree: a re-narrowing that drops tools/ fails the selftest
197 instead of passing green.
198 """
199 if not rel.endswith(EXTENSIONS):
200 return False
201 if (
202 rel.startswith(EXEMPT_PREFIXES)
203 or "/tests/" in rel
204 or rel.startswith(SOUP_PREFIXES)
205 or rel in GENERATED_SOURCE_PATHS
206 ):
207 return False
208 return not is_build_output_path(rel)
209
210
211def needs_check(path: pathlib.Path) -> bool:
212 """Whether a CLI-supplied path is first-party C subject to the nullptr rule."""
213 if path.suffix.lower() not in EXTENSIONS:
214 return False
215 try:
216 rel = path.resolve().relative_to(REPO_ROOT).as_posix()
217 except ValueError:
218 # Outside the repo (an isolated selftest fixture): the suffix already
219 # matched and no repo-relative prefix rule can apply, so it is in scope.
220 return True
221 return _in_scope(rel)
222
223
224def iter_all_files() -> Iterable[pathlib.Path]:
225 """Every first-party C file the rule governs, for the ``--all`` sweep.
226
227 Derived from git ls-files via first_party_paths (which already removes
228 vendored SOUP, generated tables, build output and the vendored port/threadx
229 tree), minus the two documented EXEMPT_PREFIXES. A newly-added top-level
230 directory of first-party C is covered the day it lands -- no allowlist.
231 """
232 for rel in first_party_paths(EXTENSIONS):
233 if _in_scope(rel):
234 yield REPO_ROOT / rel
235
236
237# ---------------------------------------------------------------------------
238# Selftest -- both directions, plus a scope assertion under tools/, the root
239# ROOT_DIRS silently omitted until #358. A scope that quietly re-narrows must
240# fail here, not report a clean tree over files it stopped scanning.
241# ---------------------------------------------------------------------------
242_BAD_FIXTURE = "int f(void) { char *p = NULL; return p == NULL; }\n"
243_GOOD_FIXTURE = (
244 "int f(void) { char *p = nullptr; // NULL in a comment is fine\n"
245 ' const char *s = "NULL literal"; // and in a string literal\n'
246 " return (p == nullptr) && (UX_NULL == p); }\n"
247)
248
249
250def selftest() -> int:
251 """Prove bare NULL fires, legal constructs stay quiet, and the scope holds."""
252 print("check_no_null.py --selftest")
253 failures: list[str] = []
254 with tempfile.TemporaryDirectory() as tmp:
255 bad = pathlib.Path(tmp) / "bad.c"
256 bad.write_text(_BAD_FIXTURE, encoding="utf-8")
257 good = pathlib.Path(tmp) / "good.c"
258 good.write_text(_GOOD_FIXTURE, encoding="utf-8")
259 expect(bool(find_violations(bad)), "bare NULL in code fires", failures)
260 expect(
261 not find_violations(good),
262 "nullptr / vendor macro / comment / string stays quiet",
263 failures,
264 )
265 expect(
266 _in_scope("tools/mkbookimg/src/mkbookimg.c"),
267 "tools/ is in scope (ROOT_DIRS omitted it before #358)",
268 failures,
269 )
270 expect(not _in_scope("tests/test_x.c"), "tests/ exempt (deliberate NULL stimulus)", failures)
271 generated = "libs/ra8_c6link/src/ra8_media_download.pb-c.c"
272 expect(
273 generated in GENERATED_SOURCE_PATHS and not _in_scope(generated),
274 "registered generated source is exempt",
275 failures,
276 )
277 expect(
278 _in_scope("libs/ra8_c6link/src/future_generated.pb-c.c"),
279 "generated-looking future source is not automatically exempt",
280 failures,
281 )
282 expect(not _in_scope("libs/third_party/threadx/src/tx.c"), "platform SOUP exempt", failures)
283 expect(
284 not _in_scope("apps/shared_libs/third_party/miniz/miniz.c"),
285 "app SOUP exempt",
286 failures,
287 )
288 expect(
289 _in_scope("apps/shared_libs/compress/src/compress.c"),
290 "adjacent app first-party code remains in scope",
291 failures,
292 )
293 return report(failures)
294
295
296def main() -> int:
297 """Fail on any use of ``NULL`` where C23 ``nullptr`` is required.
298
299 ``--all`` sweeps the tree; otherwise only the named files are checked,
300 which is how the pre-commit hook stays fast.
301
302 Returns 1 listing each use, 0 when clean.
303 """
304 parser = argparse.ArgumentParser(description=__doc__)
305 parser.add_argument("--all", action="store_true", help="scan all tracked source files")
306 parser.add_argument(
307 "--selftest", action="store_true", help="prove the rule fires and the scope holds"
308 )
309 parser.add_argument(
310 "files", nargs="*", type=pathlib.Path, help="explicit file list (e.g. staged files)"
311 )
312 args = parser.parse_args()
313
314 if args.selftest:
315 return selftest()
316
317 if args.all:
318 candidates = list(iter_all_files())
319 elif args.files:
320 candidates = [p for p in args.files if needs_check(p)]
321 else:
322 parser.print_usage(sys.stderr)
323 return 2
324
325 total_violations = 0
326 for path in candidates:
327 for line, snippet in find_violations(path):
328 print(f"{path}:{line}: bare NULL -- use nullptr (C23): {snippet}", file=sys.stderr)
329 total_violations += 1
330
331 if total_violations:
332 print(
333 f"\n{total_violations} bare NULL token(s) found. Replace with "
334 "`nullptr` (C23 builtin). Allowed: UX_NULL / TX_NULL / FX_NULL "
335 "/ NX_NULL vendor macros, comments, string literals.",
336 file=sys.stderr,
337 )
338 return 1
339 print("check_no_null.py: 0 findings.")
340 return 0
341
342
343if __name__ == "__main__":
344 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298