4"""check_c23_patterns.py -- enforce four C23 source patterns on first-party code.
6These four rules previously lived ONLY as inline ``grep`` loops inside the
7local ``scripts/git/pre-commit`` hook and were never run by the CI gate
8``pre-commit-checks`` (``gate_pre_commit_checks`` in
9``scripts/ci/gates/checks.sh``). The workflow claimed the gate mirrored the
10hook, but these four checks were absent from it -- a "local green, CI red"
11drift in the other direction, where a violation the hook rejects sails through
12CI on a machine whose hook is not installed. This checker is the single
13first-party implementation both the hook and the gate now call, so there is
14exactly one definition of each rule.
16The rules (CLAUDE.md "C23 Syntax" and "Constants and Macros"):
18 1. ``_Static_assert(`` -- C11 spelling; C23 provides ``static_assert``.
19 2. ``= {0}`` and typed variants such as ``= {0U}`` -- legacy
20 zero-initializers; C23 uses ``= {}``.
21 3. ``#include <stdbool.h>`` -- unnecessary; ``bool`` is a C23 keyword.
22 4. Object-like ``#define NAME <bare-numeric-literal>`` -- the value must be
23 paren-wrapped (``#define NAME (1000)``) so it stays a single token in
24 every expression context. Function-like macros, bare feature flags, and
25 already-parenthesised values are ignored; hex / binary / float / suffix
26 literal forms are all recognised.
29 C / C++ sources (.c .h .cpp .hpp) under libs/, src/, examples/, port/,
30 tools/, tests/. Vendored SOUP (libs/third_party/) and generated font data
31 (libs/ra8_fonts/) are exempt, as are build trees.
33Matches inside comments and string / character literals are ignored. All four
34rules share one raw-aware phase-2 logical lexer and physical-source origin map.
35Its zero-initializer view retains each literal as a non-whitespace token, so a
36string or character literal remains a real second initializer.
39 python3 scripts/checks/check_c23_patterns.py FILE [FILE ...]
40 python3 scripts/checks/check_c23_patterns.py # staged files
41 python3 scripts/checks/check_c23_patterns.py --all # every tracked file
42 python3 scripts/checks/check_c23_patterns.py --selftest # prove the rules fire
44Returns 0 on clean, 1 on findings, 2 on usage / selftest failure.
47from __future__
import annotations
55from pathlib
import Path
57sys.path.insert(0, str(Path(__file__).resolve().parent))
59from lint_targets
import is_build_output_path
61REPO_ROOT = Path(__file__).resolve().parents[2]
64ROOTS = (
"libs",
"examples",
"port",
"tools",
"apps",
"tests")
65EXTS = (
".c",
".h",
".cpp",
".hpp")
67EXEMPT_DIRS = (
"/third_party/",
"/ra8_fonts/")
71_STATIC_ASSERT_RE = re.compile(
r"^\s*_Static_assert\s*\(")
78_STANDARD_INT_SUFFIX =
r"(?:[uU](?:(?:ll|LL)|[lL])?|(?:(?:ll|LL)|[lL])[uU]?|)"
79_BIT_PRECISE_SUFFIX =
r"(?:[uU]?(?:wb|WB)|(?:wb|WB)[uU]?)"
80_SIZE_SUFFIX =
r"(?:[uU]?[zZ]|[zZ][uU]?)"
81_ZERO_SUFFIX = rf
"(?:{_STANDARD_INT_SUFFIX}|{_BIT_PRECISE_SUFFIX}|{_SIZE_SUFFIX})"
82_ZERO_DIGITS =
r"0(?:'?0)*"
83_ZERO_BODY = rf
"(?:{_ZERO_DIGITS}|0[xX]{_ZERO_DIGITS}|0[bB]{_ZERO_DIGITS})"
84_ZERO_INIT_RE = re.compile(rf
"=\s*\{{\s*{_ZERO_BODY}{_ZERO_SUFFIX}\s*,?\s*\}}")
87_STDBOOL_RE = re.compile(
r"^\s*#\s*include\s+<stdbool\.h>")
96_BARE_DEFINE_RE = re.compile(
97 r"^\s*#\s*define\s+[A-Za-z_][A-Za-z0-9_]*\s+"
99 r"0[xX][0-9a-fA-F]+[uUlL]*"
100 r"|0[bB][01]+[uUlL]*"
101 r"|[0-9]+\.[0-9]*(?:[eE][+-]?[0-9]+)?[fFlL]?"
102 r"|\.[0-9]+(?:[eE][+-]?[0-9]+)?[fFlL]?"
103 r"|[0-9]+(?:[eE][+-]?[0-9]+)?[fFlLuU]*"
110_RULES: tuple[tuple[str, re.Pattern[str], str], ...] = (
111 (
"static_assert", _STATIC_ASSERT_RE,
"C11 _Static_assert -- use C23 static_assert"),
112 (
"zero_init", _ZERO_INIT_RE,
"legacy single-zero initializer -- use C23 = {}"),
113 (
"stdbool", _STDBOOL_RE,
"unnecessary #include <stdbool.h> -- bool is a C23 keyword"),
114 (
"bare_define", _BARE_DEFINE_RE,
"bare numeric #define value -- wrap with parens, e.g. (1000)"),
117_RAW_PREFIXES = (
'u8R"',
'uR"',
'UR"',
'LR"',
'R"')
118_RAW_DELIMITER_FORBIDDEN = frozenset(
" ()\\\t\v\f\r\n")
119_RAW_DELIMITER_MAX = 16
120_DIGIT_CHARS = frozenset(
"0123456789abcdefABCDEF")
121_PP_NUMBER_CHARS = frozenset(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.'")
124def _is_digit_separator(text: str, index: int) -> bool:
125 """Return whether one apostrophe separates two preprocessing digits."""
126 if text[index] !=
"'" or index == 0
or index + 1 >= len(text):
128 if text[index - 1]
not in _DIGIT_CHARS
or text[index + 1]
not in _DIGIT_CHARS:
131 while start > 0
and text[start - 1]
in _PP_NUMBER_CHARS:
133 prefix = text[start:index]
134 return prefix[0].isdigit()
or (
135 prefix.startswith(
".")
and len(prefix) > 1
and prefix[1].isdigit()
147 """Mask one logical byte in both policy views."""
148 if text[index] ==
"\n":
149 code_out[index] = zero_out[index] =
"\n"
151 code_out[index] =
" "
152 zero_out[index] =
"L" if literal
else " "
155def _mask_literal_byte(
162 """Mask one literal byte and return the next offset and lexical state."""
164 _mask_position(text, code_out, zero_out, index, literal=
True)
165 if char ==
"\\" and index + 1 < len(text):
167 _mask_position(text, code_out, zero_out, index, literal=
True)
168 return index + 1, quote
170 return index + 1,
"code"
171 return index + 1, quote
174def _phase2_source(text: str) -> tuple[str, tuple[int, ...]]:
175 """Remove phase-2 line splices and retain each logical byte's origin."""
176 logical: list[str] = []
177 origins: list[int] = []
179 while index < len(text):
180 if text[index] ==
"\\" and index + 1 < len(text)
and text[index + 1] ==
"\n":
183 logical.append(text[index])
184 origins.append(index)
186 return "".join(logical), tuple(origins)
193 origins: tuple[int, ...],
195 """Return the end of a C++ raw literal, masking malformed forms to EOF.
197 ``None`` means no raw-literal prefix starts at ``index``. Once a standalone
198 prefix is present, malformed delimiters or absent terminators return EOF so
199 fake code inside an invalid literal cannot leak into the detector.
201 if index > 0
and (text[index - 1].isalnum()
or text[index - 1] ==
"_"):
203 prefix = next((item
for item
in _RAW_PREFIXES
if text.startswith(item, index)),
None)
206 cursor = index + len(prefix)
207 delimiter: list[str] = []
208 terminator: str |
None =
None
209 for _bound
in range(_RAW_DELIMITER_MAX + 1):
210 if cursor >= len(text):
214 terminator = f
'){"".join(delimiter)}"'
216 if char
in _RAW_DELIMITER_FORBIDDEN:
218 delimiter.append(char)
220 if terminator
is None:
222 physical_open = origins[cursor]
223 close = physical_text.find(terminator, physical_open + 1)
226 physical_stop = close + len(terminator)
227 return bisect.bisect_left(origins, physical_stop)
230def _mask_token_range(
231 text: str, code_out: list[str], zero_out: list[str], start: int, stop: int
233 """Mask one raw-literal range in both policy views."""
234 for offset
in range(start, stop):
235 _mask_position(text, code_out, zero_out, offset, literal=
True)
238def _mask_comment_byte(
245 """Mask one comment byte and return the next offset and lexical state."""
247 nxt = text[index + 1]
if index + 1 < len(text)
else ""
248 if state ==
"line_comment":
250 return index + 1,
"code"
251 _mask_position(text, code_out, zero_out, index, literal=
False)
252 return index + 1, state
253 if char ==
"*" and nxt ==
"/":
254 _mask_position(text, code_out, zero_out, index, literal=
False)
255 _mask_position(text, code_out, zero_out, index + 1, literal=
False)
256 return index + 2,
"code"
257 _mask_position(text, code_out, zero_out, index, literal=
False)
258 return index + 1, state
261def _c23_lexical_views(text: str) -> tuple[str, str, tuple[int, ...]]:
262 """Build shared line-rule and zero-rule views of phase-2 source.
264 The line-rule view blanks comments and literals. The zero-rule view blanks
265 comments but retains literals as non-whitespace ``L`` tokens so a literal
266 remains a real second initializer. Both views share one raw-aware lexer,
267 and the origin table maps each logical byte to its physical source offset.
270 text: Original C-family source text.
273 The line-rule view, zero-rule view, and physical-source origin table.
275 logical_text, origins = _phase2_source(text)
276 code_out = list(logical_text)
277 zero_out = list(logical_text)
280 while index < len(logical_text):
281 char = logical_text[index]
282 nxt = logical_text[index + 1]
if index + 1 < len(logical_text)
else ""
284 raw_end = _raw_literal_end(logical_text, index, text, origins)
285 if raw_end
is not None:
286 _mask_token_range(logical_text, code_out, zero_out, index, raw_end)
289 if char ==
"/" and nxt
in {
"/",
"*"}:
290 _mask_position(logical_text, code_out, zero_out, index, literal=
False)
291 _mask_position(logical_text, code_out, zero_out, index + 1, literal=
False)
292 state =
"line_comment" if nxt ==
"/" else "block_comment"
295 if char ==
"'" and _is_digit_separator(logical_text, index):
298 if char
in {
'"',
"'"}:
300 _mask_position(logical_text, code_out, zero_out, index, literal=
True)
301 elif state
in {
"line_comment",
"block_comment"}:
302 index, state = _mask_comment_byte(logical_text, code_out, zero_out, index, state)
305 index, state = _mask_literal_byte(logical_text, code_out, zero_out, index, state)
308 return "".join(code_out),
"".join(zero_out), origins
311def _physical_line(text: str, origins: tuple[int, ...], logical_offset: int) -> int:
312 """Map one surviving logical byte to its 1-based physical source line."""
313 return text.count(
"\n", 0, origins[logical_offset]) + 1
316def _line_rule_violations(
319 origins: tuple[int, ...],
320 orig_lines: list[str],
321) -> list[tuple[int, str, str]]:
322 """Evaluate the three line rules on shared phase-2 logical source."""
323 violations: list[tuple[int, str, str]] = []
325 for code
in code_text.split(
"\n"):
326 first_token = len(code) - len(code.lstrip())
327 for rule_id, pattern, _msg
in _RULES:
328 if rule_id ==
"zero_init" or pattern.search(code)
is None:
330 line_no = _physical_line(text, origins, logical_offset + first_token)
331 snippet = (orig_lines[line_no - 1]
if line_no <= len(orig_lines)
else code).strip()
332 violations.append((line_no, rule_id, snippet))
333 logical_offset += len(code) + 1
337def find_violations(path: Path) -> list[tuple[int, str, str]]:
338 """Report every C23-pattern violation in one file.
340 The scan runs over a comment/string-blanked view of the source so a match
341 inside a comment or a string literal is never reported. The phase-2 origin
342 table maps logical matches back to exact physical source lines.
348 A list of ``(line_no, rule_id, snippet)`` tuples, one per finding;
349 an unreadable file yields an empty list rather than raising.
352 text = path.read_text(encoding=
"utf-8", errors=
"replace")
355 orig_lines = text.splitlines()
356 code_text, zero_init_text, origins = _c23_lexical_views(text)
357 violations = _line_rule_violations(text, code_text, origins, orig_lines)
358 for match
in _ZERO_INIT_RE.finditer(zero_init_text):
359 line_no = _physical_line(text, origins, match.start())
361 orig_lines[line_no - 1]
if line_no <= len(orig_lines)
else match.group(0)
363 violations.append((line_no,
"zero_init", snippet))
364 violations.sort(key=
lambda finding: finding[0])
368def needs_check(path: Path) -> bool:
369 """Whether a path is first-party C/C++ subject to the C23 pattern rules.
372 path: Candidate file path.
375 True when the suffix is a C/C++ one and the path is neither a build
376 artifact nor under a vendored / generated tree.
378 if path.suffix.lower()
not in EXTS:
380 posix = path.as_posix()
381 if is_build_output_path(posix):
383 return not any(frag
in f
"/{posix}/" for frag
in EXEMPT_DIRS)
386def _git_lines(*pathspec: str) -> list[str]:
387 """Return tracked repo-relative paths matching `pathspec`.
390 pathspec: git pathspec arguments (e.g. ``"*.c"``).
393 Repo-relative path strings; exits 2 on a git failure.
404 proc = subprocess.run(
405 [
"git",
"ls-files",
"-z",
"--", *pathspec],
411 if proc.returncode != 0:
412 sys.stderr.write(proc.stderr)
413 sys.stderr.write(f
"git ls-files failed (exit {proc.returncode})\n")
415 return [p
for p
in proc.stdout.split(
"\0")
if p]
418def iter_all_files() -> list[Path]:
419 """Every tracked first-party C/C++ file, for the ``--all`` sweep.
422 Absolute paths under the first-party roots that pass ``needs_check``.
426 for rel
in _git_lines(*(f
"{root}/**/*{ext}" for ext
in EXTS)):
430 return sorted(set(out))
433def iter_staged_files() -> list[Path]:
434 """Every staged first-party C/C++ file, for the default (hook) mode.
437 Absolute paths of added / copied / modified / renamed staged files
438 that pass ``needs_check`` and still exist on disk.
440 proc = subprocess.run(
441 [
"git",
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR",
"-z"],
447 if proc.returncode != 0:
448 sys.stderr.write(proc.stderr)
449 sys.stderr.write(f
"git diff --cached failed (exit {proc.returncode})\n")
452 for rel
in proc.stdout.split(
"\0"):
456 if needs_check(p)
and p.is_file():
473_SELFTEST_CASES: tuple[tuple[str, str, bool], ...] = (
475 (
"static_assert",
'_Static_assert(sizeof(int) == 4, "width");\n',
True),
476 (
"static_assert",
' _Static_assert(1, "x");\n',
True),
477 (
"static_assert",
'static_assert(sizeof(int) == 4, "width");\n',
False),
479 (
"zero_init",
"int table[4] = {0};\n",
True),
480 (
"zero_init",
"unsigned table[4] = {0U};\n",
True),
481 (
"zero_init",
"unsigned long table[4] = { 0UL };\n",
True),
482 (
"zero_init",
"unsigned long long table[4]={0ULL};\n",
True),
483 (
"zero_init",
"unsigned table[4] = {0x00u};\n",
True),
484 (
"zero_init",
"unsigned table[4] = {0b00U};\n",
True),
485 (
"zero_init",
"unsigned _BitInt(2) table[1] = {0wb};\n",
True),
486 (
"zero_init",
"unsigned _BitInt(2) table[1] = {0uwb};\n",
True),
487 (
"zero_init",
"unsigned _BitInt(2) table[1] = {0WBU};\n",
True),
488 (
"zero_init",
"unsigned table[4] = {0x00ULL,};\n",
True),
489 (
"zero_init",
"unsigned table[4] = {\n 0b00UL,\n};\n",
True),
490 (
"zero_init",
"int table[4] = {};\n",
False),
491 (
"zero_init",
"int table[4] = { };\n",
False),
492 (
"zero_init",
"int table[4] = {0U, 1U};\n",
False),
493 (
"zero_init",
"int table[4] = {\n 0U,\n 1U,\n};\n",
False),
494 (
"zero_init",
"unsigned table[1] = {0'1U};\n",
False),
495 (
"zero_init",
"unsigned table[1] = {0x0'1U};\n",
False),
496 (
"zero_init",
"unsigned table[1] = {0b0'1U};\n",
False),
497 (
"zero_init",
"long table[2] = {0z, 1z};\n",
False),
499 (
"stdbool",
"#include <stdbool.h>\n",
True),
500 (
"stdbool",
"# include <stdbool.h>\n",
True),
501 (
"stdbool",
"#include <stdint.h>\n",
False),
503 (
"bare_define",
"#define K_TIMEOUT_MS 1000\n",
True),
504 (
"bare_define",
"#define K_MASK 0xFF\n",
True),
505 (
"bare_define",
"#define K_FLAGS 0b1010u\n",
True),
506 (
"bare_define",
"#define K_SCALE 1.5f\n",
True),
507 (
"bare_define",
"#define K_TIMEOUT_MS (1000)\n",
False),
508 (
"bare_define",
"#define K_TIMEOUT_MS 1000 // trailing comment ok\n",
True),
509 (
"bare_define",
"#define RA8_HAS_MVE\n",
False),
510 (
"bare_define",
"#define MAX(a, b) ((a) > (b) ? (a) : (b))\n",
False),
512 (
"_clean",
"/* _Static_assert(x); int a[1] = {0U}; #define K 5 */\n",
False),
513 (
"_clean",
'const char* s = "= {0ULL}";\n',
False),
514 (
"_clean",
"// #include <stdbool.h>\n",
False),
519_ZERO_INIT_LINE_CASES: tuple[tuple[str, int, bool], ...] = (
520 (
'unsigned table[2] = {0U, "x"};\n', 1,
False),
521 (
'unsigned table[2] = {\n 0U,\n "x",\n};\n', 2,
False),
522 (
"unsigned table[2] = {0U, 'x'};\n", 1,
False),
523 (
"unsigned table[2] = {\n 0U,\n 'x',\n};\n", 2,
False),
524 (
"\nunsigned table[1] = {\n 0U, // only a comment follows\n};\n", 2,
True),
525 (
"\nunsigned table[1] = { /* before */ 0U, /* after */ };\n", 2,
True),
526 (
"\nunsigned table[1] = {\n 0x00ULL,\n};\n", 2,
True),
527 (
'const char *s = "unsigned table[1] = {0U};";\n', 1,
False),
528 (
"/* unsigned table[1] = {0U}; */\n", 1,
False),
529 (
"// unsigned table[1] = {0U};\n", 1,
False),
530 (
'const char *s = R"(before " = {0U} after)";\n', 1,
False),
531 (
'const char *s = u8R"tag(before ")" = {0U} after)tag";\n', 1,
False),
532 (
'const char *s = uR"tag(before = {0U} after)tag";\n', 1,
False),
533 (
'const char *s = UR"tag(before = {0U} after)tag";\n', 1,
False),
534 (
'const char *s = LR"tag(before = {0U} after)tag";\n', 1,
False),
535 (
'const char *s = R"tag(\nbefore " = {0U}\nafter\n)tag";\n', 1,
False),
536 (
'const char *s = R"unterminated(fake = {0U};\n', 1,
False),
537 (
'const char *s = R"abcdefghijklmnopq(fake = {0U})abcdefghijklmnopq";\n', 1,
False),
538 (
'const char *s = R"(fake = {0U})";\nint real[1] = {0U};\n', 2,
True),
539 (
"unsigned table[1] = {\\\n0U};\n", 1,
True),
540 (
"/\\\n/ fake = {0U};\nint real;\n", 1,
False),
541 (
"/\\\n* fake = {0U}; */\nint real;\n", 1,
False),
542 (
"/* fake = {0U}; *\\\n/\nint real;\n", 1,
False),
543 (
'const char *s = R\\\n"(fake = {0U})";\n', 1,
False),
544 (
'const char *s = R"tag(fake )tag\\\n" = {0U})tag";\n', 1,
False),
545 (
"// fake \\\nint hidden[1] = {0U};\n", 1,
False),
546 (
"// fake \\\nstill hidden\nint real[1] = {0U};\n", 3,
True),
549_PHASE2_CASES: tuple[tuple[str, str, tuple[int, ...]], ...] = (
550 (
"plain",
"plain", (0, 1, 2, 3, 4)),
552 (
"\\n",
"\\n", (0, 1)),
554 (
"a\\\nb",
"ab", (0, 3)),
557_VALID_ZERO_SUFFIXES = (
603_VALID_ZERO_LITERALS = (
604 *(f
"0{suffix}" for suffix
in _VALID_ZERO_SUFFIXES),
611_LINE_RULE_CASES: tuple[tuple[str, tuple[tuple[int, str], ...]], ...] = (
612 (
'_Sta\\\ntic_assert(1, "ok");\n', ((1,
"static_assert"),)),
613 (
'\\\n_Static_assert(1, "ok");\n', ((2,
"static_assert"),)),
614 (
"#inc\\\nlude <stdbool.h>\n", ((1,
"stdbool"),)),
615 (
"#define K_TIME \\\n1000\n", ((1,
"bare_define"),)),
616 (
"/\\\n/ _Static_assert(1, x);\n", ()),
617 (
"/\\\n* #include <stdbool.h> */\n", ()),
618 (
"/* #define K 1000 *\\\n/\n", ()),
619 (
'const char*s=R"(before "\n_Static_assert(1, x);\n)";\n', ()),
620 (
'const char*s=R"(before "\n#include <stdbool.h>\n)";\n', ()),
621 (
'const char*s=R"(before "\n#define K 1000\n)";\n', ()),
622 (
'const char*s=R"(one\ntwo)";\n\n_Static_assert(1, "ok");\n', ((4,
"static_assert"),)),
623 (
"char8_t c = u8'0';\n_Static_assert(1, \"ok\");\n", ((2,
"static_assert"),)),
627def _zero_literal_failures(tmp: Path) -> list[str]:
628 """Return failures from the complete valid-zero literal table."""
629 failures: list[str] = []
630 for i, literal
in enumerate(_VALID_ZERO_LITERALS):
631 fixture = tmp / f
"zero_literal_case_{i}.cpp"
632 fixture.write_text(f
"long table[1] = {{{literal}}};\n", encoding=
"utf-8")
633 fired = [rule_id
for _line, rule_id, _snippet
in find_violations(fixture)]
634 if fired != [
"zero_init"]:
635 failures.append(f
" zero literal case {i}: got {fired} for {literal!r}")
639def _line_rule_failures(tmp: Path) -> list[str]:
640 """Return failures from phase-2, raw-literal, and line-map cases."""
641 failures: list[str] = []
642 for i, (source, expected)
in enumerate(_LINE_RULE_CASES):
643 fixture = tmp / f
"line_rule_case_{i}.cpp"
644 fixture.write_text(source, encoding=
"utf-8")
645 actual = tuple((line, rule_id)
for line, rule_id, _snippet
in find_violations(fixture))
646 if actual != expected:
648 f
" line-rule case {i}: got {actual!r}, expected {expected!r}: {source!r}"
653def selftest(tmp: Path) -> int:
654 """Prove each rule fires on a bad fixture and stays silent on the C23 form.
657 tmp: Writable scratch directory for the fixture files.
660 0 when every case matches its expectation, 1 otherwise.
662 failures: list[str] = []
663 for i, (expect_id, source, should_fire)
in enumerate(_SELFTEST_CASES):
664 fixture = tmp / f
"case_{i}.c"
665 fixture.write_text(source, encoding=
"utf-8")
666 fired = {rule_id
for _line, rule_id, _snip
in find_violations(fixture)}
668 if expect_id
not in fired:
669 failures.append(f
" case {i}: rule '{expect_id}' did not fire on: {source!r}")
672 f
" case {i}: rules {sorted(fired)} fired but none expected: {source!r}"
674 for i, (source, expected_line, should_fire)
in enumerate(_ZERO_INIT_LINE_CASES):
675 fixture = tmp / f
"zero_line_case_{i}.c"
676 fixture.write_text(source, encoding=
"utf-8")
678 line
for line, rule_id, _snippet
in find_violations(fixture)
if rule_id ==
"zero_init"
680 expected = [expected_line]
if should_fire
else []
681 if lines != expected:
683 f
" zero-line case {i}: got lines {lines}, expected {expected}: {source!r}"
685 for i, (source, expected_text, expected_origins)
in enumerate(_PHASE2_CASES):
686 actual_text, actual_origins = _phase2_source(source)
687 if (actual_text, actual_origins) != (expected_text, expected_origins):
689 f
" phase-2 case {i}: got {(actual_text, actual_origins)!r}, "
690 f
"expected {(expected_text, expected_origins)!r}"
692 failures.extend(_zero_literal_failures(tmp))
693 failures.extend(_line_rule_failures(tmp))
695 print(
"check_c23_patterns.py --selftest: FAILED\n", file=sys.stderr)
696 print(
"\n".join(failures), file=sys.stderr)
698 fires = sum(1
for c
in _SELFTEST_CASES
if c[2])
701 + len(_ZERO_INIT_LINE_CASES)
703 + len(_VALID_ZERO_LITERALS)
704 + len(_LINE_RULE_CASES)
706 fires += sum(1
for case
in _ZERO_INIT_LINE_CASES
if case[2])
707 fires += len(_VALID_ZERO_LITERALS)
708 fires += sum(1
for _source, expected
in _LINE_RULE_CASES
if expected)
710 f
"check_c23_patterns.py --selftest: PASS "
711 f
"({total} cases: {fires} must fire, {total - fires} must stay silent)"
716def _resolve_targets(args: argparse.Namespace) -> list[Path]:
717 """Decide which files to scan from the parsed arguments.
720 args: Parsed command-line arguments.
723 The list of files to scan, filtered through ``needs_check``.
726 return iter_all_files()
728 return [Path(p)
for p
in args.files
if needs_check(Path(p))
and Path(p).is_file()]
729 return iter_staged_files()
732def main(argv: list[str]) -> int:
733 """Scan first-party C/C++ for the four C23 patterns, or run the selftest.
735 With no ``--all`` and no explicit files the staged set is scanned, which is
736 how the pre-commit hook stays fast; ``--all`` sweeps every tracked file,
737 which is how the CI gate covers the whole tree.
740 argv: Full argument vector (``sys.argv``).
743 0 when clean, 1 on findings or a failing selftest, 2 on a usage error.
745 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
746 parser.add_argument(
"--all", action=
"store_true", help=
"scan all tracked C/C++ files")
748 "--selftest", action=
"store_true", help=
"prove each rule fires and stays silent correctly"
750 parser.add_argument(
"files", nargs=
"*", help=
"explicit file list (e.g. staged files)")
751 args = parser.parse_args(argv[1:])
754 with tempfile.TemporaryDirectory()
as td:
755 return selftest(Path(td))
757 targets = _resolve_targets(args)
759 messages = {rule_id: msg
for rule_id, _pat, msg
in _RULES}
762 rel = path.relative_to(REPO_ROOT)
if path.is_relative_to(REPO_ROOT)
else path
763 for line_no, rule_id, snippet
in find_violations(path):
764 print(f
"{rel}:{line_no}: {messages[rule_id]}: {snippet}", file=sys.stderr)
769 f
"\ncheck_c23_patterns.py: {total} C23-pattern violation(s). "
770 "Use static_assert, `= {}`, drop <stdbool.h>, and wrap bare "
771 "numeric #define values in parens.",
775 print(f
"check_c23_patterns.py: {len(targets)} file(s) scanned, 0 findings.")
779if __name__ ==
"__main__":
780 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.