ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
gen_driver_status.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"""Generate docs/DRIVER_STATUS.md from tree truth alone.
5
6The page this replaces was a hand-maintained snapshot of every HAL driver against
7its FSP parity benchmark, and it rotted the way every hand-maintained inventory
8in this tree has rotted: 33 of its 109 rows named a ``.c`` file that had since
9been deleted, it still described ``libs/ra8_net/`` (removed), every rollup count
10was low, and the sweep SHAs it cited had been rewritten out of history. A reader
11could not tell which third of it was true.
12
13So the page is DERIVED now, and it derives only what the tree can answer.
14
15**A driver is a source family, not a file.** ``libs/ra8_hal/src/ra8_ceu.c``,
16``ra8_ceu_init_regs.c`` and the headers ``ra8_ceu.h`` / ``ra8_ceu_api.h`` /
17``ra8_ceu_types.h`` are one driver, ``ra8_ceu``. The family root is the shortest
18base name no other base name is a prefix of, matched at an underscore boundary
19so ``ra8_etha`` stays separate from ``ra8_eth``. Keying on the header stem
20instead looked simpler and was wrong: ``ra8_ceu_api.h`` declares ``ra8_ceu_*``
21functions, so 53 companion headers reported an API of zero and read as a
22documentation gap that did not exist.
23
24Derived per driver:
25
26* **Sources** -- the ``.c`` files in the family.
27* **Headers** -- the public headers in the family.
28* **API** -- distinct ``<root>_*`` functions those headers declare, comments
29 stripped first so a name that only appears in prose is not counted.
30* **Host tests** -- files under ``tests/`` naming at least one of the driver's
31 symbols. Matched by CONTENT, not by the ``tests/test_<driver>.c`` filename
32 convention, because plenty of drivers are exercised from a differently-named
33 suite and a filename-only rule would report those as untested.
34* **Apps** -- app directories under ``examples/`` (and ``apps/``) naming one of
35 its symbols.
36
37What it deliberately does NOT carry, because nothing in the tree knows it: the
38FSP parity class (feature-complete / partial / placeholder / scaffold), the
39bench-validation verdict, audit dates and commit SHAs. Guessing any of those is
40how the old page came to describe a tree that no longer existed. That status
41lives in the issue tracker.
42
43Scope is ``libs/ra8_hal`` -- the drivers. A library that reaches its hardware
44through an injected bus vtable (the project's Dependency-Inversion seam) is not
45mechanically distinguishable from any other library, so this generator does not
46guess at one; ``libs/README.md`` indexes those.
47
48Run ``python3 scripts/gen/gen_driver_status.py`` to refresh the committed page;
49the ``artefact-freshness`` gate fails when it drifts. ``--selftest`` proves the
50derivation fires in both directions on a throwaway tree.
51"""
52
53from __future__ import annotations
54
55import argparse
56import re
57import sys
58import tempfile
59from dataclasses import dataclass
60from pathlib import Path
61
62REPO_ROOT = Path(__file__).resolve().parents[2]
63ARTEFACT = "docs/DRIVER_STATUS.md"
64
65#: The driver library. Everything on the page is derived from under here.
66HAL_DIR = Path("libs") / "ra8_hal"
67
68#: Roots scanned for callers. ``apps`` is included so a product that drives a
69#: peripheral counts the same as an example; it is skipped when absent.
70APP_ROOTS = ("examples", "apps")
71
72#: Header families that are not drivers: the HUM-derived register layouts, and
73#: module-private headers.
74_NOT_A_DRIVER = ("_regs.h", "_internal.h")
75
76#: An identifier in this project's namespace, used as a function.
77_CALL_RE = re.compile(r"\b([a-z][a-z0-9_]*)\s*\‍(")
78
79#: A C or C++ comment, stripped before a header's API surface is counted.
80_COMMENT_RE = re.compile(r"/\*.*?\*/|//[^\n]*", re.DOTALL)
81
82_SOURCE_SUFFIXES = (".c", ".cpp")
83
84
85@dataclass(frozen=True)
86class Driver:
87 """One driver family and everything the tree says about it.
88
89 Attributes:
90 name: The family root, which is also the driver's symbol prefix.
91 headers: Repo-relative POSIX paths of its public headers.
92 sources: Repo-relative POSIX paths of the sources implementing it.
93 api: Count of distinct ``<name>_*`` functions its headers declare.
94 tests: Repo-relative POSIX paths of host tests naming one of its symbols.
95 apps: App directory names whose sources name one of its symbols.
96 """
97
98 name: str
99 headers: tuple[str, ...]
100 sources: tuple[str, ...]
101 api: int
102 tests: tuple[str, ...]
103 apps: tuple[str, ...]
104
105
106def _read(path: Path) -> str:
107 """Return ``path`` decoded permissively; unreadable bytes never abort a scan."""
108 return path.read_text(encoding="utf-8", errors="replace")
109
110
111def _extends(name: str, root: str) -> bool:
112 """Return True when ``name`` is ``root`` or a child of it at an underscore.
113
114 The boundary is what keeps ``ra8_etha`` out of the ``ra8_eth`` family: they
115 are different peripherals whose names happen to share a prefix.
116 """
117 return name == root or name.startswith(f"{root}_")
118
119
120def _family_roots(names: set[str]) -> list[str]:
121 """Reduce ``names`` to the roots no other name is a prefix of."""
122 return sorted(n for n in names if not any(m != n and _extends(n, m) for m in names))
123
124
125def _public_headers(root: Path) -> list[Path]:
126 """Return the public driver headers under ``libs/ra8_hal/inc``, sorted."""
127 inc = root / HAL_DIR / "inc"
128 if not inc.is_dir():
129 return []
130 return sorted(p for p in inc.glob("*.h") if not any(p.name.endswith(s) for s in _NOT_A_DRIVER))
131
132
133def _sources(root: Path) -> list[Path]:
134 """Return the implementation files under ``libs/ra8_hal/src``, sorted."""
135 src = root / HAL_DIR / "src"
136 if not src.is_dir():
137 return []
138 return sorted(p for p in src.iterdir() if p.suffix in _SOURCE_SUFFIXES)
139
140
141def _owner(name: str, ordered_roots: list[str]) -> str | None:
142 """Return the longest root owning ``name``, or None when none does."""
143 return next((r for r in ordered_roots if _extends(name, r)), None)
144
145
146def _source_owner(stem: str, ordered_roots: list[str]) -> str | None:
147 """Return the family owning a source named ``stem``, or None.
148
149 Four sources predate the ``ra8_`` file-naming convention -- ``adc.c``,
150 ``adc_selfdiag.c``, ``gpio.c``, ``timer.c`` -- while defining ``ra8_*``
151 symbols, so a bare name that matches nothing is retried prefixed. One that
152 still matches nothing is left alone rather than guessed into a family.
153 """
154 return _owner(stem, ordered_roots) or _owner(f"ra8_{stem}", ordered_roots)
155
156
157def driver_roots(root: Path) -> list[str]:
158 """Return every driver family root the HAL defines.
159
160 The public headers define the families: they reduce correctly, because
161 ``ra8_ceu`` is a prefix of its ``ra8_ceu_api`` and ``ra8_ceu_types``
162 companions. A source belonging to none of them forms a family of its own, so
163 an implementation with no public header still appears.
164 """
165 hdr_roots = _family_roots({h.stem for h in _public_headers(root)})
166 ordered = sorted(hdr_roots, key=len, reverse=True)
167 orphans = {p.stem for p in _sources(root) if _source_owner(p.stem, ordered) is None}
168 return sorted(set(hdr_roots) | set(_family_roots(orphans)))
169
170
171def _api_count(root: Path, headers: list[str], name: str) -> int:
172 """Count the distinct ``<name>_*`` functions ``headers`` declare."""
173 found: set[str] = set()
174 for rel in headers:
175 text = _COMMENT_RE.sub(" ", _read(root / rel))
176 found |= {m for m in _CALL_RE.findall(text) if m.startswith(f"{name}_")}
177 return len(found)
178
179
180def _scan_callers(root: Path, roots: list[str]) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
181 """Attribute host tests and app directories to the drivers they name.
182
183 Each file is read once and its symbols mapped to owners, so the cost is one
184 pass over the tree rather than one pass per driver.
185
186 Args:
187 root: Repository root being scanned.
188 roots: Every driver family root.
189
190 Returns:
191 ``(tests, apps)`` -- for each root, the test paths and the app directory
192 names that name at least one of its symbols.
193 """
194 ordered = sorted(roots, key=len, reverse=True)
195 tests: dict[str, set[str]] = {r: set() for r in roots}
196 apps: dict[str, set[str]] = {r: set() for r in roots}
197
198 def _app_directory(path: Path, base: Path) -> str | None:
199 for parent in path.parents:
200 if parent == base.parent:
201 break
202 if (parent / "CMakeLists.txt").is_file():
203 return parent.name
204 return None
205
206 def _walk(base: Path, sink: dict[str, set[str]], label: str) -> None:
207 if not base.is_dir():
208 return
209 for path in sorted(base.rglob("*")):
210 if path.suffix not in _SOURCE_SUFFIXES or not path.is_file():
211 continue
212 key = (
213 path.relative_to(root).as_posix() if label == "rel" else _app_directory(path, base)
214 )
215 if key is None:
216 continue
217 for symbol in set(_CALL_RE.findall(_read(path))):
218 owner = _owner(symbol, ordered)
219 if owner is not None:
220 sink[owner].add(key)
221
222 _walk(root / "tests", tests, "rel")
223 for app_root in APP_ROOTS:
224 _walk(root / app_root, apps, "dir")
225 return tests, apps
226
227
228def collect(root: Path) -> list[Driver]:
229 """Return every driver the HAL defines, sorted by name."""
230 roots = driver_roots(root)
231 ordered = sorted(roots, key=len, reverse=True)
232 headers: dict[str, list[str]] = {r: [] for r in roots}
233 sources: dict[str, list[str]] = {r: [] for r in roots}
234 for path in _public_headers(root):
235 owner = _owner(path.stem, ordered)
236 if owner is not None:
237 headers[owner].append(path.relative_to(root).as_posix())
238 for path in _sources(root):
239 owner = _source_owner(path.stem, ordered)
240 if owner is not None:
241 sources[owner].append(path.relative_to(root).as_posix())
242 tests, apps = _scan_callers(root, roots)
243 return [
244 Driver(
245 name=name,
246 headers=tuple(headers[name]),
247 sources=tuple(sources[name]),
248 api=_api_count(root, headers[name], name),
249 tests=tuple(sorted(tests[name])),
250 apps=tuple(sorted(apps[name])),
251 )
252 for name in roots
253 ]
254
255
256_HEADER = """<!-- GENERATED by scripts/gen/gen_driver_status.py -- do not edit by hand. -->
257
258# Driver status
259
260Every driver in `libs/ra8_hal`, and what the tree says about it. Regenerate with:
261
262```sh
263python3 scripts/gen/gen_driver_status.py
264```
265
266The `artefact-freshness` gate fails when the committed copy drifts from a fresh
267run, so this page cannot rot the way its hand-maintained predecessor did -- that
268one ended up naming 33 deleted source files and a library that no longer existed.
269
270**It carries only what is derivable.** Status beyond tree-derivable facts -- FSP
271parity, whether a driver has run on real silicon, when it was last audited -- is
272written down nowhere the generator can read, so it is not guessed at here. That
273belongs in the [issue tracker](https://github.com/bsikar/ra8-firmware/issues).
274
275A driver is a source FAMILY: `ra8_ceu.c` and `ra8_ceu_init_regs.c`, with the
276headers `ra8_ceu.h`, `ra8_ceu_api.h` and `ra8_ceu_types.h`, are one row. The
277family root is the shortest header name no other is a prefix of, matched at an
278underscore boundary so `ra8_etha` stays separate from `ra8_eth`. A source whose
279name predates the `ra8_` file convention (`adc.c`, `gpio.c`) is matched by its
280prefixed name too; one that matches no header family gets a row of its own.
281
282Where each column comes from:
283
284- **Src** / **Hdr** -- files in the family under `libs/ra8_hal/src` and
285 `libs/ra8_hal/inc`. The HUM-derived `*_regs.h` layouts and module-private
286 `*_internal.h` headers are not drivers and are excluded.
287- **API** -- distinct `<driver>_*` functions those headers declare, comments
288 stripped first.
289- **Tests** -- files under `tests/` naming at least one of the driver's symbols.
290 Matched by content rather than filename, so a driver exercised from a
291 differently-named suite is not reported as untested.
292- **Apps** -- app directories under `examples/` (and `apps/`, when present)
293 naming one of its symbols.
294
295A driver with no sources is a contract declared but not implemented here. One
296with no tests and no apps is exactly the gap this page exists to make visible.
297
298"""
299
300
301def _summary(drivers: list[Driver]) -> str:
302 """Return the one-line rollup above the table."""
303 tested = sum(1 for d in drivers if d.tests)
304 used = sum(1 for d in drivers if d.apps)
305 untouched = sum(1 for d in drivers if not d.tests and not d.apps)
306 headerless = sum(1 for d in drivers if not d.sources)
307 return (
308 f"{len(drivers)} drivers: {tested} named by a host test, {used} named by an "
309 f"app, {untouched} by neither, {headerless} declared but not implemented here.\n"
310 )
311
312
313def render(drivers: list[Driver]) -> str:
314 """Return the full page text for ``drivers``, newline-terminated."""
315 rows = ["| Driver | Src | Hdr | API | Tests | Apps |", "|---|---:|---:|---:|---:|---:|"]
316 rows += [
317 f"| `{d.name}` | {len(d.sources)} | {len(d.headers)} | {d.api} | "
318 f"{len(d.tests)} | {len(d.apps)} |"
319 for d in drivers
320 ]
321 return _HEADER + _summary(drivers) + "\n" + "\n".join(rows) + "\n"
322
323
324def write(root: Path) -> int:
325 """Rewrite the committed page from ``root``; return 0 on success."""
326 page = render(collect(root))
327 target = root / ARTEFACT
328 target.parent.mkdir(parents=True, exist_ok=True)
329 target.write_text(page, encoding="ascii")
330 print(f"gen_driver_status.py: wrote {ARTEFACT} ({len(page)} bytes)")
331 return 0
332
333
334def _seed_tree(root: Path) -> None:
335 """Build a throwaway HAL with the shapes the derivation has to get right."""
336 inc = root / HAL_DIR / "inc"
337 src = root / HAL_DIR / "src"
338 inc.mkdir(parents=True)
339 src.mkdir(parents=True)
340 # One family spread over three headers and two sources.
341 (inc / "ra8_foo.h").write_text('#pragma once\n#include "ra8_foo_api.h"\n', encoding="ascii")
342 (inc / "ra8_foo_api.h").write_text(
343 "#pragma once\nra8_err_t ra8_foo_init(void);\nra8_err_t ra8_foo_read(int x);\n"
344 "/* ra8_foo_ghost() only appears in prose. */\n",
345 encoding="ascii",
346 )
347 (src / "ra8_foo.c").write_text("void ra8_foo_init(void) {}\n", encoding="ascii")
348 (src / "ra8_foo_dma.c").write_text("void ra8_foo_dma(void) {}\n", encoding="ascii")
349 # A near-miss name that must NOT fold into ra8_foo.
350 (inc / "ra8_fooa.h").write_text("#pragma once\nra8_err_t ra8_fooa_init(void);\n", "ascii")
351 (src / "ra8_fooa.c").write_text("void ra8_fooa_init(void) {}\n", encoding="ascii")
352 # Declared, never implemented here.
353 (inc / "ra8_bar.h").write_text("#pragma once\nra8_err_t ra8_bar_init(void);\n", "ascii")
354 # Not drivers.
355 (inc / "ra8_foo_regs.h").write_text("#pragma once\n", encoding="ascii")
356 (inc / "ra8_foo_internal.h").write_text("#pragma once\n", encoding="ascii")
357
358 tests = root / "tests" / "src"
359 tests.mkdir(parents=True)
360 (tests / "test_something_else.c").write_text("void t(void) { ra8_foo_init(); }\n", "ascii")
361
362 app = root / "examples" / "tier" / "blinky"
363 src = app / "src"
364 src.mkdir(parents=True)
365 (app / "CMakeLists.txt").write_text("add_executable(blinky src/main.c)\n", encoding="ascii")
366 (src / "main.c").write_text("int main(void) { ra8_foo_read(1); }\n", encoding="ascii")
367
368
369def _selftest_cases(root: Path) -> list[tuple[str, bool]]:
370 """Return one ``(label, passed)`` tuple per asserted derivation property."""
371 _seed_tree(root)
372 # ra8_foo_init + ra8_foo_read. ra8_foo_ghost() appears only in a comment,
373 # so an API count of 2 rather than 3 is what proves prose is not counted.
374 expect_api = 2
375 expect_sources = 2 # ra8_foo.c + ra8_foo_dma.c
376 expect_headers = 2 # ra8_foo.h + ra8_foo_api.h
377 by_name = {d.name: d for d in collect(root)}
378 page = render(list(by_name.values()))
379 return [
380 (
381 "companion headers fold into one family",
382 set(by_name) == {"ra8_foo", "ra8_fooa", "ra8_bar"},
383 ),
384 ("register and internal headers are not drivers", "ra8_foo_regs" not in by_name),
385 ("a near-miss prefix stays its own driver", by_name["ra8_fooa"].sources != ()),
386 ("the family collects every source", len(by_name["ra8_foo"].sources) == expect_sources),
387 (
388 "the family collects every public header",
389 len(by_name["ra8_foo"].headers) == expect_headers,
390 ),
391 ("API spans the family and excludes prose names", by_name["ra8_foo"].api == expect_api),
392 ("a header with no source is still listed", by_name["ra8_bar"].sources == ()),
393 (
394 "a differently-named host test still counts",
395 by_name["ra8_foo"].tests == ("tests/src/test_something_else.c",),
396 ),
397 ("an app naming a symbol is attributed", by_name["ra8_foo"].apps == ("blinky",)),
398 (
399 "a driver nothing names reports neither",
400 not by_name["ra8_bar"].tests and not by_name["ra8_bar"].apps,
401 ),
402 ("the page is pure ASCII", page.isascii()),
403 ("the page names every driver", all(f"`{n}`" in page for n in by_name)),
404 ]
405
406
407def selftest() -> int:
408 """Prove the derivation fires in both directions on a throwaway tree."""
409 with tempfile.TemporaryDirectory() as tmp:
410 cases = _selftest_cases(Path(tmp))
411 failures = 0
412 for label, passed in cases:
413 print(f" [{'ok' if passed else 'FAIL'}] {label}")
414 failures += 0 if passed else 1
415 if failures:
416 sys.stderr.write(f"gen_driver_status.py --selftest: {failures} case(s) failed.\n")
417 return 1
418 print(f"gen_driver_status.py --selftest: all {len(cases)} cases pass.")
419 return 0
420
421
422def main() -> int:
423 """Parse arguments and dispatch to the generator or its selftest."""
424 parser = argparse.ArgumentParser(description="Generate docs/DRIVER_STATUS.md from the tree.")
425 parser.add_argument("--selftest", action="store_true", help="run the self-test and exit")
426 args = parser.parse_args()
427 if args.selftest:
428 return selftest()
429 return write(REPO_ROOT)
430
431
432if __name__ == "__main__":
433 raise SystemExit(main())
-proof
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298