ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_agnostic_registers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Ratchet concrete RA8 driver reach-ins outside HAL and board composition.
5
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.
11
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.
16
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.
23
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.
28
29Usage::
30
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
35"""
36
37from __future__ import annotations
38
39import argparse
40import contextlib
41import io
42import re
43import sys
44import tempfile
45from collections import Counter
46from dataclasses import dataclass
47from pathlib import Path, PurePosixPath
48
49sys.path.insert(0, str(Path(__file__).resolve().parent))
50
51from check_magic_numbers import _strip_comments_and_strings
52from lint_targets import first_party_paths
53
54REPO_ROOT = Path(__file__).resolve().parents[2]
55BASELINE_FILE = REPO_ROOT / ".github" / "agnostic-register-baseline.txt"
56
57SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hh", ".hxx")
58POLICY_ROOTS = frozenset({"libs", "port", "examples", "tools", "apps"})
59EXCLUDED_PREFIXES = (
60 "libs/ra8_hal/",
61 "libs/third_party/",
62 "apps/shared_libs/third_party/",
63 "libs/ra8_fonts/",
64)
65EXCLUDED_BACKEND_FILES = frozenset(
66 {
67 "libs/ra8_display_pal/src/ra8_display_pal_lcd.c",
68 }
69)
70
71FAMILY_PATTERNS: dict[str, re.Pattern[str]] = {
72 family: re.compile(rf"\bra8_{prefix}_[A-Za-z0-9_]+\b")
73 for family, prefix in (
74 ("clock", "cgc"),
75 ("display", "glcdc"),
76 ("gpio", "gpio"),
77 ("timer", "gpt"),
78 )
79}
80
81MIN_SCANNED_FILES = 900
82"""Reject a live scan that lost a material part of its production scope.
83
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.
87"""
88
89MAX_DETAIL_LINES = 25
90BASELINE_COLUMNS = 3
91SELFTEST_ELIGIBLE_FILES = 2
92
93
94@dataclass(frozen=True)
95class Finding:
96 """One concrete-driver symbol found in policy-controlled code."""
97
98 path: str
99 line: int
100 family: str
101 symbol: str
102
103
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:
108 return False
109 if (
110 not rel.endswith(SOURCE_SUFFIXES)
111 or rel.startswith(EXCLUDED_PREFIXES)
112 or rel in EXCLUDED_BACKEND_FILES
113 ):
114 return False
115 return not (
116 len(path.parts) > 1 and path.parts[0] == "libs" and path.parts[1].startswith("ra8_board_")
117 )
118
119
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():
127 findings.extend(
128 Finding(rel, line_number, family, match.group(0))
129 for match in pattern.finditer(line)
130 )
131 return findings
132
133
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] = []
137 eligible = 0
138 for path in files:
139 try:
140 rel = path.relative_to(root).as_posix()
141 except ValueError:
142 continue
143 if not is_policy_source(rel):
144 continue
145 eligible += 1
146 findings.extend(scan_file(path, rel))
147 return findings, eligible
148
149
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)
153
154
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):
159 line = raw.strip()
160 if not line or line.startswith("#"):
161 continue
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)
170 try:
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
175 key = (path, family)
176 if count < 1 or key in counts:
177 message = f"line {line_number}: non-positive or duplicate bucket"
178 raise ValueError(message)
179 counts[key] = count
180 return counts
181
182
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():
186 return Counter()
187 return parse_baseline(BASELINE_FILE.read_text(encoding="ascii"))
188
189
190def format_baseline(counts: Counter[tuple[str, str]]) -> str:
191 """Return stable, reviewable baseline text for ``counts``."""
192 totals = Counter()
193 for (_path, family), count in counts.items():
194 totals[family] += count
195 lines = [
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).",
199 "#",
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.",
204 "#",
205 ]
206 lines.extend(f"# {family}: {totals[family]} reference(s)" for family in FAMILY_PATTERNS)
207 lines.extend(["#", "# path<TAB>family<TAB>count"])
208 lines.extend(
209 f"{path}\t{family}\t{count}" for (path, family), count in sorted(counts.items()) if count
210 )
211 return "\n".join(lines) + "\n"
212
213
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")
217
218
219def regressions(
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."""
223 return [
224 (key, baseline.get(key, 0), count)
225 for key, count in sorted(actual.items())
226 if count > baseline.get(key, 0)
227 ]
228
229
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:
233 return None
234 return (
235 f"only {files_scanned} production file(s) scanned; floor is "
236 f"{floor}. A partial scan looks like debt burn-down."
237 )
238
239
240def report_verdict(
241 findings: list[Finding],
242 actual: Counter[tuple[str, str]],
243 baseline: Counter[tuple[str, str]],
244) -> int:
245 """Compare with the baseline, print diagnostics, and return a gate status."""
246 grown = regressions(actual, baseline)
247 if grown:
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]:
255 print(
256 f" {finding.path}:{finding.line}: {finding.symbol} [{finding.family}]",
257 file=sys.stderr,
258 )
259 if len(offenders) > MAX_DETAIL_LINES:
260 print(f" ... and {len(offenders) - MAX_DETAIL_LINES} more", file=sys.stderr)
261 print(
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.",
264 file=sys.stderr,
265 )
266 return 1
267
268 shrunk = sum(1 for key, count in baseline.items() if actual.get(key, 0) < count)
269 print(
270 f"agnostic-registers: {sum(actual.values())} reference(s) in "
271 f"{len(actual)} bucket(s); no growth."
272 )
273 if shrunk:
274 print(
275 f" {shrunk} bucket(s) shrank; run "
276 "`python3 scripts/checks/check_agnostic_registers.py --update` to lock it in."
277 )
278 return 0
279
280
281def _write_fixture(root: Path, rel: str, text: str) -> Path:
282 """Write one self-test fixture and return its path."""
283 path = root / rel
284 path.parent.mkdir(parents=True, exist_ok=True)
285 path.write_text(text, encoding="ascii")
286 return path
287
288
289def _build_selftest_tree(root: Path) -> list[Path]:
290 """Create must-fire, must-stay-quiet, and exemption fixtures."""
291 bad = _write_fixture(
292 root,
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',
299 )
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(
304 root,
305 "libs/ra8_display_pal/src/ra8_display_pal_lcd.c",
306 "void ra8_glcdc_adapter(void) {}\n",
307 )
308 test = _write_fixture(root, "tests/test_gpio.c", "void ra8_gpio_test(void) {}\n")
309 return [bad, clean, hal, board, adapter, test]
310
311
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:
320 failures.append(
321 f"scope counted {files} eligible fixture files, expected {SELFTEST_ELIGIBLE_FILES}"
322 )
323 if any(finding.path != "examples/demo/main.c" for finding in findings):
324 failures.append(
325 "an exempt HAL, backend, board, test, comment or string reference was reported"
326 )
327 return failures
328
329
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")
340 return failures
341
342
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")
355
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")
359 return failures
360
361
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")
369 return failures
370
371
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())
381 if failures:
382 print("SELFTEST FAILED:", file=sys.stderr)
383 for failure in failures:
384 print(f" - {failure}", file=sys.stderr)
385 return 1
386 print(
387 "selftest: agnostic-registers OK (all families fire; comments, strings, "
388 "HAL, backends, boards and tests stay quiet; growth fails; floor holds)."
389 )
390 return 0
391
392
393def check_files(
394 files: list[Path],
395 root: Path,
396 baseline: Counter[tuple[str, str]],
397 floor: int = MIN_SCANNED_FILES,
398) -> int:
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)
404 return 2
405 actual = bucket(findings)
406 print(f"agnostic-registers: scanned {files_scanned} production file(s).")
407 return report_verdict(findings, actual, baseline)
408
409
410def update_baseline(actual: Counter[tuple[str, str]], findings: list[Finding]) -> int:
411 """Shrink the committed baseline to ``actual`` without permitting growth."""
412 try:
413 baseline = load_baseline()
414 except ValueError as exc:
415 print(f"ERROR: malformed baseline: {exc}", file=sys.stderr)
416 return 2
417 seeding = not BASELINE_FILE.is_file()
418 grown = [] if seeding else regressions(actual, baseline)
419 if grown:
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)
423 return 1
424 write_baseline(actual)
425 print(f"baseline updated: {len(findings)} reference(s) across {len(actual)} bucket(s).")
426 return 0
427
428
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)]
432 if mode == "check":
433 try:
434 baseline = load_baseline()
435 except ValueError as exc:
436 print(f"ERROR: malformed baseline: {exc}", file=sys.stderr)
437 return 2
438 return check_files(files, REPO_ROOT, baseline)
439
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)
444 return 2
445 actual = bucket(findings)
446 print(f"agnostic-registers: scanned {files_scanned} production file(s).")
447 if mode == "list":
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)")
451 return 0
452 return update_baseline(actual, findings)
453
454
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)
464 if args.selftest:
465 return selftest()
466 if args.update:
467 return run_scan("update")
468 if args.list:
469 return run_scan("list")
470 return run_scan("check")
471
472
473if __name__ == "__main__":
474 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298