4"""Gate: every first-party entry point uses its own build domain's contract.
6Two domains, two contracts, one enforced boundary (#707):
8* **Hosted** -- ``tests/`` and ``tools/`` run under an OS that reads an exit
9 status, so ISO C applies: ``int main(void)`` or ``int main(int, char**)``.
10 Both host compilers enforce this themselves; this gate exists so the
11 spelling cannot drift back to ``int32_t`` where nothing would notice.
12* **Freestanding** -- ``examples/`` and ``port/`` are bare metal
13 reached from ``Reset_Handler``. There is no process and no exit status, so
14 the entry point is ``void main(void)``, declared once in
15 ``libs/ra8_core/inc/ra8_boot_entry.h``.
17``apps/`` -- the products tier -- is the one root that carries BOTH, so it
18cannot be classified by its name: see ``FIRMWARE_APPS`` below.
20WHY A GATE RATHER THAN TRUSTING THE COMPILER
22The compiler catches a *disagreement it can see*. It could not see this one.
23``int32_t`` is ``long int`` on arm-none-eabi and ``int`` on the host, so
24``int32_t main(void)`` meant a different function type on each side; on the
25chip that is ``-Werror=main``, which 208 example files silenced with a local
26``#pragma GCC diagnostic ignored "-Wmain"``. Worse, the declaration lived in
27sixteen copy-pasted ``extern int32_t main(void);`` lines in vector tables --
28a different translation unit from the definition, so roughly thirty apps had
29drifted into declaring one type and defining another with nothing to notice.
31The single declaration in ``ra8_boot_entry.h`` is what makes the compiler
32able to check; requiring firmware entry points to INCLUDE it is what makes
33the check actually happen, because GCC exempts ``main`` from
34``-Wmissing-prototypes``. This gate enforces the include, the spelling, and
35the absence of any renewed suppression.
37WHAT IS DELIBERATELY NOT ENFORCED
39Type *agreement* between declaration and definition -- that is the
40compiler's job once the include is present, and it does it better. This gate
41is textual on purpose (see ``funcsize_c.py`` for the same reasoning): it must
42cover translation units that never reach a ``compile_commands.json``,
43including apps excluded from the unified cross-configure.
45``LLVMFuzzerTestOneInput`` harnesses have no ``main`` at all, and the
46Cortex-M33 ``cpu1_main`` is ``static`` and never named ``main``; neither is
47an entry point by this gate's definition and neither is scanned.
51 python3 scripts/checks/check_entry_points.py # scan the tree
52 python3 scripts/checks/check_entry_points.py --selftest # both directions
53 python3 scripts/checks/check_entry_points.py --list # inventory
54 python3 scripts/checks/check_entry_points.py FILE... # pre-commit mode
56Exit 0 if clean, 1 on findings or a failing selftest, 2 on a broken scan.
59from __future__
import annotations
63from pathlib
import Path
65sys.path.insert(0, str(Path(__file__).resolve().parent))
66from lint_targets
import (
70from selftest_assert
import expect, report
72REPO_ROOT = Path(__file__).resolve().parents[2]
78BOOT_HEADER =
"ra8_boot_entry.h"
79BOOT_HEADER_REL =
"libs/ra8_core/inc/ra8_boot_entry.h"
87FIRMWARE_ROOTS = (
"examples/",
"port/")
88HOSTED_ROOTS = (
"tests/",
"tools/",
"apps/")
106FIRMWARE_APPS = (
"apps/board/stand_alone/ereader/",)
119 "apps/board/stand_alone/ereader/src/main.c",
120 "libs/ra8_core/inc/ra8_boot_entry.h",
123SUFFIXES = (
".c",
".cpp",
".h")
128MAIN_DEF_RE = re.compile(
r"^(?P<ret>[A-Za-z_][A-Za-z0-9_ ]*?)\s+main\s*\((?P<args>[^)]*)\)\s*$")
129MAIN_DECL_RE = re.compile(
r"^\s*(?:extern\s+)?[A-Za-z_][A-Za-z0-9_ ]*?\s+main\s*\([^)]*\)\s*;\s*$")
130INCLUDE_RE = re.compile(
r'^\s*#\s*include\s+"([^"]+)"')
131RETURN_VALUE_RE = re.compile(
r"^\s*return\s+[^;]+;")
132SUPPRESSION_RE = re.compile(
133 r'#\s*pragma\s+(?:GCC|clang)\s+diagnostic\s+ignored\s+"-Wmain(?:-return-type)?"'
137 "int argc, char** argv",
138 "int argc, char **argv",
139 "int argc, char *argv[]",
140 "int argc, char* argv[]",
145class ScanError(RuntimeError):
146 """The scan stopped being a measurement."""
149def domain_of(rel: str, firmware_apps: tuple[str, ...] = FIRMWARE_APPS) -> str |
None:
150 """Which build domain owns this path, or None if it is neither.
152 Nested test build units are hosted even when they verify a firmware
153 product. A named firmware product is then tested before the general
154 ``apps/`` hosted root.
156 if "tests" in rel.split(
"/"):
158 if rel.startswith(firmware_apps):
160 if rel.startswith(HOSTED_ROOTS):
162 if rel.startswith(FIRMWARE_ROOTS):
167def find_main(lines: list[str]) -> tuple[int, str, str] |
None:
168 """Locate a ``main`` DEFINITION: signature line, return type, arguments."""
169 for i, line
in enumerate(lines):
170 match = MAIN_DEF_RE.match(line.rstrip())
173 ret =
" ".join(match.group(
"ret").split())
174 if ret
in {
"return",
"case",
"else"}
or ret.startswith(
"//"):
179 (lines[j].strip()
for j
in range(i + 1,
min(i + 3, len(lines)))
if lines[j].strip()),
""
181 if not (line.rstrip().endswith(
"{")
or nxt.startswith(
"{")):
183 return i, ret,
" ".join(match.group(
"args").split())
187def body_returns_a_value(lines: list[str], sig: int) -> int |
None:
188 """1-based line of the first value-returning `return` in main's body."""
191 for i
in range(sig, len(lines)):
192 depth += lines[i].count(
"{") - lines[i].count(
"}")
193 if lines[i].count(
"{"):
195 if started
and depth <= 0:
197 if started
and depth >= 1
and RETURN_VALUE_RE.match(lines[i]):
202def copied_main_declarations(rel: str, lines: list[str]) -> list[str]:
203 """Reject declarations copied outside the one authoritative header."""
204 if rel == BOOT_HEADER_REL:
207 f
"{rel}:{i + 1}: duplicates the main() declaration outside "
208 f
'{BOOT_HEADER_REL}. Include "{BOOT_HEADER}" instead so every '
209 f
"firmware definition is checked against one authoritative type."
210 for i, line
in enumerate(lines)
211 if MAIN_DECL_RE.match(line)
216 rel: str, text: str, firmware_apps: tuple[str, ...] = FIRMWARE_APPS
217) -> tuple[list[str], str |
None]:
218 """Findings for one file, plus the domain of any entry point it defines."""
219 findings: list[str] = []
220 lines = text.splitlines()
221 findings.extend(copied_main_declarations(rel, lines))
223 for i, line
in enumerate(lines):
224 if SUPPRESSION_RE.search(line):
226 f
"{rel}:{i + 1}: -Wmain suppression. The firmware lane is "
227 f
"-ffreestanding, so the diagnostic does not apply; a "
228 f
"suppression here is hiding something else."
231 found = find_main(lines)
233 return findings,
None
234 sig, ret, args = found
235 domain = domain_of(rel, firmware_apps)
239 f
"{rel}:{sig + 1}: defines main() but is under neither a hosted "
240 f
"root {HOSTED_ROOTS} nor a firmware root {FIRMWARE_ROOTS}, so no "
241 f
"entry-point contract applies to it. Classify it by moving it, "
242 f
"or extend the roots in this checker."
244 return findings,
None
246 if domain ==
"firmware":
247 if ret !=
"void" or args !=
"void":
249 f
"{rel}:{sig + 1}: firmware entry point is "
250 f
"`{ret} main({args})`; the freestanding contract is "
251 f
"`void main(void)` (see {BOOT_HEADER})."
253 included = {m.group(1)
for m
in (INCLUDE_RE.match(line)
for line
in lines)
if m}
254 if BOOT_HEADER
not in included:
256 f
"{rel}:{sig + 1}: firmware entry point does not include "
257 f
'"{BOOT_HEADER}". Without it the compiler never compares this '
258 f
"definition against the shared declaration, which is exactly "
259 f
"how ~30 of these drifted out of agreement."
261 bad = body_returns_a_value(lines, sig)
264 f
"{rel}:{bad}: `return <value>;` inside a `void main`. "
265 f
"A freestanding entry point has nothing to return to."
267 elif ret !=
"int" or args
not in HOSTED_ARGS_OK:
269 f
"{rel}:{sig + 1}: hosted entry point is `{ret} main({args})`; "
270 f
"ISO C requires `int main(void)` or `int main(int, char**)`."
273 return findings, domain
276def scan(paths: list[str]) -> tuple[list[str], dict[str, int]]:
277 """Scan every path, returning findings and a per-domain count."""
278 findings: list[str] = []
279 counts = {
"hosted": 0,
"firmware": 0}
282 text = (REPO_ROOT / rel).read_text(encoding=
"utf-8")
283 except (OSError, UnicodeDecodeError):
285 file_findings, domain = check_file(rel, text)
286 findings.extend(file_findings)
289 return findings, counts
292def check_shared_declaration() -> list[str]:
293 """The one declaration must still exist, and stay freestanding-guarded.
295 Everything else in this gate is downstream of this file. Delete the
296 declaration and every firmware entry point silently loses the
297 cross-translation-unit check that is the whole point of #707; drop the
298 guard and every hosted TU that includes the header stops compiling with
299 `conflicting types for 'main'`.
301 rel = BOOT_HEADER_REL
303 text = (REPO_ROOT / rel).read_text(encoding=
"utf-8")
305 return [f
"{rel}: missing. It holds the one declaration of main()."]
308 if not re.search(
r"^void main\(void\);$", text, re.MULTILINE):
310 f
"{rel}: no `void main(void);` declaration. Firmware entry points "
311 f
"include this header so the compiler can compare their definition "
312 f
"against it; without the declaration nothing is checked."
314 if "__STDC_HOSTED__" not in text:
316 f
"{rel}: the main() declaration is not guarded by "
317 f
"`__STDC_HOSTED__ == 0`. Unguarded, every hosted TU including "
318 f
"this header fails with `conflicting types for 'main'`."
323def check_firmware_apps(paths: list[str] |
None =
None) -> list[str]:
324 """``FIRMWARE_APPS`` must still name exactly the firmware products present.
326 ``lint_targets.firmware_app_dirs()`` derives the set from what the build
327 does with a directory -- an app dir under ``apps/`` holding both a linker
328 script and a ``vector_table.c`` is linked into an image, not started by a C
329 runtime. Both directions of a disagreement are silent failures, which is
330 why this is a hard error rather than a warning:
332 * a firmware product missing from the tuple is classified HOSTED, so the
333 gate demands ISO ``int main`` of a freestanding image and stops requiring
334 the ``ra8_boot_entry.h`` include;
335 * a stale entry classifies nothing at all, and goes on reporting success
338 ``paths`` defaults to the whole tracked tree deliberately: this gate's own
339 scan carries only C/C++ sources, and a linker script is half the evidence.
340 Pass a list only from a selftest fixture.
342 derived = tuple(f
"{d}/" for d
in firmware_app_dirs(paths))
343 if derived == FIRMWARE_APPS:
346 f
"{d}: a firmware product (linker script + vector_table.c) that "
347 f
"FIRMWARE_APPS does not name, so this gate classifies it as a hosted "
348 f
"program and applies the wrong entry-point contract to it. Add it to "
349 f
"FIRMWARE_APPS in check_entry_points.py."
351 if d
not in FIRMWARE_APPS
354 f
"{d}: named in FIRMWARE_APPS but no longer a firmware product (no "
355 f
"linker script + vector_table.c pair). A stale entry classifies "
356 f
"nothing and reports success forever; drop it."
357 for d
in FIRMWARE_APPS
360 return missing + stale
363def enforce_floors(counts: dict[str, int], paths: list[str]) ->
None:
364 """Raise unless the sweep still reached enough to be a measurement."""
365 if counts[
"firmware"] < FIRMWARE_FLOOR:
367 f
"{counts['firmware']} firmware entry point(s) found, floor is "
368 f
"{FIRMWARE_FLOOR}. The scan stopped reaching them; a clean tree "
369 f
"is NOT the explanation."
372 if counts[
"hosted"] < HOSTED_FLOOR:
374 f
"{counts['hosted']} hosted entry point(s) found, floor is "
375 f
"{HOSTED_FLOOR}. The scan stopped reaching them."
378 missing = [p
for p
in MUST_DISCOVER
if p
not in set(paths)]
380 msg = f
"the scan no longer reaches: {', '.join(missing)}"
384def _selftest_quiet(failures: list[str]) ->
None:
385 """MUST STAY QUIET: conforming entry points produce no finding."""
386 good_fw =
'#include "ra8_boot_entry.h"\nvoid main(void)\n{\n for (;;) {\n }\n}\n'
387 quiet, domain = check_file(
"examples/x/src/main.c", good_fw)
388 expect(
not quiet, f
"a conforming firmware entry point is silent (got {quiet})", failures)
389 expect(domain ==
"firmware", f
"classified firmware (got {domain})", failures)
391 good_hosted =
"int main(void)\n{\n return 0;\n}\n"
392 quiet, domain = check_file(
"tests/misc/src/test_x.c", good_hosted)
393 expect(
not quiet, f
"a conforming hosted entry point is silent (got {quiet})", failures)
394 expect(domain ==
"hosted", f
"classified hosted (got {domain})", failures)
396 good_product_fw =
'#include "ra8_boot_entry.h"\nvoid main(void)\n{\n for (;;) {\n }\n}\n'
397 quiet, domain = check_file(
"apps/board/stand_alone/ereader/src/main.c", good_product_fw)
398 expect(
not quiet, f
"a conforming firmware PRODUCT is silent (got {quiet})", failures)
400 domain ==
"firmware",
401 f
"a named firmware product classifies firmware (got {domain})",
405 quiet, domain = check_file(
"apps/host/mdl/src/main.c",
"int main(void)\n{\n return 0;\n}\n")
406 expect(
not quiet, f
"a conforming hosted PRODUCT is silent (got {quiet})", failures)
407 expect(domain ==
"hosted", f
"an unnamed product stays hosted (got {domain})", failures)
409 quiet, domain = check_file(
"apps/board/stand_alone/ereader/tests/src/test_main.c", good_hosted)
410 expect(
not quiet, f
"a firmware product's hosted test stays silent (got {quiet})", failures)
411 expect(domain ==
"hosted", f
"a nested product test is hosted (got {domain})", failures)
413 argv_main =
"int main(int argc, char** argv)\n{\n return 0;\n}\n"
415 not check_file(
"tools/t/src/main.c", argv_main)[0],
416 "hosted int main(int, char**) is silent",
421 not check_file(
"libs/ra8_core/src/x.c",
"static int main_loop(void)\n{\n}\n")[0],
422 "a function merely NAMED like main is not an entry point",
426 not check_file(BOOT_HEADER_REL,
"void main(void);\n")[0],
427 "the one declaration in the shared header is silent",
432def _selftest_fires(failures: list[str]) ->
None:
433 """MUST FIRE: every rule catches its own violation."""
435 "firmware entry point returning int": (
436 "examples/x/src/main.c",
437 '#include "ra8_boot_entry.h"\nint main(void)\n{\n return 0;\n}\n',
439 "firmware entry point missing the shared header": (
440 "examples/x/src/main.c",
441 "void main(void)\n{\n}\n",
443 "value-returning return inside void main": (
444 "examples/x/src/main.c",
445 '#include "ra8_boot_entry.h"\nvoid main(void)\n{\n return 1;\n}\n',
447 "hosted entry point spelled int32_t": (
448 "tests/misc/src/test_x.c",
449 "int32_t main(void)\n{\n return 0;\n}\n",
451 "a renewed -Wmain suppression": (
452 "examples/x/src/main.c",
453 '#include "ra8_boot_entry.h"\n'
454 '#pragma GCC diagnostic ignored "-Wmain"\n'
455 "void main(void)\n{\n}\n",
457 "an entry point in an unclassifiable root": (
458 "libs/ra8_core/src/x.c",
459 "int main(void)\n{\n return 0;\n}\n",
461 "a copied main declaration outside the shared header": (
462 "examples/x/src/vector_table.c",
463 "extern int32_t main(void);\n",
465 "a firmware PRODUCT written to the hosted contract": (
466 "apps/board/stand_alone/ereader/src/main.c",
467 '#include "ra8_boot_entry.h"\nint main(void)\n{\n return 0;\n}\n',
469 "a hosted PRODUCT written to the freestanding contract": (
470 "apps/host/mdl/src/main.c",
471 "void main(void)\n{\n}\n",
474 for label, (rel, text)
in fires.items():
475 expect(bool(check_file(rel, text)[0]), f
"MUST FIRE: {label}", failures)
478def _selftest_scope(failures: list[str]) ->
None:
479 """The scan still reaches what it must, and still excludes what it must."""
482 any(p.startswith(
"examples/")
for p
in live),
"the live scan reaches examples/", failures
484 expect(any(p.startswith(
"tests/")
for p
in live),
"the live scan reaches tests/", failures)
486 all(
not p.startswith((
"libs/third_party/",
"apps/shared_libs/third_party/"))
for p
in live),
487 "the live scan excludes vendored SOUP",
492 collapsed_rejected =
False
494 enforce_floors({
"hosted": 0,
"firmware": 0}, list(MUST_DISCOVER))
496 collapsed_rejected =
True
497 expect(collapsed_rejected,
"MUST FIRE: a collapsed scan is rejected", failures)
500 not check_shared_declaration(),
501 "the live shared declaration is present and guarded",
509 not check_firmware_apps(),
510 "FIRMWARE_APPS agrees with the products actually in the tree",
514 bool(check_firmware_apps([
"libs/ra8_core/src/x.c"])),
515 "MUST FIRE: a stale FIRMWARE_APPS entry is rejected",
522 "apps/board/stand_alone/ereader/src/vector_table.c",
523 "apps/board/stand_alone/ereader/linker_script.ld",
524 "apps/board/stand_alone/invented/src/vector_table.c",
525 "apps/board/stand_alone/invented/invented.ld",
529 "MUST FIRE: an unlisted firmware product is rejected",
533 domain_of(
"apps/board/stand_alone/ereader/src/main.c", ()) ==
"hosted",
534 "without its FIRMWARE_APPS entry the e-reader would be misclassified",
539def selftest() -> int:
540 """Assert both directions: the rules fire, and they stay quiet."""
541 print(
"check_entry_points.py --selftest")
542 failures: list[str] = []
543 _selftest_quiet(failures)
544 _selftest_fires(failures)
545 _selftest_scope(failures)
546 return report(failures)
549def discover() -> list[str]:
550 """Every first-party C/C++ path, from git ls-files."""
551 return first_party_paths(SUFFIXES)
554def main(argv: list[str]) -> int:
555 """CLI entry point; see the module docstring for the modes."""
557 if "--selftest" in args:
560 explicit = [a
for a
in args
if not a.startswith(
"-")]
561 paths = explicit
or discover()
563 findings, counts = scan(paths)
565 findings = check_shared_declaration() + check_firmware_apps() + findings
569 text = (REPO_ROOT / rel).read_text(encoding=
"utf-8", errors=
"ignore")
570 found = find_main(text.splitlines())
572 print(f
"{domain_of(rel) or 'UNCLASSIFIED':<13}{rel}")
579 enforce_floors(counts, paths)
580 except ScanError
as exc:
581 print(f
"check_entry_points.py: FATAL -- {exc}", file=sys.stderr)
586 f
"check_entry_points.py: {len(findings)} entry-point violation(s):\n",
589 for finding
in findings:
590 print(f
" {finding}", file=sys.stderr)
592 "\nFirmware entry points are `void main(void)` and include "
593 f
'"{BOOT_HEADER}"; hosted ones use ISO `int main(...)`. '
594 "See docs/STYLE_GUIDE.md.",
600 f
"check_entry_points.py: {counts['firmware']} firmware + "
601 f
"{counts['hosted']} hosted entry point(s), no findings."
606if __name__ ==
"__main__":
607 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.