4"""Gate: EIL==HIL parity -- every HIL app is also exercised in the emulator.
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.
10Two harnesses already exist:
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.
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.
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.
31It FAILS (non-zero) if any of the following hold, with a precise message per
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.
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
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).
73from __future__
import annotations
79from dataclasses
import dataclass
80from pathlib
import Path
82REPO_ROOT = Path(__file__).resolve().parents[2]
84HIL_ALL = REPO_ROOT /
"scripts" /
"hil" /
"all.sh"
85EIL_ALL = REPO_ROOT /
"scripts" /
"emu" /
"eil_all.sh"
88DISCOVERY_SKIP =
"README.md"
92EIL_MODE_CASE_HEADER =
'case "${HIL_MODE:-}" in'
100def _read(path: Path) -> str |
None:
102 return path.read_text(encoding=
"utf-8")
107def _rel(path: Path) -> str:
108 if path.is_relative_to(REPO_ROOT):
109 return str(path.relative_to(REPO_ROOT))
113def _parse_repo_root_dir(text: str, var: str) -> Path |
None:
114 """Parse ``VAR="${REPO_ROOT}/<path>"`` and return REPO_ROOT / <path>.
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.
119 pattern = re.compile(
r"^\s*" + re.escape(var) +
r'="\$\{REPO_ROOT\}/([^"]+)"', re.MULTILINE)
120 match = pattern.search(text)
123 return REPO_ROOT / match.group(1)
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.
129 The authoritative list is the ``case`` label right after
130 ``case "${HIL_MODE:-}" in``::
132 uart_scrape | alive | jlink_memprobe | rtt_scrape | hil_eth_tcp) : ;;
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.
137 lines = text.splitlines()
138 for idx, line
in enumerate(lines):
139 if EIL_MODE_CASE_HEADER
not in line:
141 for follow
in lines[idx + 1 :]:
142 stripped = follow.strip()
143 if not stripped
or stripped.startswith(
"#"):
145 match = re.match(
r"^([a-z0-9_ |]+)\)\s*:\s*;;\s*$", stripped)
148 return [tok.strip()
for tok
in match.group(1).split(
"|")
if tok.strip()]
152def _discover_apps(root: Path) -> list[str]:
153 """App names directly under a hil/ root -- mirrors hil_discover_apps().
155 An app is an immediate child DIRECTORY; the README.md file is skipped.
156 Returned sorted, matching the harness's ``| sort``.
158 if not root.is_dir():
160 names = [c.name
for c
in root.iterdir()
if c.is_dir()
and c.name != DISCOVERY_SKIP]
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():
171 for raw
in text.splitlines():
173 if line.startswith(
"HIL_MODE="):
174 return line[len(
"HIL_MODE=") :].strip().strip(
'"').strip(
"'")
180 """Everything the checks need, derived once from the two harness scripts."""
182 hil_root_hilall: Path
183 hil_root_silall: Path
187 ra8p1_apps: list[str]
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)
197 errors.append(f
"cannot read {_rel(HIL_ALL)}")
199 errors.append(f
"cannot read {_rel(EIL_ALL)}")
200 if hil_text
is None or eil_text
is None:
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:
215 f
"{_rel(EIL_ALL)}: could not parse the EIL-capable mode set from the "
216 f
"'{EIL_MODE_CASE_HEADER}' dispatch"
218 if None in (hil_root_hilall, hil_root_silall, ra8p1_root, eil_modes):
221 hil_apps = _discover_apps(hil_root_hilall)
222 eil = set(_discover_apps(hil_root_silall))
224 name
for name
in _discover_apps(ra8p1_root)
if (ra8p1_root / name /
"hil.conf").is_file()
226 eil.update(ra8p1_apps)
228 hil_root_hilall=hil_root_hilall,
229 hil_root_silall=hil_root_silall,
230 ra8p1_root=ra8p1_root,
233 ra8p1_apps=ra8p1_apps,
234 eil_apps=sorted(eil),
243def _msg_no_conf(appdir_rel: str) -> str:
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/."
251def _msg_escapes_eil(app: str) -> str:
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."
259def _msg_root_drift(model: Model) -> str:
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."
267def _msg_bad_mode(app: str, mode: str, capable: set[str]) -> str:
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 "
276def check_missing_conf(model: Model) -> list[str]:
277 """Check 1: every hil/ app must declare a hil.conf."""
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()
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)
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:
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))
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:
312 ra8p1 = root /
"ra8p1"
313 (hil /
"good").mkdir(parents=
True)
314 (hil /
"good/hil.conf").write_text(
"HIL_MODE=uart_scrape\n", encoding=
"ascii")
316 good = Model(hil, hil, ra8p1, [
"uart_scrape"], [
"good"], [], [
"good"])
318 check_missing_conf(good) + check_set_drift(good) + check_unsupported_mode(good)
321 (hil /
"missing").mkdir()
322 (hil /
"unsupported").mkdir()
323 (hil /
"unsupported/hil.conf").write_text(
"HIL_MODE=usb_only\n", encoding=
"ascii")
326 root /
"different-hil",
329 [
"good",
"missing",
"unsupported"],
334 check_missing_conf(bad),
335 check_set_drift(bad),
336 check_unsupported_mode(bad),
338 parsed_modes = _parse_eil_modes(
339 'case "${HIL_MODE:-}" in\n uart_scrape | alive) : ;;\nesac\n'
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"),
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}")
351 print(f
"check_hil_eil_parity.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
353 print(
"check_hil_eil_parity.py --selftest: all cases pass (both directions).")
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}")
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)
379 print(f
" - {err}", file=sys.stderr)
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.",
388def main(argv: list[str]) -> int:
389 """Fail when the HIL app set and the EIL app set have drifted apart.
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.
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.
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
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).",
411 help=
"enumerate the derived hil/eil sets + modes, then exit 0 (no gating).",
413 parser.add_argument(
"--selftest", action=
"store_true", help=
"run isolated both-direction tests")
414 args = parser.parse_args(argv[1:])
418 parser.error(
"--selftest and --list are mutually exclusive")
421 model, errors = build_model()
423 return _report_parse_errors(errors)
429 offenders = check_missing_conf(model) + check_set_drift(model) + check_unsupported_mode(model)
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)."
440 f
"check_hil_eil_parity.py: {len(offenders)} EIL==HIL parity violation(s):\n",
443 for offender
in offenders:
444 print(f
" - {offender}", file=sys.stderr)
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.",
455if __name__ ==
"__main__":
456 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.