4"""Gate: no function shall exist only to satisfy the linker.
6A *silent stub* is a function whose body does no work -- it discards its
7arguments and hands back a canned answer -- yet whose presence lets the build
8link and the program claim a capability it does not have. The failure mode is
9worse than a missing feature: the tool links clean, advertises support, and
10fails at runtime (or, worse, silently succeeds having done nothing).
12The former ``tools/rabook_imagepack/src/webp_stub.c`` and
13``apps/host/mdl/src/webp_stub.c`` were the
14motivating case. Each defined the real symbol
15``jof_priv_webp_transcode()``, discarded both arguments and returned
16``k_ra8_err_not_supported`` -- purely so the JOF producer would link
17without compiling the vendored libwebp decoder. A complete WebP decoder was
18already vendored, wrapped, tested and fuzzed in this very repository; the tools
19just were not compiling it. Both tools shipped a WebP feature that could never
24Most no-op bodies in this tree are *legitimate* -- an early revision of this
25gate flagged 27 candidates of which 24 were correct by design:
27 * platform alternatives (``tools/ra8_emulator/src/display/board_view_stub.c`` is the
28 headless stand-in for the Cocoa window layer on Linux CI; it reports
29 failure honestly so callers take their headless branch);
30 * the fail-closed ``#else`` half of the placeholder-crypto guard, which
31 ``check_stub_crypto_guarded.py`` *requires* to return a hard error;
32 * callbacks matching a vtable / registry signature that genuinely have
33 nothing to do (USBX activate / deactivate hooks, an empty ISR completion
34 callback, a ra8_emulator MMIO write handler for a deliberately inert
36 * MMIO read handlers that return module state rather than a constant.
38A gate that fires on those is noise, and a noisy gate gets disabled -- which is
39worse than no gate. So this one does not ask "is the body empty?". It asks
40the two much narrower questions that separate the webp stubs from all 24:
43 A no-op body that provides a *second* definition of a symbol which is also
44 defined for real elsewhere in first-party code. Both definitions must
45 have external linkage: translation-unit-local `static` helpers can reuse a
46 descriptive name without presenting competing symbols to the linker. An
47 external duplicate is always a defect: whichever definition the linker
48 picks, one of them is a lie, and the build silently disables working code
49 that exists in the tree. This is exactly the webp case.
52 A function that returns an explicit "unsupported / unimplemented" error
53 constant, discards every parameter, and has no other statement. Such a
54 function claims an operation was attempted and refused, when in fact no
55 implementation exists at all. Callbacks returning ``void``/``bool``/a
56 pointer, and handlers returning module state, are outside this rule by
61A capability whose *hardware does not physically exist yet* may legitimately
62have no implementation. Such a function is waived by carrying an explicit
63marker naming the missing part, in the form CLAUDE.md mandates::
65 TODO(ESP32-C6 radio module not yet on the bench): ...
67The marker must name something -- a bare ``TODO`` or an empty ``TODO()`` is
68rejected, so the waiver cannot become a catch-all. It is keyed on the marker
69and not on a filename pattern, so it waives exactly the function that carries
70it and nothing else in the file. Note that a waiver is *not* available to
71Rule SHADOW: if a real implementation exists in the tree, the hardware plainly
76 check_no_silent_stubs.py # scan the whole tree
77 check_no_silent_stubs.py FILE ... # scan listed files
78 check_no_silent_stubs.py --selftest # prove the detector both fires and
79 # stays silent on the right inputs
81Exit 0 if no silent stub is found, 1 otherwise.
84from __future__
import annotations
91from pathlib
import Path
95ROOTS = (
"libs",
"tools",
"apps",
"examples",
"port")
96EXCLUDED = (
"libs/third_party/",
"apps/shared_libs/third_party/",
"libs/ra8_fonts/")
101CANNED_ERRORS = frozenset(
103 "k_ra8_err_not_supported",
104 "k_ra8_err_not_implemented",
105 "k_ra8_err_unsupported",
106 "k_ra8_err_unimplemented",
112WAIVER_RE = re.compile(
r"TODO\(\s*([^)]*?)\s*\)")
116NOT_A_FUNCTION = frozenset(
117 {
"if",
"for",
"while",
"switch",
"return",
"sizeof",
"do",
"else",
"catch"}
125FUNCTION_RE = re.compile(
126 r"(?:^|\n)[ \t]*(?:[A-Za-z_][\w \t\*\n]*?)\b(\w+)[ \t\n]*\(([^;{}]*)\)[ \t\n]*\{",
130DISCARD_RE = re.compile(
r"\(\s*void\s*\)\s*(\w+)\Z")
131RETURN_CONST_RE = re.compile(
r"return\s+([A-Za-z_]\w*|-?\d+|nullptr|NULL|true|false)\Z")
132PARAM_NAME_RE = re.compile(
r"(\w+)\s*(?:\[\s*\])?\s*\Z")
135def blank_noncode(text: str) -> str:
136 """Blank out comments and string/char literals, preserving offsets.
138 Offsets are preserved (and newlines kept) so that reported line numbers and
139 brace matching still refer to the original file.
145 if c ==
"/" and i + 1 < n
and text[i + 1] ==
"*":
146 end = text.find(
"*/", i + 2)
147 end = n
if end < 0
else end + 2
148 out.append(re.sub(
r"[^\n]",
" ", text[i:end]))
150 elif c ==
"/" and i + 1 < n
and text[i + 1] ==
"/":
151 end = text.find(
"\n", i)
152 end = n
if end < 0
else end
153 out.append(
" " * (end - i))
157 while j < n
and text[j] != c:
158 j += 2
if text[j] ==
"\\" else 1
160 out.append(re.sub(
r"[^\n]",
" ", text[i:j]))
168def body_span(text: str, brace_idx: int) -> tuple[int, int] |
None:
169 """Return the (start, end) offsets of a function body by brace matching."""
171 for i
in range(brace_idx, len(text)):
177 return (brace_idx + 1, i)
181def param_names(params: str) -> set[str]:
182 """Extract the declared parameter NAMES from a C parameter list.
184 Feeds the discard test in ``classify_body``, which has to know whether a
185 ``(void)x;`` names a real parameter or some unrelated local -- so what is
186 wanted here is the identifiers, with the types thrown away.
188 ``void`` as the whole list yields the empty set rather than a name, which
189 is what makes a zero-parameter function fall out as "discards everything
190 it was given" and stay eligible for the CANNED rule.
192 names: set[str] = set()
193 for raw_part
in params.split(
","):
194 part = raw_part.strip()
195 if not part
or part ==
"void":
197 m = PARAM_NAME_RE.search(part)
199 names.add(m.group(1))
203def classify_body(body: str, params: str) -> str |
None:
204 """Return the returned constant if the body is a pure discard-and-return.
206 Returns None when the body does real work. A body qualifies only when every
207 statement is either a ``(void)param;`` discard or a single ``return
208 <constant>;``, AND at least one discarded name is an actual parameter --
209 that last condition is what separates "ignores its inputs" from a handler
210 that legitimately returns module state.
212 statements = [s.strip()
for s
in body.split(
";")
if s.strip()]
215 discards: list[str] = []
216 returns: list[str] = []
217 for stmt
in statements:
218 m = DISCARD_RE.match(stmt)
220 discards.append(m.group(1))
222 m = RETURN_CONST_RE.match(stmt)
224 returns.append(m.group(1))
227 if len(returns) != 1:
229 declared = param_names(params)
233 if declared
and not (set(discards) & declared):
242 if not declared
and (discards
or returns[0]
not in CANNED_ERRORS):
247def waiver_in(text: str) -> str |
None:
248 """Return the named missing dependency from a TODO(...) marker, if any."""
249 for m
in WAIVER_RE.finditer(text):
250 named = m.group(1).strip()
256def preceding_doc(text: str, start: int) -> str:
257 """Return the ~40 lines before a definition (its comment block)."""
259 return "\n".join(head.splitlines()[-40:])
262def in_failclosed_crypto_guard(raw: str, offset: int) -> bool:
263 """True if the offset sits in the #else half of the placeholder-crypto guard.
265 ``check_stub_crypto_guarded.py`` REQUIRES those bodies to return a hard
266 error so a production image cannot ship fake crypto. Firing on them would
267 pit one gate against another.
269 guard =
"defined(RA8_INSECURE_STUB_CRYPTO)"
270 if guard
not in raw[:offset]:
272 idx = raw.rfind(guard, 0, offset)
273 between = raw[idx:offset]
274 return "#else" in between
and between.count(
"#endif") == 0
277def scan_text(raw: str, path: str) -> list[dict]:
278 """Find discard-and-return functions in one translation unit."""
279 code = blank_noncode(raw)
280 found: list[dict] = []
281 for m
in FUNCTION_RE.finditer(code):
282 name, params = m.group(1), m.group(2)
283 if name
in NOT_A_FUNCTION:
285 span = body_span(code, m.end() - 1)
288 returned = classify_body(code[span[0] : span[1]], params)
291 if in_failclosed_crypto_guard(raw, m.start()):
293 region = preceding_doc(raw, m.start()) + raw[m.start() : span[1]]
297 "line": code[: m.start()].count(
"\n") + 1,
300 "waiver": waiver_in(region),
301 "internal": re.search(
r"\bstatic\b", code[m.start() : m.start(1)])
is not None,
307def real_definitions(files: list[Path]) -> dict[str, list[dict[str, str | bool]]]:
308 """Map symbol -> real definitions and whether each has internal linkage."""
309 defs: dict[str, list[dict[str, str | bool]]] = {}
311 raw = path.read_text(errors=
"replace")
312 code = blank_noncode(raw)
313 for m
in FUNCTION_RE.finditer(code):
315 if name
in NOT_A_FUNCTION:
317 span = body_span(code, m.end() - 1)
320 if classify_body(code[span[0] : span[1]], m.group(2))
is None:
321 defs.setdefault(name, []).append(
324 "internal": re.search(
r"\bstatic\b", code[m.start() : m.start(1)])
331def first_party_sources(explicit: list[str]) -> list[Path]:
332 """Enumerate worktree first-party .c files under ROOTS.
334 Tracked or untracked-but-not-ignored, not globbed. A bare rglob also sweeps
335 in build output -- every
336 configured app leaves a CMake compiler-probe TU at
337 ``<app>/build/CMakeFiles/*/CompilerIdC/CMakeCCompilerId.c`` -- so the set
338 scanned depended on whether the caller had built, and generated code got
339 held to a first-party rule. CI never saw it (it runs against a clean
340 snapshot of committed ``HEAD``) but the pre-commit hook runs in the working
341 tree, which is exactly where a spurious finding costs the most trust.
342 Including untracked files and dropping deleted tracked paths makes the gate
343 accurate during an unstaged move: it scans the destination, not a vanished
344 index-only source. Ignored build output remains excluded.
347 return [Path(p)
for p
in explicit]
349 pathspec = [f
"{root}/**/*.c" for root
in ROOTS]
350 listed = subprocess.run(
356 "--exclude-standard",
365 except (OSError, subprocess.CalledProcessError)
as exc:
367 f
"check_no_silent_stubs.py: FATAL -- cannot list tracked sources: {exc}\n"
368 " This gate enumerates via git and must not fall back to a glob:\n"
369 " a glob silently scans build output and changes the verdict."
373 for name
in listed.split(
"\0")
375 if (path := Path(name)).is_file()
376 if not any(name.startswith(x)
for x
in EXCLUDED)
380def _selftest_source_scope(tmp: Path) -> list[str]:
381 """Assert moved first-party sources stay in scope while both SOUP roots do not."""
382 failures: list[str] = []
383 app_source = tmp /
"apps/shared_libs/compress/src/codec.c"
384 app_vendor = tmp /
"apps/shared_libs/third_party/miniz/miniz.c"
385 platform_vendor = tmp /
"libs/third_party/stack/source.c"
386 for path
in (app_source, app_vendor, platform_vendor):
387 path.parent.mkdir(parents=
True, exist_ok=
True)
388 path.write_text(
"int source;\n", encoding=
"ascii")
391 "apps/shared_libs/compress/src/codec.c",
392 "apps/shared_libs/third_party/miniz/miniz.c",
393 "libs/third_party/stack/source.c",
395 included = [name
for name
in names
if not any(name.startswith(x)
for x
in EXCLUDED)]
396 if names[0]
not in included:
397 failures.append(
" moved app-owned first-party source fell out of scan scope")
398 if names[1]
in included
or names[2]
in included:
399 failures.append(
" a registered SOUP-root source entered first-party scan scope")
403def analyse(files: list[Path]) -> list[tuple[str, dict]]:
404 """Return (rule, finding) violations across the given files."""
405 defs = real_definitions(files)
406 violations: list[tuple[str, dict]] = []
408 for f
in scan_text(path.read_text(errors=
"replace"), str(path)):
412 for d
in defs.get(f[
"name"], [])
413 if d[
"path"] != f[
"path"]
and not d[
"internal"]
and not f[
"internal"]
416 f[
"shadows"] = shadowed
417 violations.append((
"SHADOW", f))
420 if f[
"returns"]
in CANNED_ERRORS
and not f[
"waiver"]:
421 violations.append((
"CANNED", f))
425SELFTEST_CASES: list[tuple[str, str, bool, str]] = [
427 "shadowing stub (the webp case)",
429 ra8_err_t real_thing(state_t* st, pull_t* pfx)
432 return do_work(st, pfx);
439 "shadowing stub (the webp case)",
441 ra8_err_t real_thing(state_t* st, pull_t* pfx)
445 return k_ra8_err_not_supported;
452 "canned unsupported with no implementation anywhere",
454 ra8_err_t lonely_feature(uint32_t mask)
457 return k_ra8_err_not_supported;
464 "same-named static helpers have no cross-TU linker collision",
466 static bool internal_ready(state_t* st)
476 "same-named static helpers have no cross-TU linker collision",
478 static bool internal_ready(state_t* st)
488 "hardware-blocked, waived by a named TODO",
490 /* TODO(ESP32-C6 radio module has been ordered but has not arrived) */
491 ra8_err_t wifi_connect(const char* ssid)
494 return k_ra8_err_not_supported;
501 "bare TODO is not a waiver",
503 /* TODO: wire this up later */
504 ra8_err_t someday(uint32_t x)
507 return k_ra8_err_not_supported;
514 "thin wrapper delegating to a real call",
516 ra8_err_t wrapper(uint8_t* buf, size_t len)
518 return backend_write(buf, len);
525 "platform alternative reporting failure honestly",
527 view_t* board_view_open(uint16_t w, uint16_t h, const char* title)
539 "intentionally empty ISR / vtable callback",
541 static void on_complete(void* ctx, uint16_t status)
551 "MMIO read handler returning module state",
553 static uint64_t reset1_read(uc_engine* uc, uint64_t addr, unsigned size)
565 "zero-parameter canned return is still a stub",
567 ra8_err_t ra8_widget_calibrate(void)
569 return k_ra8_err_not_supported;
576 "zero-parameter getter returning module state",
578 uint32_t ra8_time_ms(void)
587 "fail-closed half of the placeholder-crypto guard",
589 #if defined(RA8_INSECURE_STUB_CRYPTO) || defined(RA8_OFF_TARGET)
590 ra8_err_t ra8_rsip_tamper_enable(uint32_t sources)
592 return simulate(sources);
595 ra8_err_t ra8_rsip_tamper_enable(uint32_t sources)
598 return k_ra8_err_not_supported;
608def selftest(tmp: Path) -> int:
609 """Assert the detector fires on stubs and stays silent on legitimate code."""
610 failures: list[str] = []
611 failures.extend(_selftest_source_scope(tmp))
612 groups: dict[str, list[tuple[str, str, bool]]] = {}
613 for label, body, should_fire, fname
in SELFTEST_CASES:
614 groups.setdefault(label, []).append((body, fname, should_fire))
616 for label, cases
in groups.items():
617 d = tmp / re.sub(
r"\W+",
"_", label)
618 d.mkdir(parents=
True, exist_ok=
True)
620 expect: dict[str, bool] = {}
621 for body, fname, should_fire
in cases:
625 expect[str(p)] = should_fire
626 fired = {v[1][
"path"]
for v
in analyse(files)}
627 for path, should_fire
in expect.items():
629 if did != should_fire:
630 verb =
"did not fire" if should_fire
else "fired"
631 failures.append(f
" {label} [{Path(path).name}]: gate {verb} (unexpected)")
634 print(
"check_no_silent_stubs.py --selftest: FAILED\n", file=sys.stderr)
635 print(
"\n".join(failures), file=sys.stderr)
637 total = len(SELFTEST_CASES)
638 fires = sum(1
for c
in SELFTEST_CASES
if c[2])
640 f
"check_no_silent_stubs.py --selftest: PASS "
641 f
"({total} cases: {fires} must fire, {total - fires} must stay silent)"
646def _scan_set_is_usable(files: list[Path]) -> bool:
647 """Whether the resolved scan set can be trusted; prints FATAL when not.
649 Fail loudly rather than silently passing on a broken scan: a gate that
650 reports success because it looked at nothing is worse than no gate. The
651 roots resolve relative to the current directory, so running this from
652 anywhere but the repository root finds nothing.
656 "check_no_silent_stubs.py: FATAL -- no first-party sources found.\n"
657 f
"Expected .c files under {', '.join(ROOTS)} relative to the current\n"
658 "directory. Run this from the repository root.",
662 missing = [str(p)
for p
in files
if not p.is_file()]
665 "check_no_silent_stubs.py: FATAL -- these paths do not exist:\n "
666 +
"\n ".join(missing),
673def _report_violations(violations: list[tuple[str, dict]]) ->
None:
674 """List every stub found, then explain what each rule means and how to fix it.
676 The trailing prose is long on purpose: both rules reject code that
677 compiles, links and looks deliberate, so a bare file:line would leave a
678 reader with no idea why the gate objects.
681 f
"check_no_silent_stubs.py: {len(violations)} silent stub(s) found:\n",
684 for rule, f
in sorted(violations, key=
lambda v: (v[1][
"path"], v[1][
"line"])):
685 print(f
" [{rule}] {f['path']}:{f['line']} {f['name']}()", file=sys.stderr)
687 for other
in f[
"shadows"]:
688 print(f
" real implementation lives in {other}", file=sys.stderr)
690 print(f
" discards its arguments, returns {f['returns']}", file=sys.stderr)
692 "\n[SHADOW] A second, do-nothing definition of a symbol that is really\n"
693 "implemented elsewhere in this tree. Whichever one the linker picks, the\n"
694 "build silently disables working code. Compile the real implementation\n"
695 "instead of redefining the symbol -- if it did not link, fix the build\n"
696 "recipe, do not fake the symbol.\n"
697 "\n[CANNED] The function reports that an operation was refused when no\n"
698 "implementation exists at all. Implement it, or delete it and update\n"
699 "every call site in the same change.\n"
700 "\nIf -- and only if -- the capability is blocked on hardware that does\n"
701 "not physically exist yet, mark it with a TODO naming the missing part:\n"
702 " TODO(ESP32-C6 radio module ordered, not yet on the bench)\n"
703 "A bare TODO with no named dependency is not a waiver, and no waiver is\n"
704 "available when a real implementation already exists in the tree.",
709def main(argv: list[str]) -> int:
710 """Scan first-party C for SHADOW and CANNED stubs, or run the detector selftest.
712 An empty source set is FATAL rather than clean, and the message says to run
713 from the repository root: the roots are resolved relative to the current
714 directory, so invoking this from elsewhere finds nothing and would
715 otherwise report a stub-free tree it never opened.
717 CI runs ``--selftest`` before the scan for the same reason in the other
718 direction -- a detector whose patterns stopped matching would also report
719 a clean tree, and only an assertion in both directions can tell the two
722 Returns 0 when no stub is found, 1 on a finding, on an empty source set,
723 on a named file that does not exist, or on a failing selftest.
725 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
726 ap.add_argument(
"files", nargs=
"*", help=
"specific files to scan")
730 help=
"prove the detector fires on stubs and not on legitimate code",
732 args = ap.parse_args(argv[1:])
735 with tempfile.TemporaryDirectory()
as td:
736 return selftest(Path(td))
738 files = first_party_sources(args.files)
739 if not _scan_set_is_usable(files):
742 violations = analyse(files)
744 print(f
"check_no_silent_stubs.py: OK ({len(files)} files scanned, no silent stubs)")
747 _report_violations(violations)
751if __name__ ==
"__main__":
752 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.