4"""Ratchet concrete RA8 driver reach-ins outside HAL and board composition.
6The platform-architecture migration (#692) introduces neutral ``fw_if_*``
7ports between portable logic and silicon-specific drivers. Today that seam is
8incomplete: first-party production code directly names ``ra8_cgc_*``,
9``ra8_glcdc_*``, ``ra8_gpio_*`` and ``ra8_gpt_*`` symbols. Removing every
10reach-in is incremental work under #693, so a zero-debt gate would be a cliff.
12This checker freezes the existing debt per ``(file, family)`` in
13``.github/agnostic-register-baseline.txt``. A new or increased bucket fails;
14shrinkage passes and asks for a re-baseline. Counts ignore comments and string
15literals, so documentation does not masquerade as a dependency.
17Scope is first-party C/C++ production code under ``libs/``, ``port/``,
18``examples/``, ``tools/`` and ``apps/``. Concrete HAL implementations, named
19backend translation units, and board composition libraries are allowed to name
20the concrete symbols. Tests are outside the production layering policy and
21may exercise a concrete driver directly. Vendored and generated sources
22inherit ``lint_targets`` exclusions.
24This is invariant 5 from #698. The prefix-based neutral-code invariants 1 and
252 become enforceable with the final rename (#697). The no-RTOS-symbol and
26architecture-capability invariants belong to #695 and #694 respectively; they
27are deliberately not duplicated here.
31 python3 scripts/checks/check_agnostic_registers.py --selftest
32 python3 scripts/checks/check_agnostic_registers.py --check
33 python3 scripts/checks/check_agnostic_registers.py --update
34 python3 scripts/checks/check_agnostic_registers.py --list
37from __future__
import annotations
45from collections
import Counter
46from dataclasses
import dataclass
47from pathlib
import Path, PurePosixPath
49sys.path.insert(0, str(Path(__file__).resolve().parent))
51from check_magic_numbers
import _strip_comments_and_strings
52from lint_targets
import first_party_paths
54REPO_ROOT = Path(__file__).resolve().parents[2]
55BASELINE_FILE = REPO_ROOT /
".github" /
"agnostic-register-baseline.txt"
57SOURCE_SUFFIXES = (
".c",
".h",
".cpp",
".hpp",
".cc",
".cxx",
".hh",
".hxx")
58POLICY_ROOTS = frozenset({
"libs",
"port",
"examples",
"tools",
"apps"})
62 "apps/shared_libs/third_party/",
65EXCLUDED_BACKEND_FILES = frozenset(
67 "libs/ra8_display_pal/src/ra8_display_pal_lcd.c",
71FAMILY_PATTERNS: dict[str, re.Pattern[str]] = {
72 family: re.compile(rf
"\bra8_{prefix}_[A-Za-z0-9_]+\b")
73 for family, prefix
in (
81MIN_SCANNED_FILES = 900
82"""Reject a live scan that lost a material part of its production scope.
84The baseline was seeded with 1,191 eligible files. A 900-file floor tolerates
85real consolidation while catching a missing top-level tree or broken path
86filter before the resulting smaller count can be mistaken for burn-down.
91SELFTEST_ELIGIBLE_FILES = 2
94@dataclass(frozen=True)
96 """One concrete-driver symbol found in policy-controlled code."""
104def is_policy_source(rel: str) -> bool:
105 """Return whether ``rel`` is production code governed by this ratchet."""
106 path = PurePosixPath(rel)
107 if not path.parts
or path.parts[0]
not in POLICY_ROOTS:
110 not rel.endswith(SOURCE_SUFFIXES)
111 or rel.startswith(EXCLUDED_PREFIXES)
112 or rel
in EXCLUDED_BACKEND_FILES
116 len(path.parts) > 1
and path.parts[0] ==
"libs" and path.parts[1].startswith(
"ra8_board_")
120def scan_file(path: Path, rel: str) -> list[Finding]:
121 """Return concrete-driver references in one source file."""
122 text = path.read_text(encoding=
"utf-8", errors=
"replace")
123 code_lines = _strip_comments_and_strings(text).splitlines()
124 findings: list[Finding] = []
125 for line_number, line
in enumerate(code_lines, 1):
126 for family, pattern
in FAMILY_PATTERNS.items():
128 Finding(rel, line_number, family, match.group(0))
129 for match
in pattern.finditer(line)
134def scan_files(files: list[Path], root: Path) -> tuple[list[Finding], int]:
135 """Scan explicit files and return ``(findings, eligible_file_count)``."""
136 findings: list[Finding] = []
140 rel = path.relative_to(root).as_posix()
143 if not is_policy_source(rel):
146 findings.extend(scan_file(path, rel))
147 return findings, eligible
150def bucket(findings: list[Finding]) -> Counter[tuple[str, str]]:
151 """Reduce findings to stable per-file, per-family counts."""
152 return Counter((finding.path, finding.family)
for finding
in findings)
155def parse_baseline(text: str) -> Counter[tuple[str, str]]:
156 """Parse baseline text, rejecting malformed or duplicate rows."""
157 counts: Counter[tuple[str, str]] = Counter()
158 for line_number, raw
in enumerate(text.splitlines(), 1):
160 if not line
or line.startswith(
"#"):
162 parts = line.split(
"\t")
163 if len(parts) != BASELINE_COLUMNS:
164 message = f
"line {line_number}: expected path<TAB>family<TAB>count"
165 raise ValueError(message)
166 path, family, count_text = parts
167 if family
not in FAMILY_PATTERNS:
168 message = f
"line {line_number}: unknown family {family!r}"
169 raise ValueError(message)
171 count = int(count_text)
172 except ValueError
as exc:
173 message = f
"line {line_number}: invalid count {count_text!r}"
174 raise ValueError(message)
from exc
176 if count < 1
or key
in counts:
177 message = f
"line {line_number}: non-positive or duplicate bucket"
178 raise ValueError(message)
183def load_baseline() -> Counter[tuple[str, str]]:
184 """Load the committed baseline; a missing file means zero allowed debt."""
185 if not BASELINE_FILE.is_file():
187 return parse_baseline(BASELINE_FILE.read_text(encoding=
"ascii"))
190def format_baseline(counts: Counter[tuple[str, str]]) -> str:
191 """Return stable, reviewable baseline text for ``counts``."""
193 for (_path, family), count
in counts.items():
194 totals[family] += count
196 "# Concrete RA8 driver reach-in debt, per (file, peripheral family).",
197 "# Consumed by scripts/checks/check_agnostic_registers.py --check",
198 "# (CI gate: agnostic-registers; issue #698).",
200 "# New or increased counts fail. Shrinkage passes and should be locked in",
201 "# with --update. This baseline may only shrink; do not add new debt.",
202 "# HAL implementations, named backend translation units, board composition",
203 "# libraries, tests, vendored code, and generated code are outside this ratchet.",
206 lines.extend(f
"# {family}: {totals[family]} reference(s)" for family
in FAMILY_PATTERNS)
207 lines.extend([
"#",
"# path<TAB>family<TAB>count"])
209 f
"{path}\t{family}\t{count}" for (path, family), count
in sorted(counts.items())
if count
211 return "\n".join(lines) +
"\n"
214def write_baseline(counts: Counter[tuple[str, str]]) ->
None:
215 """Write the baseline in stable ASCII form."""
216 BASELINE_FILE.write_text(format_baseline(counts), encoding=
"ascii")
220 actual: Counter[tuple[str, str]], baseline: Counter[tuple[str, str]]
221) -> list[tuple[tuple[str, str], int, int]]:
222 """Return ``(bucket, allowed, actual)`` rows that grew."""
224 (key, baseline.get(key, 0), count)
225 for key, count
in sorted(actual.items())
226 if count > baseline.get(key, 0)
230def scope_error(files_scanned: int, floor: int = MIN_SCANNED_FILES) -> str |
None:
231 """Describe a collapsed live scan, or return ``None``."""
232 if files_scanned >= floor:
235 f
"only {files_scanned} production file(s) scanned; floor is "
236 f
"{floor}. A partial scan looks like debt burn-down."
241 findings: list[Finding],
242 actual: Counter[tuple[str, str]],
243 baseline: Counter[tuple[str, str]],
245 """Compare with the baseline, print diagnostics, and return a gate status."""
246 grown = regressions(actual, baseline)
248 grown_keys = {key
for key, _allowed, _count
in grown}
249 print(
"FAIL: concrete RA8 driver reach-ins grew above baseline:", file=sys.stderr)
250 for (path, family), allowed, count
in grown:
251 print(f
" {path}: {family} {allowed} -> {count}", file=sys.stderr)
252 print(
"\nOffending references:", file=sys.stderr)
253 offenders = [f
for f
in findings
if (f.path, f.family)
in grown_keys]
254 for finding
in offenders[:MAX_DETAIL_LINES]:
256 f
" {finding.path}:{finding.line}: {finding.symbol} [{finding.family}]",
259 if len(offenders) > MAX_DETAIL_LINES:
260 print(f
" ... and {len(offenders) - MAX_DETAIL_LINES} more", file=sys.stderr)
262 "\nUse or extend a neutral fw_if_* port. The baseline records existing\n"
263 "migration debt; it is not an allowance for new reach-ins.",
268 shrunk = sum(1
for key, count
in baseline.items()
if actual.get(key, 0) < count)
270 f
"agnostic-registers: {sum(actual.values())} reference(s) in "
271 f
"{len(actual)} bucket(s); no growth."
275 f
" {shrunk} bucket(s) shrank; run "
276 "`python3 scripts/checks/check_agnostic_registers.py --update` to lock it in."
281def _write_fixture(root: Path, rel: str, text: str) -> Path:
282 """Write one self-test fixture and return its path."""
284 path.parent.mkdir(parents=
True, exist_ok=
True)
285 path.write_text(text, encoding=
"ascii")
289def _build_selftest_tree(root: Path) -> list[Path]:
290 """Create must-fire, must-stay-quiet, and exemption fixtures."""
291 bad = _write_fixture(
293 "examples/demo/main.c",
294 "void demo(void)\n{\n"
295 " ra8_cgc_init();\n ra8_glcdc_start();\n"
296 " ra8_gpio_write();\n ra8_gpt_init();\n"
297 " // ra8_gpio_write() in a comment is quiet.\n"
298 ' const char *s = "ra8_cgc_init";\n}\n',
300 clean = _write_fixture(root,
"libs/portable/src/logic.c",
"void portable_logic(void) {}\n")
301 hal = _write_fixture(root,
"libs/ra8_hal/src/backend.c",
"void ra8_gpio_backend(void) {}\n")
302 board = _write_fixture(root,
"libs/ra8_board_demo/src/board.c",
"void ra8_cgc_board(void) {}\n")
303 adapter = _write_fixture(
305 "libs/ra8_display_pal/src/ra8_display_pal_lcd.c",
306 "void ra8_glcdc_adapter(void) {}\n",
308 test = _write_fixture(root,
"tests/test_gpio.c",
"void ra8_gpio_test(void) {}\n")
309 return [bad, clean, hal, board, adapter, test]
312def _selftest_scan(root: Path, files_to_scan: list[Path]) -> list[str]:
313 """Prove all four families fire and exemptions stay quiet."""
314 findings, files = scan_files(files_to_scan, root)
315 failures: list[str] = []
316 counts = Counter(finding.family
for finding
in findings)
317 if counts != Counter(dict.fromkeys(FAMILY_PATTERNS, 1)):
318 failures.append(f
"bad fixture produced {dict(counts)}, expected one of each family")
319 if files != SELFTEST_ELIGIBLE_FILES:
321 f
"scope counted {files} eligible fixture files, expected {SELFTEST_ELIGIBLE_FILES}"
323 if any(finding.path !=
"examples/demo/main.c" for finding
in findings):
325 "an exempt HAL, backend, board, test, comment or string reference was reported"
330def _selftest_gate(root: Path, files_to_scan: list[Path]) -> list[str]:
331 """Drive the same check entry point CI uses, in both directions."""
332 failures: list[str] = []
333 findings, _files = scan_files(files_to_scan, root)
334 accepted = bucket(findings)
335 with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
336 if check_files(files_to_scan, root, Counter(), SELFTEST_ELIGIBLE_FILES) == 0:
337 failures.append(
"the CI check entry point passed concrete references above zero debt")
338 if check_files(files_to_scan, root, accepted, SELFTEST_ELIGIBLE_FILES) != 0:
339 failures.append(
"the CI check entry point failed references at their baseline")
343def _selftest_ratchet() -> list[str]:
344 """Prove growth fails while unchanged and shrinking buckets pass."""
345 failures: list[str] = []
346 base = Counter({(
"examples/a.c",
"gpio"): 2})
347 if regressions(Counter({(
"examples/a.c",
"gpio"): 2}), base):
348 failures.append(
"an unchanged bucket failed")
349 if regressions(Counter({(
"examples/a.c",
"gpio"): 1}), base):
350 failures.append(
"a shrinking bucket failed")
351 if not regressions(Counter({(
"examples/a.c",
"gpio"): 3}), base):
352 failures.append(
"a growing bucket passed")
353 if not regressions(Counter({(
"examples/new.c",
"gpio"): 1}), base):
354 failures.append(
"a new file bucket passed")
356 fixture = Counter({(
"examples/a.c",
"clock"): 3, (
"src/b.c",
"timer"): 1})
357 if parse_baseline(format_baseline(fixture)) != fixture:
358 failures.append(
"baseline formatting did not round-trip")
362def _selftest_floor() -> list[str]:
363 """Prove the live-scan floor rejects collapse and accepts its boundary."""
364 failures: list[str] = []
365 if scope_error(MIN_SCANNED_FILES - 1)
is None:
366 failures.append(
"the scope floor accepted too few files")
367 if scope_error(MIN_SCANNED_FILES)
is not None:
368 failures.append(
"the scope floor rejected its documented boundary")
372def selftest() -> int:
373 """Run must-fire, must-stay-quiet, ratchet and floor assertions."""
374 with tempfile.TemporaryDirectory()
as temporary:
375 root = Path(temporary)
376 files_to_scan = _build_selftest_tree(root)
377 failures = _selftest_scan(root, files_to_scan)
378 failures.extend(_selftest_gate(root, files_to_scan))
379 failures.extend(_selftest_ratchet())
380 failures.extend(_selftest_floor())
382 print(
"SELFTEST FAILED:", file=sys.stderr)
383 for failure
in failures:
384 print(f
" - {failure}", file=sys.stderr)
387 "selftest: agnostic-registers OK (all families fire; comments, strings, "
388 "HAL, backends, boards and tests stay quiet; growth fails; floor holds)."
396 baseline: Counter[tuple[str, str]],
397 floor: int = MIN_SCANNED_FILES,
399 """Run the blocking check over explicit files; this is CI's check path."""
400 findings, files_scanned = scan_files(files, root)
401 broken = scope_error(files_scanned, floor)
402 if broken
is not None:
403 print(f
"ERROR: {broken}", file=sys.stderr)
405 actual = bucket(findings)
406 print(f
"agnostic-registers: scanned {files_scanned} production file(s).")
407 return report_verdict(findings, actual, baseline)
410def update_baseline(actual: Counter[tuple[str, str]], findings: list[Finding]) -> int:
411 """Shrink the committed baseline to ``actual`` without permitting growth."""
413 baseline = load_baseline()
414 except ValueError
as exc:
415 print(f
"ERROR: malformed baseline: {exc}", file=sys.stderr)
417 seeding =
not BASELINE_FILE.is_file()
418 grown = []
if seeding
else regressions(actual, baseline)
420 print(
"ERROR: --update refuses to grow the baseline", file=sys.stderr)
421 for (path, family), allowed, count
in grown:
422 print(f
" {path}: {family} {allowed} -> {count}", file=sys.stderr)
424 write_baseline(actual)
425 print(f
"baseline updated: {len(findings)} reference(s) across {len(actual)} bucket(s).")
429def run_scan(mode: str) -> int:
430 """Run a live scan in check, update, or list mode."""
431 files = [REPO_ROOT / rel
for rel
in first_party_paths(SOURCE_SUFFIXES)]
434 baseline = load_baseline()
435 except ValueError
as exc:
436 print(f
"ERROR: malformed baseline: {exc}", file=sys.stderr)
438 return check_files(files, REPO_ROOT, baseline)
440 findings, files_scanned = scan_files(files, REPO_ROOT)
441 broken = scope_error(files_scanned)
442 if broken
is not None:
443 print(f
"ERROR: {broken}", file=sys.stderr)
445 actual = bucket(findings)
446 print(f
"agnostic-registers: scanned {files_scanned} production file(s).")
448 for finding
in findings:
449 print(f
"{finding.path}:{finding.line}: {finding.symbol} [{finding.family}]")
450 print(f
"total: {len(findings)} concrete-driver reference(s)")
452 return update_baseline(actual, findings)
455def main(argv: list[str] |
None =
None) -> int:
456 """Parse one required mode and return its exit status."""
457 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
458 modes = parser.add_mutually_exclusive_group(required=
True)
459 modes.add_argument(
"--selftest", action=
"store_true")
460 modes.add_argument(
"--check", action=
"store_true")
461 modes.add_argument(
"--update", action=
"store_true")
462 modes.add_argument(
"--list", action=
"store_true")
463 args = parser.parse_args(argv)
467 return run_scan(
"update")
469 return run_scan(
"list")
470 return run_scan(
"check")
473if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.