ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_hil_eil_parity.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"""Gate: EIL==HIL parity -- every HIL app is also exercised in the emulator.
5
6The owner invariant this gate enforces: **every hardware-in-the-loop (HIL) app
7MUST also be exercised in the emulator (EIL), automatically and dynamically**,
8so that *adding* a HIL app cannot silently escape EIL coverage.
9
10Two harnesses already exist:
11
12 * ``scripts/hil/all.sh`` flashes the physical EK-RA8D2 and scrapes its UART /
13 J-Link / RTT / wire for every app under
14 ``examples/ek_ra8d2/hw_validated/hil/``.
15 * ``scripts/emu/eil_all.sh`` boots the SAME ``.elf`` files in ``tools/ra8_emulator``
16 headless and checks the SAME per-app ``hil.conf`` expectations with NO board
17 attached. The ``eil-integration`` CI job runs it enforcing, 0 skips.
18
19Nothing, however, GUARANTEED that the two suites cover the same set of apps or
20that a newly-added HIL app is EIL-visible. A HIL app whose ``hil.conf`` is
21missing, or whose ``HIL_MODE`` ``ra8_emulator`` cannot check, would quietly fall
22out of EIL coverage -- exactly the drift this gate makes impossible.
23
24It is a CHEAP, hardware-free structural gate: it does not build or run either
25harness. It re-derives each harness's app-discovery *from the harness scripts
26themselves* (parsing ``HIL_DIR`` / ``EIL_RA8P1_DIR`` and the EIL-capable-mode
27``case`` list out of ``eil_all.sh``, ``HIL_DIR`` out of ``hil_all.sh``) and then
28globs the tree exactly as those scripts do. Because the roots and the mode list
29are parsed, not hardcoded, the gate tracks the harnesses as they evolve.
30
31It FAILS (non-zero) if any of the following hold, with a precise message per
32offender:
33
34 1. an app under ``examples/ek_ra8d2/hw_validated/hil/`` has no ``hil.conf``
35 (it is HIL-tiered but unspecified -- ``hil_all.sh`` fails loud on it, and
36 ``ra8_emulator`` never sees it);
37 2. the set of apps ``hil_all.sh`` would run is not covered by the set
38 ``eil_all.sh`` would run -- drift between the two harnesses. ``eil_all.sh``
39 legitimately runs a SUPERSET (it also emulates the RA8P1/NPU foundation
40 apps under ``examples/ra8p1_foundation/`` via ``--device ra8p1``, which the
41 RA8D2 bench cannot flash), so the relation checked is HIL subset-of EIL:
42 every HIL app must be in the EIL run set;
43 3. any ``hw_validated/hil`` app declares a ``HIL_MODE`` that ``eil_all.sh``
44 cannot check -- i.e. one that is not in its EIL-capable set
45 (``uart_scrape`` / ``alive`` / ``jlink_memprobe`` / ``rtt_scrape`` /
46 ``hil_eth_tcp``, parsed from ``eil_all.sh``). There must be NO EIL skips.
47
48Run::
49
50 check_hil_eil_parity.py # gate (exit 1 on any violation)
51 check_hil_eil_parity.py --list # enumerate the derived sets, no gate
52
53Exit 0 if the EIL==HIL invariant holds, exit 1 otherwise, exit 2 if the harness
54scripts could not be parsed (a structural change that must be reconciled -- the
55gate refuses to silently no-op).
56"""
57
58# ---------------------------------------------------------------------------
59# EIL==HIL is an ONGOING DISCIPLINE, not a one-time state.
60#
61# "EIL==HIL" means ra8_emulator (tools/ra8_emulator) must reproduce the ACTUAL
62# silicon behaviour of every path a HIL app exercises -- bugs INCLUDED, not an
63# idealised model. When a new HIL app drives a peripheral or code path that
64# ra8_emulator does not yet model, the correct response is NOT to skip it in EIL:
65# ra8_emulator MUST be extended so the EIL suite stays complete. This gate makes
66# skipping impossible (a missing hil.conf, an un-modelled HIL_MODE, or an app
67# invisible to eil_all.sh all fail the build), which is precisely what FORCES
68# the emulator to keep up with the hardware. Concretely, the upcoming
69# archive / compression work may need new ra8_emulator SD / decode models before
70# its HIL apps can pass EIL -- and that is by design: the emulator follows silicon.
71# ---------------------------------------------------------------------------
72
73from __future__ import annotations
74
75import argparse
76import re
77import sys
78import tempfile
79from dataclasses import dataclass
80from pathlib import Path
81
82REPO_ROOT = Path(__file__).resolve().parents[2]
83
84HIL_ALL = REPO_ROOT / "scripts" / "hil" / "all.sh"
85EIL_ALL = REPO_ROOT / "scripts" / "emu" / "eil_all.sh"
86
87# hil_discover_apps() skips this file when walking a hil/ directory.
88DISCOVERY_SKIP = "README.md"
89
90# The eil_all.sh line whose case-labels are the EIL-capable modes lives right
91# after this dispatch header (in run_one()).
92EIL_MODE_CASE_HEADER = 'case "${HIL_MODE:-}" in'
93
94
95# ---------------------------------------------------------------------------
96# Harness-script parsing (all failures are collected as strings, never raised,
97# so a structural change reports a precise reconcile message and exit 2 rather
98# than an unhandled traceback).
99# ---------------------------------------------------------------------------
100def _read(path: Path) -> str | None:
101 try:
102 return path.read_text(encoding="utf-8")
103 except OSError:
104 return None
105
106
107def _rel(path: Path) -> str:
108 if path.is_relative_to(REPO_ROOT):
109 return str(path.relative_to(REPO_ROOT))
110 return str(path)
111
112
113def _parse_repo_root_dir(text: str, var: str) -> Path | None:
114 """Parse ``VAR="${REPO_ROOT}/<path>"`` and return REPO_ROOT / <path>.
115
116 This is how both harnesses anchor their discovery roots, so parsing it (per
117 harness) keeps the gate tracking a root the scripts may relocate.
118 """
119 pattern = re.compile(r"^\s*" + re.escape(var) + r'="\$\{REPO_ROOT\}/([^"]+)"', re.MULTILINE)
120 match = pattern.search(text)
121 if match is None:
122 return None
123 return REPO_ROOT / match.group(1)
124
125
126def _parse_eil_modes(text: str) -> list[str] | None:
127 """Parse the EIL-capable HIL_MODE set from eil_all.sh's run_one dispatch.
128
129 The authoritative list is the ``case`` label right after
130 ``case "${HIL_MODE:-}" in``::
131
132 uart_scrape | alive | jlink_memprobe | rtt_scrape | hil_eth_tcp) : ;;
133
134 Parsed (not hardcoded) so a mode added to the emulator is picked up here.
135 Returns None if the dispatch or its label line cannot be found/parsed.
136 """
137 lines = text.splitlines()
138 for idx, line in enumerate(lines):
139 if EIL_MODE_CASE_HEADER not in line:
140 continue
141 for follow in lines[idx + 1 :]:
142 stripped = follow.strip()
143 if not stripped or stripped.startswith("#"):
144 continue
145 match = re.match(r"^([a-z0-9_ |]+)\‍)\s*:\s*;;\s*$", stripped)
146 if match is None:
147 return None
148 return [tok.strip() for tok in match.group(1).split("|") if tok.strip()]
149 return None
150
151
152def _discover_apps(root: Path) -> list[str]:
153 """App names directly under a hil/ root -- mirrors hil_discover_apps().
154
155 An app is an immediate child DIRECTORY; the README.md file is skipped.
156 Returned sorted, matching the harness's ``| sort``.
157 """
158 if not root.is_dir():
159 return []
160 names = [c.name for c in root.iterdir() if c.is_dir() and c.name != DISCOVERY_SKIP]
161 return sorted(names)
162
163
164def _hil_mode_of(conf: Path) -> str | None:
165 """Parse ``HIL_MODE=<mode>`` from a hil.conf (quotes stripped)."""
166 if not conf.is_file():
167 return None
168 text = _read(conf)
169 if text is None:
170 return None
171 for raw in text.splitlines():
172 line = raw.strip()
173 if line.startswith("HIL_MODE="):
174 return line[len("HIL_MODE=") :].strip().strip('"').strip("'")
175 return None
176
177
178@dataclass
179class Model:
180 """Everything the checks need, derived once from the two harness scripts."""
181
182 hil_root_hilall: Path
183 hil_root_silall: Path
184 ra8p1_root: Path
185 eil_modes: list[str]
186 hil_apps: list[str]
187 ra8p1_apps: list[str]
188 eil_apps: list[str]
189
190
191def build_model() -> tuple[Model | None, list[str]]:
192 """Derive the two harnesses' discovery, or a list of parse-error strings."""
193 errors: list[str] = []
194 hil_text = _read(HIL_ALL)
195 eil_text = _read(EIL_ALL)
196 if hil_text is None:
197 errors.append(f"cannot read {_rel(HIL_ALL)}")
198 if eil_text is None:
199 errors.append(f"cannot read {_rel(EIL_ALL)}")
200 if hil_text is None or eil_text is None:
201 return None, errors
202
203 hil_root_hilall = _parse_repo_root_dir(hil_text, "HIL_DIR")
204 hil_root_silall = _parse_repo_root_dir(eil_text, "HIL_DIR")
205 ra8p1_root = _parse_repo_root_dir(eil_text, "EIL_RA8P1_DIR")
206 eil_modes = _parse_eil_modes(eil_text)
207 if hil_root_hilall is None:
208 errors.append(f'{_rel(HIL_ALL)}: no HIL_DIR="${{REPO_ROOT}}/..." assignment')
209 if hil_root_silall is None:
210 errors.append(f'{_rel(EIL_ALL)}: no HIL_DIR="${{REPO_ROOT}}/..." assignment')
211 if ra8p1_root is None:
212 errors.append(f'{_rel(EIL_ALL)}: no EIL_RA8P1_DIR="${{REPO_ROOT}}/..." assignment')
213 if eil_modes is None:
214 errors.append(
215 f"{_rel(EIL_ALL)}: could not parse the EIL-capable mode set from the "
216 f"'{EIL_MODE_CASE_HEADER}' dispatch"
217 )
218 if None in (hil_root_hilall, hil_root_silall, ra8p1_root, eil_modes):
219 return None, errors
220
221 hil_apps = _discover_apps(hil_root_hilall)
222 eil = set(_discover_apps(hil_root_silall))
223 ra8p1_apps = [
224 name for name in _discover_apps(ra8p1_root) if (ra8p1_root / name / "hil.conf").is_file()
225 ]
226 eil.update(ra8p1_apps)
227 model = Model(
228 hil_root_hilall=hil_root_hilall,
229 hil_root_silall=hil_root_silall,
230 ra8p1_root=ra8p1_root,
231 eil_modes=eil_modes,
232 hil_apps=hil_apps,
233 ra8p1_apps=ra8p1_apps,
234 eil_apps=sorted(eil),
235 )
236 return model, []
237
238
239# ---------------------------------------------------------------------------
240# Per-offender message builders (kept out of the comprehensions below so the
241# checks stay one readable expression each).
242# ---------------------------------------------------------------------------
243def _msg_no_conf(appdir_rel: str) -> str:
244 return (
245 f"{appdir_rel}: no hil.conf -- a hil/ app is HIL-tiered but declares no "
246 "HIL mode (hil_all.sh fails loud, ra8_emulator never sees it). Add a "
247 "hil.conf or move it to manual/."
248 )
249
250
251def _msg_escapes_eil(app: str) -> str:
252 return (
253 f"{app}: hil_all.sh would run it but eil_all.sh would not -- the HIL app "
254 "escapes EIL coverage. Ensure eil_all.sh discovers it (same hil/ root) so "
255 "it is exercised in ra8_emulator too."
256 )
257
258
259def _msg_root_drift(model: Model) -> str:
260 return (
261 "hil_all.sh and eil_all.sh discover DIFFERENT hil/ roots "
262 f"({_rel(model.hil_root_hilall)} vs {_rel(model.hil_root_silall)}) -- the "
263 "two harnesses have drifted; point both HIL_DIR at the same root."
264 )
265
266
267def _msg_bad_mode(app: str, mode: str, capable: set[str]) -> str:
268 return (
269 f"{app}: HIL_MODE='{mode}' is not EIL-checkable (eil_all.sh checks only: "
270 f"{', '.join(sorted(capable))}). ra8_emulator would SKIP it -- there must be "
271 "NO EIL skips. Model the mode in ra8_emulator + eil_all.sh, do not leave it "
272 "hardware-only."
273 )
274
275
276def check_missing_conf(model: Model) -> list[str]:
277 """Check 1: every hil/ app must declare a hil.conf."""
278 return [
279 _msg_no_conf(_rel(model.hil_root_hilall / app))
280 for app in model.hil_apps
281 if not (model.hil_root_hilall / app / "hil.conf").is_file()
282 ]
283
284
285def check_set_drift(model: Model) -> list[str]:
286 """Check 2: hil_all's run set must be covered by eil_all's run set."""
287 offenders: list[str] = []
288 if model.hil_root_hilall != model.hil_root_silall:
289 offenders.append(_msg_root_drift(model))
290 eil_set = set(model.eil_apps)
291 offenders.extend(_msg_escapes_eil(app) for app in model.hil_apps if app not in eil_set)
292 return offenders
293
294
295def check_unsupported_mode(model: Model) -> list[str]:
296 """Check 3: every hil/ app's HIL_MODE must be EIL-checkable (no skips)."""
297 capable = set(model.eil_modes)
298 offenders: list[str] = []
299 for app in model.hil_apps:
300 # A missing hil.conf / HIL_MODE is reported by check 1, not double-counted.
301 mode = _hil_mode_of(model.hil_root_hilall / app / "hil.conf")
302 if mode is not None and mode not in capable:
303 offenders.append(_msg_bad_mode(app, mode, capable))
304 return offenders
305
306
307def selftest() -> int:
308 """Prove parity stays quiet and missing config, drift, and bad mode all fire."""
309 with tempfile.TemporaryDirectory(prefix="hil-eil-parity-selftest-") as raw:
310 root = Path(raw)
311 hil = root / "hil"
312 ra8p1 = root / "ra8p1"
313 (hil / "good").mkdir(parents=True)
314 (hil / "good/hil.conf").write_text("HIL_MODE=uart_scrape\n", encoding="ascii")
315 ra8p1.mkdir()
316 good = Model(hil, hil, ra8p1, ["uart_scrape"], ["good"], [], ["good"])
317 good_findings = (
318 check_missing_conf(good) + check_set_drift(good) + check_unsupported_mode(good)
319 )
320
321 (hil / "missing").mkdir()
322 (hil / "unsupported").mkdir()
323 (hil / "unsupported/hil.conf").write_text("HIL_MODE=usb_only\n", encoding="ascii")
324 bad = Model(
325 hil,
326 root / "different-hil",
327 ra8p1,
328 ["uart_scrape"],
329 ["good", "missing", "unsupported"],
330 [],
331 ["good"],
332 )
333 bad_groups = (
334 check_missing_conf(bad),
335 check_set_drift(bad),
336 check_unsupported_mode(bad),
337 )
338 parsed_modes = _parse_eil_modes(
339 'case "${HIL_MODE:-}" in\n uart_scrape | alive) : ;;\nesac\n'
340 )
341 cases = (
342 (not good_findings, "matching app set and supported mode stay quiet"),
343 (all(bad_groups), "missing config, root/set drift, and unsupported mode fire"),
344 (parsed_modes == ["uart_scrape", "alive"], "authoritative mode case is parsed"),
345 (_parse_eil_modes("case unrelated in") is None, "missing dispatch fails parsing"),
346 )
347 failed = [label for passed, label in cases if not passed]
348 for passed, label in cases:
349 print(f" [{'ok' if passed else 'FAIL'}] {label}")
350 if failed:
351 print(f"check_hil_eil_parity.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
352 return 1
353 print("check_hil_eil_parity.py --selftest: all cases pass (both directions).")
354 return 0
355
356
357def _print_list(model: Model) -> None:
358 print("check_hil_eil_parity.py --list")
359 print("-------------------------------------------------------------------")
360 print(f"hil_all.sh HIL_DIR : {_rel(model.hil_root_hilall)}")
361 print(f"eil_all.sh HIL_DIR : {_rel(model.hil_root_silall)}")
362 print(f"eil_all.sh EIL_RA8P1_DIR: {_rel(model.ra8p1_root)}")
363 print(f"EIL-capable HIL_MODEs : {', '.join(model.eil_modes)}")
364 print(f"hil_all.sh run set : {len(model.hil_apps)} app(s)")
365 print(f"eil_all.sh run set : {len(model.eil_apps)} app(s)")
366 print(f" EIL-only (RA8P1) : {', '.join(model.ra8p1_apps) or '(none)'}")
367 print("-------------------------------------------------------------------")
368 print(f"{'APP':<34} {'MODE':<16} {'IN EIL':<7}")
369 eil_set = set(model.eil_apps)
370 for app in model.hil_apps:
371 mode = _hil_mode_of(model.hil_root_hilall / app / "hil.conf") or "(no hil.conf)"
372 in_eil = "yes" if app in eil_set else "NO"
373 print(f"{app:<34} {mode:<16} {in_eil:<7}")
374
375
376def _report_parse_errors(errors: list[str]) -> int:
377 print("check_hil_eil_parity.py: could not parse the HIL/EIL harness scripts:", file=sys.stderr)
378 for err in errors:
379 print(f" - {err}", file=sys.stderr)
380 print(
381 "\nThe harness scripts changed shape and the gate can no longer derive the "
382 "discovery rules. Reconcile the parse -- do not bypass the gate.",
383 file=sys.stderr,
384 )
385 return 2
386
387
388def main(argv: list[str]) -> int:
389 """Fail when the HIL app set and the EIL app set have drifted apart.
390
391 A model-build failure short-circuits ahead of every parity check and is
392 reported on its own: if the two sets could not be derived, any verdict
393 about their agreement would be an artefact of the broken parse rather than
394 a fact about the tree.
395
396 ``--list`` prints the derived sets and modes and always exits 0. It is a
397 debugging aid, explicitly NOT a gate -- CI must invoke this bare, or the
398 step passes without ever comparing anything.
399
400 Returns 0 when parity holds or under ``--list``, 1 on drift (an app
401 missing a hil.conf, a set mismatch, or an unsupported mode) or on a
402 model-build error.
403 """
404 parser = argparse.ArgumentParser(
405 prog="check_hil_eil_parity.py",
406 description="Gate: every HIL app is also exercised in the emulator (EIL==HIL).",
407 )
408 parser.add_argument(
409 "--list",
410 action="store_true",
411 help="enumerate the derived hil/eil sets + modes, then exit 0 (no gating).",
412 )
413 parser.add_argument("--selftest", action="store_true", help="run isolated both-direction tests")
414 args = parser.parse_args(argv[1:])
415
416 if args.selftest:
417 if args.list:
418 parser.error("--selftest and --list are mutually exclusive")
419 return selftest()
420
421 model, errors = build_model()
422 if model is None:
423 return _report_parse_errors(errors)
424
425 if args.list:
426 _print_list(model)
427 return 0
428
429 offenders = check_missing_conf(model) + check_set_drift(model) + check_unsupported_mode(model)
430 if not offenders:
431 print(
432 f"check_hil_eil_parity.py: EIL==HIL holds -- {len(model.hil_apps)} HIL "
433 "app(s), all with a hil.conf, all in an EIL-checkable mode "
434 f"({', '.join(model.eil_modes)}), all covered by eil_all.sh "
435 f"({len(model.eil_apps)} EIL app(s) incl. {len(model.ra8p1_apps)} RA8P1)."
436 )
437 return 0
438
439 print(
440 f"check_hil_eil_parity.py: {len(offenders)} EIL==HIL parity violation(s):\n",
441 file=sys.stderr,
442 )
443 for offender in offenders:
444 print(f" - {offender}", file=sys.stderr)
445 print(
446 "\nEIL==HIL is an ongoing discipline: ra8_emulator must reproduce the actual\n"
447 "silicon behaviour of every HIL path (bugs included). A HIL app may never\n"
448 "be skipped in EIL -- if ra8_emulator cannot yet model a path, extend\n"
449 "ra8_emulator + eil_all.sh so EIL stays complete. See the header comment.",
450 file=sys.stderr,
451 )
452 return 1
453
454
455if __name__ == "__main__":
456 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298