4"""check_no_null.py -- enforce the C23 nullptr-only rule on first-party code.
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:
10 * comment lines and inline `/* ... */` / `// ...` comments
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
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).
24 python3 scripts/checks/check_no_null.py FILE [FILE ...]
25 python3 scripts/checks/check_no_null.py --all
27Returns 0 on clean, 1 on findings, 2 on usage error.
30from __future__
import annotations
37from collections.abc
import Iterable
39sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
41from lint_coverage_rules
import PATH_CLASS
42from lint_targets
import first_party_paths, is_build_output_path
43from selftest_assert
import expect, report
45REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
47EXTENSIONS = (
".c",
".h",
".cpp",
".hpp")
54 "apps/shared_libs/third_party/",
56 "tools/vela/generated/",
76GENERATED_SOURCE_PATHS = frozenset(
77 path
for path, classification
in PATH_CLASS.items()
if classification ==
"generated-source"
82NULL_RE = re.compile(
r"\bNULL\b")
87def _strip_noncode(line: str) -> str:
97 nxt = line[i + 1]
if i + 1 < n
else ""
101 if c ==
"*" and nxt ==
"/":
108 if c ==
"\\" and i + 1 < n:
116 if c ==
"\\" and i + 1 < n:
123 if c ==
"/" and nxt ==
"/":
125 if c ==
"/" and nxt ==
"*":
146ALLOWED_TOKENS = {
"UX_NULL",
"TX_NULL",
"FX_NULL",
"NX_NULL"}
149def find_violations(path: pathlib.Path) -> list[tuple[int, str]]:
150 """Report every use of ``NULL`` in one file.
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.
156 Returns ``(line_no, line_text)`` per finding; an unreadable file yields an
157 empty list rather than raising.
159 violations: list[tuple[int, str]] = []
161 text = path.read_text(encoding=
"utf-8", errors=
"replace")
164 in_block_comment =
False
165 for n, raw
in enumerate(text.splitlines(), 1):
173 in_block_comment =
False
176 if bo != -1
and cur.find(
"*/", bo + 2) == -1:
178 in_block_comment =
True
179 code = _strip_noncode(cur)
180 if "NULL" not in 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()))
192def _in_scope(rel: str) -> bool:
193 """Whether repo-relative ``rel`` is first-party C the nullptr rule governs.
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.
199 if not rel.endswith(EXTENSIONS):
202 rel.startswith(EXEMPT_PREFIXES)
204 or rel.startswith(SOUP_PREFIXES)
205 or rel
in GENERATED_SOURCE_PATHS
208 return not is_build_output_path(rel)
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:
216 rel = path.resolve().relative_to(REPO_ROOT).as_posix()
221 return _in_scope(rel)
224def iter_all_files() -> Iterable[pathlib.Path]:
225 """Every first-party C file the rule governs, for the ``--all`` sweep.
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.
232 for rel
in first_party_paths(EXTENSIONS):
234 yield REPO_ROOT / rel
242_BAD_FIXTURE =
"int f(void) { char *p = NULL; return p == NULL; }\n"
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"
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)
261 not find_violations(good),
262 "nullptr / vendor macro / comment / string stays quiet",
266 _in_scope(
"tools/mkbookimg/src/mkbookimg.c"),
267 "tools/ is in scope (ROOT_DIRS omitted it before #358)",
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"
273 generated
in GENERATED_SOURCE_PATHS
and not _in_scope(generated),
274 "registered generated source is exempt",
278 _in_scope(
"libs/ra8_c6link/src/future_generated.pb-c.c"),
279 "generated-looking future source is not automatically exempt",
282 expect(
not _in_scope(
"libs/third_party/threadx/src/tx.c"),
"platform SOUP exempt", failures)
284 not _in_scope(
"apps/shared_libs/third_party/miniz/miniz.c"),
289 _in_scope(
"apps/shared_libs/compress/src/compress.c"),
290 "adjacent app first-party code remains in scope",
293 return report(failures)
297 """Fail on any use of ``NULL`` where C23 ``nullptr`` is required.
299 ``--all`` sweeps the tree; otherwise only the named files are checked,
300 which is how the pre-commit hook stays fast.
302 Returns 1 listing each use, 0 when clean.
304 parser = argparse.ArgumentParser(description=__doc__)
305 parser.add_argument(
"--all", action=
"store_true", help=
"scan all tracked source files")
307 "--selftest", action=
"store_true", help=
"prove the rule fires and the scope holds"
310 "files", nargs=
"*", type=pathlib.Path, help=
"explicit file list (e.g. staged files)"
312 args = parser.parse_args()
318 candidates = list(iter_all_files())
320 candidates = [p
for p
in args.files
if needs_check(p)]
322 parser.print_usage(sys.stderr)
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
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.",
339 print(
"check_no_null.py: 0 findings.")
343if __name__ ==
"__main__":
344 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.