ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_selftest.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Regression tests for the annotation checker itself, on a synthetic tree.
4
5Every defect guarded here is one this gate has actually shipped, and they share
6a shape: the checker kept reporting a number, the number got smaller, and
7smaller read as better. So each assertion below is made in BOTH directions --
8the rule fires on the broken fixture *and* stays quiet on the correct one --
9because a rule can be "fixed" by defanging it and no single-direction test can
10tell the difference.
11
12The fixtures are laid out like ``libs/<module>/`` and ``tools/<tool>/`` so
13``module_of()`` and ``is_first_party()`` resolve against them, with the
14annotations spelled the way ``ra8_attributes.h`` lowers them so no project
15header is needed. Scope is pointed at the temporary tree through
16:func:`annot_scope.override_repo_root`, never by rebinding a module global:
17a rebinding is invisible to other modules' imports, which would leave this
18suite asserting against the real checkout and passing without proving anything.
19"""
20
21from __future__ import annotations
22
23import pathlib
24import re
25import sys
26import tempfile
27
28from annot_clang import _first_party_include_roots, cindex
29from annot_loopbound import run_loopbound_selftest
30from annot_model import AnnotatedSymbol, Violation, WalkState
31from annot_rules import enforce_rules
32from annot_scope import discover_translation_units, override_repo_root
33from annot_walk import walk_tu
34
35#: Synthetic TUs for run_selftest().
36#:
37#: Insertion order is load-bearing for the namesake case: it reproduces the
38#: walk order that once took dev red. The namesake `static` is walked first,
39#: so under a name-keyed table its USR was the one latched into the single
40#: merged entry while that entry's `file` was overwritten by the RA8_PRIV
41#: module's definition -- and a module's call to its own file-local helper
42#: was reported as a cross-module RA8_PRIV call.
43_SELFTEST_SOURCES: dict[str, str] = {
44 # --- ra8_priv namesake resolution -----------------------------------
45 # A namesake `static`, walked FIRST. Its call binds to its own
46 # file-local copy and must never be attributed to mod_priv's RA8_PRIV
47 # symbol. Three `internal_zero_bytes` and two `priv_byte_copy` have
48 # this exact shape in-tree.
49 "libs/mod_other/src/other.c": """
50[[clang::annotate("ra8_internal")]] static void shared_helper(unsigned char* p, unsigned short n)
51{
52 for (unsigned short i = 0U; i < n; ++i) {
53 p[i] = 0U;
54 }
55}
56
57[[clang::annotate("ra8_priv")]] void other_caller(unsigned char* p);
58
59void other_caller(unsigned char* p)
60{
61 shared_helper(p, 4U);
62}
63""",
64 # The RA8_PRIV owner: external linkage, tagged the way a *_internal.h
65 # does it. Clang propagates the attribute onto the definition in the
66 # same TU, which is how the merged entry used to pick up this path.
67 "libs/mod_priv/src/priv.c": """
68[[clang::annotate("ra8_priv")]] void shared_helper(unsigned char* p, unsigned int n);
69[[clang::annotate("ra8_priv")]] void priv_owner_caller(unsigned char* p);
70
71void shared_helper(unsigned char* p, unsigned int n)
72{
73 for (unsigned int i = 0U; i < n; ++i) {
74 p[i] = 0U;
75 }
76}
77
78void priv_owner_caller(unsigned char* p)
79{
80 shared_helper(p, 4U);
81}
82""",
83 # A genuine cross-module call to the external RA8_PRIV symbol. This one
84 # MUST still be reported, otherwise the namesake fix would have
85 # silenced the rule rather than made it accurate.
86 "libs/mod_stranger/src/stranger.c": """
87[[clang::annotate("ra8_priv")]] void stranger_caller(unsigned char* p);
88
89void shared_helper(unsigned char* p, unsigned int n);
90
91void stranger_caller(unsigned char* p)
92{
93 shared_helper(p, 4U);
94}
95""",
96 # --- linkage rule: the four ways a definition passes ------------------
97 "libs/mod_link/inc/mod_link.h": """
98#pragma once
99void link_public_api(void);
100""",
101 "libs/mod_link/src/mod_link_internal.h": """
102#pragma once
103void link_internal_declared(void);
104[[clang::annotate("ra8_priv")]] void link_internal_annotated(void);
105""",
106 "libs/mod_link/src/pass.c": """
107#include "mod_link.h"
108#include "mod_link_internal.h"
109
110/* Published by the library's public inc/ header: public API, no annotation. */
111void link_public_api(void) {}
112
113/* Declared in the *_internal.h AND tagged: the sanctioned cross-TU shape. */
114void link_internal_annotated(void) {}
115
116/* Tagged in place, declared nowhere: still fine, the tag is the statement. */
117[[clang::annotate("ra8_test_helper")]] void link_test_hook(void) {}
118
119/* static + RA8_INTERNAL: out of scope for the rule entirely. */
120[[clang::annotate("ra8_internal")]] static void link_file_local(void) {}
121
122int main(void)
123{
124 link_file_local();
125 return 0;
126}
127""",
128 # --- linkage rule: the two ways a definition fails --------------------
129 "libs/mod_link/src/fail.c": """
130#include "mod_link_internal.h"
131
132/* Declared library-private but never classified -> wants RA8_PRIV. */
133void link_internal_declared(void) {}
134
135/* Nothing declares it and no table names it -> wants static or a header. */
136void link_undeclared(void) {}
137""",
138 # --- linkage rule: the vector-table exemption -------------------------
139 # Two byte-identical handlers. One is named by the table, one is not.
140 # If the exemption were keyed on a name pattern instead of on table
141 # membership, both would pass and the rule would be blind to any
142 # handler-shaped symbol anyone chose to leave unwired.
143 "libs/mod_link/src/vectors.c": """
144void handler_tabled(void);
145void handler_untabled(void);
146
147void handler_tabled(void) {}
148void handler_untabled(void) {}
149
150void (*const g_vector_table[])(void) = {
151 handler_tabled,
152};
153""",
154 # --- NASA P10 Rule 3: firmware allocates -> reported ------------------
155 # Both helpers are `static` so the linkage rule has no opinion on them
156 # and the only thing under test is the allocation sweep.
157 "libs/mod_alloc/src/alloc.c": """
158void* malloc(unsigned long n);
159void free(void* p);
160
161/* Firmware, no waiver: both the malloc and the free must be reported. */
162[[clang::annotate("ra8_internal")]] static void fw_untagged_allocator(void)
163{
164 void* p = malloc(16UL);
165 free(p);
166}
167
168/* Firmware WITH the documented waiver: the sweep must leave it alone. */
169[[clang::annotate("ra8_nasa_rule_3_ok")]] static void fw_waived_allocator(void)
170{
171 void* p = malloc(16UL);
172 free(p);
173}
174""",
175 # --- RA8_EXPECTS_LOCK: the three caller shapes ------------------------
176 # The rule used to demand a preceding call to `RA8_TAKE_LOCK`, which does
177 # not exist in this tree in any form -- so it could not be satisfied and
178 # had zero uses. All three shapes below are asserted, because "made
179 # satisfiable" and "defanged" look identical from one direction.
180 "libs/mod_lock/src/lock.c": """
181[[clang::annotate("ra8_priv")]] void lock_take(void);
182[[clang::annotate("ra8_priv")]]
183[[clang::annotate("ra8_releases_resource:bus")]] void lock_drop(void);
184[[clang::annotate("ra8_priv")]]
185[[clang::annotate("ra8_expects_lock:bus")]] void lock_guarded_body(void);
186
187void lock_take(void) {}
188void lock_drop(void) {}
189void lock_guarded_body(void) {}
190
191[[clang::annotate("ra8_priv")]]
192[[clang::annotate("ra8_releases_resource:other")]] void lock_drop_other(void);
193
194void lock_drop_other(void) {}
195
196/* PASSES: takes the lock for its whole body and discharges it. */
197[[clang::annotate("ra8_priv")]]
198[[clang::annotate("ra8_owns_resource:bus")]] void lock_owner_caller(void);
199
200void lock_owner_caller(void)
201{
202 lock_take();
203 lock_guarded_body();
204 lock_drop();
205}
206
207/* PASSES: entered under the lock, propagating the contract upward. */
208[[clang::annotate("ra8_priv")]]
209[[clang::annotate("ra8_expects_lock:bus")]] void lock_nested_body(void);
210
211void lock_nested_body(void)
212{
213 lock_guarded_body();
214}
215
216/* FAILS: reaches the guarded body holding nothing. */
217[[clang::annotate("ra8_priv")]] void lock_bare_caller(void);
218
219void lock_bare_caller(void)
220{
221 lock_guarded_body();
222}
223
224/* FAILS: owns a DIFFERENT lock. The name has to match, or one mutex would
225 silently discharge another mutex's contract. */
226[[clang::annotate("ra8_priv")]]
227[[clang::annotate("ra8_owns_resource:other")]] void lock_wrong_name_caller(void);
228
229void lock_wrong_name_caller(void)
230{
231 lock_guarded_body();
232 lock_drop_other();
233}
234""",
235 # --- tools/ is in scope, and is host-only -----------------------------
236 # This fixture carries the whole point of widening SCAN_DIRS to tools/,
237 # in both directions at once. `host_unpublished` proves the linkage rule
238 # genuinely reaches into tools/ -- if tools/ fell back out of SCAN_DIRS,
239 # is_first_party() would drop it and the selftest fails. `host_allocator`
240 # proves the Rule 3 sweep does NOT fire there, so the widening cannot be
241 # "passed" by burying a host emulator under 216 unactionable findings.
242 "tools/mod_host/src/host_tool.c": """
243void* malloc(unsigned long n);
244void free(void* p);
245
246[[clang::annotate("ra8_internal")]] static void host_allocator(void)
247{
248 void* p = malloc(16UL);
249 free(p);
250}
251
252/* Non-static, no header declares it: in scope, and a genuine gap. */
253void host_unpublished(void)
254{
255 host_allocator();
256}
257
258/* RA8_PRIV inside tools/mod_host. Reaching this from another tool is the
259 same boundary violation as one library calling another's private helper. */
260[[clang::annotate("ra8_priv")]] void host_priv_helper(void);
261
262void host_priv_helper(void) {}
263""",
264 # --- apps/ host-only boundary after the products reorganization -------
265 # Only apps/host is exempt. The portable and board forms can reach a
266 # firmware image and must retain the Rule 3 allocation ban.
267 "apps/host/mod_alloc/src/host_alloc.c": """
268void* malloc(unsigned long n);
269
270[[clang::annotate("ra8_internal")]] static void host_product_allocator(void)
271{
272 (void)malloc(16UL);
273}
274""",
275 "apps/board/stand_alone/mod_alloc/src/board_alloc.c": """
276void* malloc(unsigned long n);
277
278[[clang::annotate("ra8_internal")]] static void board_product_allocator(void)
279{
280 (void)malloc(16UL);
281}
282""",
283 "apps/shared_libs/mod_alloc/src/shared_alloc.c": """
284void* malloc(unsigned long n);
285
286[[clang::annotate("ra8_internal")]] static void shared_product_allocator(void)
287{
288 (void)malloc(16UL);
289}
290""",
291 "apps/shared_libs/mod_alloc/tests/src/test_alloc.c": """
292void* malloc(unsigned long n);
293void free(void* p);
294
295[[clang::annotate("ra8_internal")]] static void host_test_wrapper_allocator(void)
296{
297 void* p = malloc(16UL);
298 free(p);
299}
300""",
301 # --- exact generated-source boundary ---------------------------------
302 # The reviewed protoc-c output is classified by lint coverage as generated
303 # and must never be judged as hand-authored naming/linkage. A neighboring
304 # generated-looking file has no such classification and MUST remain in
305 # scope, preventing a broad suffix exemption.
306 "libs/ra8_c6link/src/ra8_media_download.pb-c.c": """
307void generated_unpublished(void) {}
308""",
309 "libs/ra8_c6link/src/future_generated.pb-c.c": """
310void future_generated_unpublished(void) {}
311""",
312 # --- apps/: a module is the PRODUCT, across its build forms -----------
313 # mod_product's portable core, under the shared category.
314 "apps/shared_libs/mod_product/src/product_core.c": """
315[[clang::annotate("ra8_priv")]] void product_priv_helper(void);
316
317void product_priv_helper(void) {}
318""",
319 # The SAME product's host composition root, in a different category. A
320 # build form driving its own core's promoted seam is what a composition
321 # root is for, so this must stay QUIET.
322 "apps/host/mod_product/src/product_form.c": """
323[[clang::annotate("ra8_priv")]] void product_form_caller(void);
324
325void product_priv_helper(void);
326
327void product_form_caller(void)
328{
329 product_priv_helper();
330}
331""",
332 # A DIFFERENT product in the same category reaching into mod_product's
333 # core. Same boundary violation as one library calling another's private
334 # helper, and it must still FIRE -- otherwise dropping the category from
335 # the key would have bought the quiet case by going blind.
336 "apps/host/mod_stranger/src/stranger_form.c": """
337[[clang::annotate("ra8_priv")]] void stranger_form_caller(void);
338
339void product_priv_helper(void);
340
341void stranger_form_caller(void)
342{
343 product_priv_helper();
344}
345""",
346 # A second tool calling the first one's RA8_PRIV symbol. module_of() has
347 # to resolve tools/<tool> as a module for this to be caught at all.
348 "tools/mod_other_host/src/other_host.c": """
349[[clang::annotate("ra8_priv")]] void other_host_caller(void);
350
351void host_priv_helper(void);
352
353void other_host_caller(void)
354{
355 host_priv_helper();
356}
357""",
358 # --- naming/linkage vocabulary: both failing and passing shapes ------
359 "libs/mod_name/inc/mod_name.h": """
360#pragma once
361void naming_public_entry(void);
362void internal_external_bad(void);
363void priv_public_bad(void);
364static void header_static_bad(void) {}
365static inline void naming_inline_public(void) {}
366""",
367 "libs/mod_name/src/mod_name_internal.h": """
368#pragma once
369[[clang::annotate("ra8_priv")]] void priv_naming_good(void);
370[[clang::annotate("ra8_priv")]] void bad_priv_name(void);
371""",
372 "libs/mod_name/src/naming.c": """
373#include "mod_name.h"
374#include "mod_name_internal.h"
375
376[[clang::annotate("ra8_internal")]] static void internal_naming_good(void) {}
377static void internal_missing_annotation(void) {}
378[[clang::annotate("ra8_internal")]] static void wrong_internal_name(void) {}
379[[clang::annotate("ra8_internal")]] static void s_bad_function(void) {}
380[[clang::annotate("ra8_internal")]] void internal_annotated_external_bad(void) {}
381[[clang::annotate("ra8_internal")]]
382[[clang::annotate("ra8_priv")]] static void internal_conflict_bad(void) {}
383[[clang::annotate("ra8_test_helper")]] static void internal_static_test_helper_bad(void) {}
384
385static int s_good_data;
386static int bad_static_data;
387
388void priv_naming_good(void) {}
389void bad_priv_name(void) {}
390[[clang::annotate("ra8_priv")]] void priv_missing_header(void) {}
391void internal_external_bad(void) {}
392void priv_public_bad(void) {}
393[[clang::annotate("ra8_test_helper")]] void naming_test_helper_good(void) {}
394
395void naming_public_entry(void)
396{
397 int s_bad_local = 0;
398 internal_naming_good();
399 internal_missing_annotation();
400 wrong_internal_name();
401 s_bad_function();
402 internal_annotated_external_bad();
403 internal_conflict_bad();
404 internal_static_test_helper_bad();
405 naming_test_helper_good();
406 naming_inline_public();
407 s_good_data = s_bad_local;
408 bad_static_data = s_good_data;
409}
410""",
411 "libs/mod_name/src/naming_cpp.cpp": """
412namespace {
413void anonymous_namespace_bad() {}
414[[clang::annotate("ra8_internal")]] static void internal_cpp_good() {}
415}
416""",
417 # --- recursive include-root discovery after the src/inc migration ---
418 "tests/mocks/inc/mock_contract.h": "#pragma once\n",
419 "tests/support/inc/support_contract.h": """
420#pragma once
421void support_public_entry(void);
422""",
423 "tests/consumer/src/test_support_include.c": """
424#include "support_contract.h"
425void support_public_entry(void) {}
426""",
427 "examples/board/state/demo/inc/demo_contract.h": "#pragma once\n",
428 "tests/mocks/src/mock_contract_internal.h": "#pragma once\n",
429 # A src/ directory containing only an ordinary header is deliberately
430 # not an include root. The placement gate rejects this shape; accepting it
431 # here would hide that defect and widen header-name shadowing.
432 "tests/plain/src/plain_header.h": "#pragma once\n",
433 "tests/build/generated/inc/ignored_contract.h": "#pragma once\n",
434}
435
436#: What run_selftest() expects the linkage rule to report, by symbol name.
437#: ``handler_untabled`` is here because it is byte-identical to a handler
438#: the table does name: table membership is the only thing separating them.
439#: ``host_unpublished`` lives under ``tools/`` and is the scope assertion:
440#: the linkage rule only judges files is_first_party() accepts, so this name
441#: goes missing the moment ``tools`` drops out of SCAN_DIRS.
442_SELFTEST_LINKAGE_EXPECTED = frozenset(
443 {
444 "future_generated_unpublished",
445 "handler_untabled",
446 "host_unpublished",
447 "link_internal_declared",
448 "link_undeclared",
449 }
450)
451
452#: What run_selftest() expects the RA8_EXPECTS_LOCK rule to report, by the
453#: CALLER's name -- the finding is located at the call site, not the callee.
454_SELFTEST_LOCK_EXPECTED = frozenset({"lock_bare_caller", "lock_wrong_name_caller"})
455
456#: Definitions the linkage rule must leave alone -- one per passing shape,
457#: plus the un-tabled handler's twin that proves the exemption is keyed on
458#: table membership rather than on what the function looks like.
459_SELFTEST_LINKAGE_CLEAN = (
460 "link_public_api",
461 "link_internal_annotated",
462 "link_test_hook",
463 "link_file_local",
464 "main",
465 "handler_tabled",
466 "lock_guarded_body",
467 "lock_owner_caller",
468 "lock_nested_body",
469 "support_public_entry",
470)
471
472
473#: What _selftest_parse() returns: the symbol table, the call list and the
474#: set of USRs a vector table names.
475def _selftest_parse(root: pathlib.Path) -> WalkState:
476 """Write the synthetic tree under ``root`` and walk every TU in it."""
477 for rel, body in _SELFTEST_SOURCES.items():
478 path = root / rel
479 path.parent.mkdir(parents=True, exist_ok=True)
480 path.write_text(body)
481
482 # Use the production discovery path. Constructing this list from the
483 # fixture dictionary would bypass the exact generated-source exclusion and
484 # let that boundary regress while every rule-level assertion stayed green.
485 tu_paths = discover_translation_units()
486
487 include_args = [f"-I{path}" for path in _first_party_include_roots()]
488 state = WalkState()
489 index = cindex.Index.create()
490 for path in tu_paths:
491 language_args = (
492 ["-std=c++23", "-x", "c++"] if path.suffix == ".cpp" else ["-std=c23", "-x", "c"]
493 )
494 tu = index.parse(
495 str(path),
496 args=[*language_args, *include_args],
497 options=cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD,
498 )
499 walk_tu(tu, state)
500 return state
501
502
503def _names_matching(violations: list[Violation], rule: str, pattern: str) -> set[str]:
504 """Pull the symbol names out of every ``rule`` finding matching ``pattern``."""
505 out: set[str] = set()
506 for v in violations:
507 if v.rule != rule:
508 continue
509 m = re.search(pattern, v.message)
510 if m:
511 out.add(m.group(1))
512 return out
513
514
515def _check_priv_namesakes(
516 violations: list[Violation], symbols: dict[str, AnnotatedSymbol]
517) -> list[str]:
518 """RA8_PRIV must separate namesakes by USR without going toothless."""
519 offenders = {
520 pathlib.Path(v.file).name
521 for v in violations
522 if v.rule == "ra8_priv" and "called from outside" in v.message
523 }
524 failures: list[str] = []
525 if "other.c" in offenders:
526 failures.append(
527 "namesake regression: a module's call to its own file-local static "
528 "'shared_helper' was reported as a cross-module RA8_PRIV call"
529 )
530 if "priv.c" in offenders:
531 failures.append(
532 "same-module regression: mod_priv calling its own RA8_PRIV symbol was reported"
533 )
534 if "stranger.c" not in offenders:
535 failures.append(
536 "ra8_priv went toothless: the genuine cross-module call to RA8_PRIV "
537 "'shared_helper' from mod_stranger was NOT reported"
538 )
539 if "other_host.c" not in offenders:
540 failures.append(
541 "ra8_priv is blind under tools/: mod_other_host calls mod_host's "
542 "RA8_PRIV 'host_priv_helper' across a module boundary and it was NOT "
543 "reported -- module_of() is not resolving tools/<tool> as a module, so "
544 "every RA8_PRIV tag under tools/ is decorative"
545 )
546 if "product_form.c" in offenders:
547 failures.append(
548 "build-form regression: apps/host/mod_product driving its own "
549 "core's RA8_PRIV 'product_priv_helper' was reported -- module_of() is "
550 "keying apps/ on the category, so a product split across build forms "
551 "reads as two libraries"
552 )
553 if "stranger_form.c" not in offenders:
554 failures.append(
555 "ra8_priv went toothless under apps/: mod_stranger reaches into "
556 "mod_product's core RA8_PRIV 'product_priv_helper' and it was NOT "
557 "reported -- dropping the category from the module key must not drop "
558 "the boundary between two products"
559 )
560
561 # The merged symbol table is the underlying defect, so assert its shape
562 # directly too: the namesakes must stay distinct entries.
563 namesakes = [s for s in symbols.values() if s.name == "shared_helper"]
564 expected_namesakes = 2 # one external (mod_priv) + one static (mod_other)
565 if len(namesakes) != expected_namesakes:
566 failures.append(
567 f"symbol table merged namesakes: expected {expected_namesakes} distinct "
568 f"'shared_helper' entries, found {len(namesakes)}"
569 )
570 return failures
571
572
573def _check_naming_contract(violations: list[Violation]) -> list[str]:
574 """Naming/linkage prefixes must agree with AST storage and scope."""
575 fixture = [v for v in violations if "mod_name" in pathlib.Path(v.file).parts]
576 # The generic helper returns only group 1; every naming message deliberately
577 # puts the subject in the first quoted field, so collect it directly here.
578 naming = {
579 match.group(1)
580 for finding in fixture
581 if finding.rule == "ra8_naming"
582 if (match := re.search(r"'([^']+)'", finding.message)) is not None
583 }
584 expected = {
585 "internal_missing_annotation",
586 "wrong_internal_name",
587 "s_bad_function",
588 "internal_annotated_external_bad",
589 "bad_static_data",
590 "s_bad_local",
591 "priv_missing_header",
592 "internal_external_bad",
593 "priv_public_bad",
594 "header_static_bad",
595 "internal_conflict_bad",
596 "internal_static_test_helper_bad",
597 "anonymous_namespace_bad",
598 }
599 clean = {
600 "internal_naming_good",
601 "s_good_data",
602 "priv_naming_good",
603 "naming_inline_public",
604 "internal_cpp_good",
605 "naming_test_helper_good",
606 }
607 failures = [
608 f"ra8_naming went toothless: broken fixture '{name}' was not reported"
609 for name in sorted(expected - naming)
610 ]
611 failures.extend(
612 f"ra8_naming false positive: conforming fixture '{name}' was reported"
613 for name in sorted(clean & naming)
614 )
615
616 priv = {
617 match.group(1)
618 for finding in fixture
619 if finding.rule == "ra8_naming" and "RA8_PRIV function" in finding.message
620 if (match := re.search(r"'([^']+)'", finding.message)) is not None
621 }
622 if "bad_priv_name" not in priv:
623 failures.append("ra8_priv accepted a module-private function without the priv_ prefix")
624 if "priv_naming_good" in priv:
625 failures.append("ra8_priv rejected the conforming non-static priv_ fixture")
626 return failures
627
628
629def _check_linkage(violations: list[Violation]) -> list[str]:
630 """The linkage rule must catch both gap shapes and exempt only tabled handlers."""
631 linkage = _names_matching(violations, "ra8_linkage", r"'([^']+)'")
632 failures = [
633 f"ra8_linkage went toothless: '{name}' has external linkage that "
634 f"nothing justifies, and the rule did not report it"
635 for name in sorted(_SELFTEST_LINKAGE_EXPECTED - linkage)
636 ]
637 failures.extend(
638 f"ra8_linkage false positive: '{name}' is a justified definition but the rule reported it"
639 for name in sorted(linkage - _SELFTEST_LINKAGE_EXPECTED)
640 )
641 if "handler_untabled" not in linkage:
642 failures.append(
643 "vector-table exemption over-matches: 'handler_untabled' is byte-identical "
644 "to a tabled handler but appears in no table, so it must still be reported"
645 )
646 return failures
647
648
649def _check_expects_lock(violations: list[Violation]) -> list[str]:
650 """RA8_EXPECTS_LOCK must fire on an unheld call and stay quiet on a held one.
651
652 The "quiet" direction is the one that matters here: the rule shipped for
653 the life of the tree keyed on a ``RA8_TAKE_LOCK`` call that no first-party
654 file could produce, so EVERY caller of an annotated function was a
655 violation and the annotation had to go unused to keep the gate green. A
656 rule nobody can satisfy and a rule nobody wrote are indistinguishable from
657 the gate's output.
658 """
659 callers = _names_matching(violations, "ra8_expects_lock", r"from '([^']+)'")
660 failures = [
661 f"ra8_expects_lock went toothless: '{name}' reaches a guarded body "
662 f"without holding the named lock and was not reported"
663 for name in sorted(_SELFTEST_LOCK_EXPECTED - callers)
664 ]
665 failures.extend(
666 f"ra8_expects_lock false positive: '{name}' holds the named lock "
667 f"(RA8_OWNS_RESOURCE) or was entered under it (RA8_EXPECTS_LOCK), "
668 f"which is exactly how the macro says the contract is met"
669 for name in sorted(callers - _SELFTEST_LOCK_EXPECTED)
670 )
671 return failures
672
673
674def _check_rule3(violations: list[Violation]) -> list[str]:
675 """NASA P10 Rule 3, both axes: the waiver and the firmware/host boundary."""
676 allocators = _names_matching(violations, "ra8_nasa_rule_3_ok", r"from '([^']+)'")
677 failures: list[str] = []
678 if "fw_untagged_allocator" not in allocators:
679 failures.append(
680 "ra8_nasa_rule_3_ok went toothless: firmware function "
681 "'fw_untagged_allocator' calls malloc/free with no waiver and was not reported"
682 )
683 if "fw_waived_allocator" in allocators:
684 failures.append(
685 "ra8_nasa_rule_3_ok false positive: 'fw_waived_allocator' carries "
686 "RA8_NASA_RULE_3_OK, which is exactly the documented waiver"
687 )
688 if "host_allocator" in allocators:
689 failures.append(
690 "ra8_nasa_rule_3_ok false positive: 'host_allocator' is under tools/, "
691 "which the host toolchain compiles and no firmware image contains -- "
692 "Rule 3 is a claim about firmware"
693 )
694 if "host_product_allocator" in allocators:
695 failures.append("ra8_nasa_rule_3_ok false positive: apps/host is a hosted product form")
696 if "host_test_wrapper_allocator" in allocators:
697 failures.append(
698 "ra8_nasa_rule_3_ok false positive: a host test wrapper under "
699 "apps/shared_libs/.../tests cannot enter a firmware image"
700 )
701 failures.extend(
702 "ra8_nasa_rule_3_ok scope regression: "
703 f"'{name}' can be linked into firmware but was treated as host-only"
704 for name in ("board_product_allocator", "shared_product_allocator")
705 if name not in allocators
706 )
707 return failures
708
709
710def _check_fixtures_parsed(symbols: dict[str, AnnotatedSymbol]) -> list[str]:
711 """Every fixture the clean-shape assertions rely on must have parsed."""
712 seen = {s.name for s in symbols.values()}
713 return [
714 f"selftest fixture did not parse: '{name}' is missing from the "
715 f"symbol table, so its linkage shape was never exercised"
716 for name in _SELFTEST_LINKAGE_CLEAN
717 if name not in seen
718 ]
719
720
721def _check_generated_scope(symbols: dict[str, AnnotatedSymbol]) -> list[str]:
722 """Exact generated files stay out while generated-looking neighbors stay in."""
723 seen = {symbol.name for symbol in symbols.values()}
724 failures: list[str] = []
725 if "generated_unpublished" in seen:
726 failures.append(
727 "generated-source exclusion failed: the exact protoc-c output was parsed as "
728 "hand-authored first-party code"
729 )
730 if "future_generated_unpublished" not in seen:
731 failures.append(
732 "generated-source exclusion over-matches: an unclassified neighboring pb-c.c "
733 "file disappeared from the annotation scope"
734 )
735 return failures
736
737
738def _check_include_root_discovery(root: pathlib.Path) -> list[str]:
739 """Public ``inc`` and sanctioned private ``src`` roots survive any depth."""
740 actual = {path.relative_to(root).as_posix() for path in _first_party_include_roots()}
741 expected = {
742 "examples/board/state/demo/inc",
743 "libs/mod_link/inc",
744 "libs/mod_link/src",
745 "libs/mod_name/inc",
746 "libs/mod_name/src",
747 "tests/mocks/inc",
748 "tests/mocks/src",
749 "tests/support/inc",
750 }
751 failures = [
752 f"include-root discovery missed conventional path '{path}'"
753 for path in sorted(expected - actual)
754 ]
755 forbidden = {"tests/build/generated/inc", "tests/plain/src"}
756 failures.extend(
757 f"include-root discovery accepted excluded or non-private path '{path}'"
758 for path in sorted(forbidden & actual)
759 )
760 return failures
761
762
763def run_selftest() -> int:
764 """Regression-test the checker itself. Returns a process exit code.
765
766 Four classes of defect are guarded, all of which this gate has shipped:
767
768 * **Namesake merging.** Keying the symbol table by bare name merged
769 distinct same-named functions into one entry, so a module calling its
770 own file-local ``static`` was reported for calling another module's
771 RA8_PRIV symbol.
772 * **A rule that cannot fire.** The linkage rule turns "nothing declares
773 this" into a failure, and a rule which reports nothing looks exactly
774 like a clean tree. The synthetic module contains one definition of
775 every passing shape and one of every failing shape.
776 * **A root silently out of scope.** ``tools/`` was absent from SCAN_DIRS,
777 so ra8_emulator, mdl and ra8_viewer were never checked and the gate
778 reported a clean tree over code it had not read. The ``tools/`` fixture
779 pins the scope from inside the rules rather than by reading the
780 constant: ``host_unpublished`` is only reachable if is_first_party()
781 accepts the root.
782 * **A rule that cannot be satisfied.** ``RA8_EXPECTS_LOCK`` demanded a
783 ``RA8_TAKE_LOCK`` call that exists nowhere in this tree and could not,
784 since callee names resolve after macro expansion -- so the annotation
785 was unusable and went unused, and the gate looked clean because nobody
786 could adopt the rule. The lock fixture asserts both directions.
787 * **NASA P10 Rule 3 scope and waiver.** Rule 3 is a claim about firmware,
788 so it is asserted along both axes -- untagged firmware allocation
789 fires, the documented waiver does not, and host-only code under
790 ``tools/`` does not. Getting the second axis wrong is what turns a real
791 gate into 216 findings nobody can act on.
792 * **Generated-source scope.** The exact reproducible protoc-c output is
793 excluded by the lint-coverage registry, while an unclassified neighboring
794 ``*.pb-c.c`` remains ordinary first-party C and must still be judged.
795 * **Include-root discovery.** Public ``inc/`` directories at multiple
796 product depths and sanctioned ``src/*_internal.h`` directories are found,
797 while build output and an ordinary misplaced ``src`` header stay out.
798 """
799 with tempfile.TemporaryDirectory() as td:
800 root = pathlib.Path(td).resolve()
801 with override_repo_root(root):
802 state = _selftest_parse(root)
803 include_root_failures = _check_include_root_discovery(root)
804 violations = enforce_rules(
805 state,
806 naming_contract=True,
807 )
808
809 failures = [
810 *_check_priv_namesakes(violations, state.symbols),
811 *_check_linkage(violations),
812 *_check_expects_lock(violations),
813 *_check_rule3(violations),
814 *_check_naming_contract(violations),
815 *_check_fixtures_parsed(state.symbols),
816 *_check_generated_scope(state.symbols),
817 *include_root_failures,
818 # The loop-bound scan is textual and libclang-free, so it self-tests on
819 # synthetic source strings rather than the parsed synthetic tree.
820 *run_loopbound_selftest(),
821 ]
822 if failures:
823 for f in failures:
824 sys.stderr.write(f"[FAIL] check_annotations selftest: {f}\n")
825 sys.stderr.write(f"check_annotations selftest: {len(failures)} failure(s)\n")
826 return 1
827 print(
828 "check_annotations selftest: OK (namesakes resolved by USR; linkage rule "
829 "catches both gap shapes, reaches tools/, and exempts only tabled handlers; "
830 "NASA rule 3 fires on untagged firmware allocation and stays quiet on the "
831 "documented waiver and on host-only code; RA8_EXPECTS_LOCK fires on an "
832 "unheld call and stays quiet on RA8_OWNS_RESOURCE / propagated holders; "
833 "the exact generated protoc-c source is excluded while an unclassified "
834 "pb-c.c neighbor remains in scope; "
835 "recursive inc/ and sanctioned private src/ include roots are discovered; "
836 "linkage prefixes agree with static/data scope and their annotations; "
837 "loop-bound scan fires on a "
838 "mis-attached marker and a stale RA8_BOUNDED_LOOP statement, stays quiet on "
839 "correct markers and on #define/comment/string mentions)"
840 )
841 return 0