← Commit history

kicad-bridge-test: end-to-end verb suite (all 48 verbs, coverage-enforced, rp2040 fixture) for daily pre-release testing

John Lauer ·407d6e56f7 ·2mo ago ·parent 0e81601
2 files changed +444
skills/kicad-bridge-test/SKILL.mdadded+105
@@ -0,0 +1,105 @@+---+name: kicad-bridge-test+user-invocable: false+description: DEVELOPER-only skill (user-invocable:false). The standard end-to-end verb test for the KiCad bridge - one runner that exercises ALL 48 kicad_* verbs against a real Windows box with real sample files (the rp2040 tour pack), the way an actual user's AI drives them. Coverage-enforced: a new verb without a test turns the run red. Run it daily pre-release, after every runtime publish, and after any Adom Bridge (ab) core update. Trigger words - test the kicad bridge, verb test, daily test, test all verbs, regression test kicad, verb coverage, run_verb_tests, pre-release check, are any verbs failing.+---++# kicad-bridge-test++The daily end-to-end check that every `kicad_*` verb still works, born from+pre-public-release hardening (2026-08-14). Issues #19 and #20 were both verbs+that had simply never been exercised the way a user calls them; this suite+exists so that class of bug dies in the daily run instead of in a user demo.++## The one command++```bash+python3 skills/kicad-bridge-test/run_verb_tests.py --target AdomLapper+# no execution, just show the matrix:+python3 skills/kicad-bridge-test/run_verb_tests.py --plan+# headless subset (no GUI windows on the box — safe while the user works):+python3 skills/kicad-bridge-test/run_verb_tests.py --target AdomLapper --skip-gui+```++Outputs `/tmp/kicad-verbtest/report.{json,md}`. Exit 0 = every non-skipped verb+passed AND coverage is complete. Runtime for a full pass: ~6-10 min (exports+dominate; STEP and molecule are the slow ones).++## What it does, phase by phase++0. **Fixture push** — sends the rp2040 tour pack (board/schematic/project — the+   same design on the wiki page) to `Documents/adom-kicad-verbtest` via+   `send_files {dest}`. Everything downstream uses these files, so the run+   matches how a user's own project flows through the verbs.+1. **Read-only / meta** (no KiCad needed): describe, status, readiness,+   list_versions, diagnostics, bridge_status, check_for_updates,+   adom_library_status, fix_keyboard, and the two DANGEROUS verbs in their safe+   modes: `upgrade {diagnoseOnly:true}` and `uninstall {dryRun:true}`.+2. **Headless exports** on the fixture: run_drc, run_erc, lint_board,+   lint_schematic, lint_library, export_gerber (+ the `export_gerbers` legacy+   alias), export_pdf, export_svg, export_step, export_bom_csv,+   export_molecule, and format_upgrade **on a copy**, never the fixture.+3. **GUI lifecycle** (background windows, zero focus steal): launch (cold-start+   or idempotent no-op), open_board, open_schematic,+   **open_symbol_editor {Device:R} — the #20 acceptance test with verified+   load**, open_footprint_editor, open_3d_viewer, open_editors, window_info,+   screenshot_all, dismiss_dialogs, send_key (escape), click (3D-viewer+   toolbar, window-targeted), bridge_call (plugin ping).+4. **Library writes**: install_library / install_symbol / install_footprint+   with a generated `ADOM_VERBTEST` part, install_plugin (idempotent+   re-deploy), place_footprint of that part onto a scratch board.+5. **Demo**: beat 1 (`--full` runs all six beats).+6. **Teardown**: close_* then `close {force:true}` — the box ends with no+   KiCad windows and only `Documents/adom-kicad-verbtest` + the ADOM_VERBTEST+   library entries as residue (both removed by `uninstall` for real, which the+   suite never does).++## Coverage enforcement++The runner fetches `kicad_describe`'s live verb list and diffs it against the+matrix. Any live verb that is neither tested nor on the explicit skip list+(with a written reason) fails the run. Current deliberate skip:++- `enable_software_opengl` — writes mesa DLLs into the user's KiCad `bin/`.+  Only correct on GPU-less VMs; test manually on ADOMBASELINE when touching+  that code path.++## Safety rules (why the suite is safe to run on John's real laptop)++- `upgrade` runs `diagnoseOnly:true` ONLY. `uninstall` runs `dryRun:true` ONLY.+- All windows open in the background; the suite never foregrounds, never+  raises, never sends input to a window it didn't open (send_key/click target+  KiCad windows by hwnd).+- format_upgrade mutates a copy; the fixture files are never written.+- Ends with force-close so nothing lingers.+- Uses `--ai-thread "kicad-bridge daily-test"` so the box's Activity Log+  attributes every action.++Prefer running while the box is idle anyway: GUI phases open ~6 KiCad windows+in the background, which is churn the user can feel in the taskbar.++## Daily automation++Cron line for the container (matches the contract-drift-check pattern —+failures should turn into a wiki issue, not scrollback):++```cron+30 11 * * * [ "$(TZ=America/Chicago date +\%H)" = "06" ] && cd /home/adom/project/adom-desktop-kicad-bridge && python3 skills/kicad-bridge-test/run_verb_tests.py --target AdomLapper > /tmp/kicad-verbtest/cron.log 2>&1 || adom-wiki issue create adom/kicad-bridge --title "Daily verb test FAILED $(date +\%F)" --body-file /tmp/kicad-verbtest/report.md+```++Manual daily driving works too: run it, read `report.md`, file an issue per+new failure with the `response_head` from `report.json`.++## Interpreting failures++- **Everything fails at phase 0/1** — the bridge isn't reachable: check+  adom-bridge#56 (spawn regression) before blaming verbs; `bridge_status` and+  the manual-instance workaround are in the kicad-bridge-dev skill.+- **open_symbol_editor fails** — regression of #20; response now honestly+  reports `symbolLoaded:false` instead of lying with success.+- **launch fails** — regression of #19, or a modal is blocking:+  `kicad_screenshot_all` shows it.+- **Exports fail together** — usually kicad-cli path detection or a KiCad+  upgrade changed CLI flags; `kicad_diagnostics` first.+- **Coverage gap listed** — someone (you) added a verb without a test. Add a+  matrix entry or a justified skip; that is the contract.
skills/kicad-bridge-test/run_verb_tests.pyadded+339
@@ -0,0 +1,339 @@+#!/usr/bin/env python3+"""run_verb_tests.py — end-to-end verb test for the KiCad bridge, ALL 48 verbs.++Runs from an Adom container against a real Windows box over the relay, driving+the bridge exactly the way a user's AI does: `adom-bridge --target <box> kicad_*`.+Sample data is the rp2040 tour pack (the same board users see in the README),+pushed to the box at the start of the run.++    python3 run_verb_tests.py --target AdomLapper            # full run+    python3 run_verb_tests.py --plan                         # print the matrix, run nothing+    python3 run_verb_tests.py --target X --only export_gerber,run_drc+    python3 run_verb_tests.py --target X --skip-gui          # headless subset (exports/meta)++Coverage is ENFORCED: the script fetches kicad_describe's live verb list and+fails the run if any verb is neither tested nor on the explicit SKIP list with+a reason. A new verb added to the bridge makes the daily run red until a test+(or a justified skip) exists — that is the point.++Safety hard rules (public-release posture):+  - kicad_upgrade  -> diagnoseOnly:true ONLY (never installs anything)+  - kicad_uninstall-> dryRun:true ONLY (never removes anything)+  - every GUI verb runs BACKGROUND (the bridge's own no-foreground contract);+    the run never foregrounds, never types into unknown windows+  - ends with kicad_close {force:true} so the box is left clean++Output: JSON report + markdown summary in --out (default /tmp/kicad-verbtest/),+exit 0 only if every non-skipped verb passed AND coverage is complete.+"""+import argparse+import json+import shlex+import subprocess+import sys+import time+from datetime import datetime, timezone+from pathlib import Path++AI_THREAD = "kicad-bridge daily-test"+REPO = Path(__file__).resolve().parents[2]+PACK = REPO / "tour-pack-rp2040" / "template"+WINDIR = "C:/Users/{user}/Documents/adom-kicad-verbtest"++# ---------------------------------------------------------------- CLI plumbing++def ab(target: str, verb: str, args: dict, timeout: int = 150) -> dict:+    cmd = ["adom-bridge", "--ai-thread", AI_THREAD, "--target", target,+           verb, json.dumps(args)]+    try:+        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+        out = p.stdout.strip() or p.stderr.strip()+        try:+            return json.loads(out)+        except Exception:+            return {"status": "parse_error", "raw": out[:500]}+    except subprocess.TimeoutExpired:+        return {"status": "cli_timeout", "error": f"CLI timeout after {timeout}s"}+++def ok(r: dict) -> bool:+    if r.get("success") is True:+        return True+    if r.get("success") is False:+        return False+    return r.get("status") == "ok" and not r.get("error")++# ------------------------------------------------------------------ the matrix+# Each entry: verb -> dict(phase, args, check (callable name), timeout, note)+# Checks beyond ok(): named lambdas in CHECKS below.++CHECKS = {+    "ok": ok,+    "verbs>=48": lambda r: ok(r) and len(r.get("verbs") or []) >= 48,+    "editors_listed": lambda r: ok(r) and "summary" in r,+    "windows_listed": lambda r: ok(r) and isinstance((r.get("data") or r).get("editors", None), list),+    "screenshot_files": lambda r: ok(r) and (r.get("screenshots") or r.get("savedTo") or (r.get("data") or {}).get("screenshots")),+    "symbol_verified": lambda r: ok(r) and (r.get("via") == "plugin-background" or "loaded" in str(r.get("output", "")).lower()),+    "dry_report": lambda r: r.get("dryRun") is True and "removed" in r,+    "diagnose_facts": lambda r: ok(r) or "facts" in json.dumps(r)[:2000] or "uacEnableLUA" in json.dumps(r),+    "launched_or_running": lambda r: ok(r) and (r.get("launched") or r.get("alreadyRunning")),+    "demo_beat": lambda r: ok(r) and (r.get("say") or (r.get("data") or {}).get("say") or r.get("stage")),+}++def matrix(w: dict) -> dict:+    """w: paths on the Windows box (filled at runtime)."""+    B, S, P = w["board"], w["sch"], w["pro"]+    OUT = w["out"]+    M = {+        # ---- phase 1: read-only / meta (no KiCad needed)+        "describe":          dict(phase=1, args={}, check="verbs>=48"),+        "status":            dict(phase=1, args={}),+        "readiness":         dict(phase=1, args={}),+        "list_versions":     dict(phase=1, args={}),+        "diagnostics":       dict(phase=1, args={}),+        "bridge_status":     dict(phase=1, args={}),+        "check_for_updates": dict(phase=1, args={}),+        "adom_library_status": dict(phase=1, args={}),+        "fix_keyboard":      dict(phase=1, args={}, note="clears stuck modifiers; safe no-op when none"),+        "upgrade":           dict(phase=1, args={"diagnoseOnly": True}, check="diagnose_facts",+                                  note="SAFETY: diagnoseOnly only — never a real install"),+        "uninstall":         dict(phase=1, args={"dryRun": True}, check="dry_report",+                                  note="SAFETY: dryRun only — never a real removal"),+        # ---- phase 2: headless exports on the sample board (kicad-cli)+        "run_drc":           dict(phase=2, args={"filePath": B}, timeout=200),+        "run_erc":           dict(phase=2, args={"filePath": S}, timeout=200),+        "lint_board":        dict(phase=2, args={"filePath": B}, timeout=200),+        "lint_schematic":    dict(phase=2, args={"filePath": S}, timeout=200),+        "lint_library":      dict(phase=2, args={"filePath": w["symlib"]}, timeout=120,+                                  note="Adom.kicad_sym installed in phase 4a; runs after it"),+        "export_gerber":     dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),+        "export_gerbers":    dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers2"}, timeout=220,+                                  note="legacy alias of export_gerber — kept until callers migrate"),+        "export_pdf":        dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.pdf"}, timeout=220),+        "export_svg":        dict(phase=2, args={"filePath": B, "outputDir": OUT + "/svg"}, timeout=220),+        "export_step":       dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.step"}, timeout=320),+        "export_bom_csv":    dict(phase=2, args={"filePath": S, "outputPath": OUT + "/bom.csv"}, timeout=200),+        "export_molecule":   dict(phase=2, args={"filePath": B, "outputDir": OUT + "/molecule"}, timeout=320),+        "format_upgrade":    dict(phase=2, args={"filePath": w["board_copy"]}, timeout=200,+                                  note="runs on a COPY of the board, never the fixture itself"),+        # ---- phase 3: GUI lifecycle (background windows on the real box)+        "launch":            dict(phase=3, args={"waitSeconds": 25}, check="launched_or_running", timeout=60),+        "open_board":        dict(phase=3, args={"filePath": B, "reason": "daily verb test"}, timeout=160),+        "open_schematic":    dict(phase=3, args={"filePath": S, "reason": "daily verb test"}, timeout=160),+        "open_symbol_editor": dict(phase=3, args={"symbolName": "R", "libraryName": "Device"},+                                   check="symbol_verified", timeout=160,+                                   note="the #20 acceptance test: verified Device:R load"),+        "open_footprint_editor": dict(phase=3, args={}, timeout=160),+        "open_3d_viewer":    dict(phase=3, args={"reason": "daily verb test"}, timeout=200),+        "open_editors":      dict(phase=3, args={}, check="editors_listed"),+        "window_info":       dict(phase=3, args={}),+        "screenshot_all":    dict(phase=3, args={}, check="screenshot_files", timeout=160),+        "dismiss_dialogs":   dict(phase=3, args={}),+        "send_key":          dict(phase=3, args={"key": "escape"},+                                  note="escape to the KiCad window: safe, cancels nothing persistent"),+        "click":             dict(phase=3, args={"__special": "click_3d_toolbar"},+                                  note="clicks the 3D viewer's zoom-in toolbar button (window-targeted)"),+        "bridge_call":       dict(phase=3, args={"exeName": "eeschema", "method": "ping", "params": {}},+                                  note="plugin IPC liveness"),+        # ---- phase 4a: library installs (write into the Adom lib, then verify)+        "install_library":   dict(phase=4, args={"__special": "install_library"}, timeout=160),+        "install_symbol":    dict(phase=4, args={"__special": "install_symbol"}, timeout=160),+        "install_footprint": dict(phase=4, args={"__special": "install_footprint"}, timeout=160),+        "install_plugin":    dict(phase=4, args={}, timeout=160, note="idempotent payload re-deploy"),+        "place_footprint":   dict(phase=4, args={"__special": "place_footprint"}, timeout=220),+        # ---- phase 5: demo (one beat — the full 6-beat tour is --full)+        "demo":              dict(phase=5, args={}, check="demo_beat", timeout=240,+                                  note="beat 1 only by default; --full runs all six"),+        # ---- phase 6: teardown+        "close_symbol_editor":    dict(phase=6, args={}),+        "close_footprint_editor": dict(phase=6, args={}),+        "close_3d_viewer":        dict(phase=6, args={}),+        "close":                  dict(phase=6, args={"force": True}, timeout=120,+                                       note="final teardown — leaves the box with no KiCad windows"),+        # ---- explicit skips (reason required — counted as covered)+        "enable_software_opengl": dict(skip="mutates the user's KiCad bin/ (mesa deploy) — only for GPU-less "+                                            "VMs; run manually on ADOMBASELINE when testing that path"),+    }+    return M++SPECIALS_DOC = """Specials are composed calls the runner fills at runtime:+  click_3d_toolbar   -> find 3D viewer hwnd via window_info, click zoom-in at (280,100) relative:false+  install_library    -> installs the tour pack's AdomRP2040 lib dir if present, else a generated 1-symbol lib+  install_symbol     -> installs a generated test symbol ADOM_VERBTEST into the Adom lib+  install_footprint  -> installs a generated 2-pad test footprint ADOM_VERBTEST into Adom.pretty+  place_footprint    -> places Adom:ADOM_VERBTEST on a scratch copy of the template board+"""++TEST_SYMBOL = '''(symbol "ADOM_VERBTEST" (pin_numbers hide) (in_bom yes) (on_board yes)+  (property "Reference" "U" (at 0 5.08 0) (effects (font (size 1.27 1.27))))+  (property "Value" "ADOM_VERBTEST" (at 0 -5.08 0) (effects (font (size 1.27 1.27))))+  (symbol "ADOM_VERBTEST_0_1" (rectangle (start -5.08 2.54) (end 5.08 -2.54)+    (stroke (width 0.254) (type default)) (fill (type background))))+  (symbol "ADOM_VERBTEST_1_1"+    (pin passive line (at -7.62 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+    (pin passive line (at 7.62 0 180) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))'''++TEST_FOOTPRINT = '''(footprint "ADOM_VERBTEST" (version 20240108) (generator "verbtest")+  (layer "F.Cu")+  (attr smd)+  (fp_text reference "REF**" (at 0 -2) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))+  (fp_text value "ADOM_VERBTEST" (at 0 2) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))+  (pad "1" smd roundrect (at -1 0) (size 1 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25))+  (pad "2" smd roundrect (at 1 0) (size 1 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (roundrect_rratio 0.25)))'''++# -------------------------------------------------------------------- running++def main():+    ap = argparse.ArgumentParser()+    ap.add_argument("--target", default="AdomLapper")+    ap.add_argument("--plan", action="store_true", help="print the matrix and exit")+    ap.add_argument("--only", default="", help="comma-separated verb subset")+    ap.add_argument("--skip-gui", action="store_true", help="phases 1-2 only")+    ap.add_argument("--full", action="store_true", help="run all six demo beats")+    ap.add_argument("--out", default="/tmp/kicad-verbtest")+    a = ap.parse_args()++    outdir = Path(a.out); outdir.mkdir(parents=True, exist_ok=True)++    # live verb list for coverage enforcement+    live = None+    if not a.plan:+        d = ab(a.target, "kicad_describe", {}, timeout=90)+        live = sorted(v if isinstance(v, str) else v.get("name", "").replace("kicad_", "")+                      for v in (d.get("verbs") or []))+        live = [v.replace("kicad_", "") for v in live if v]++    # discover the Windows user for paths+    user = "john"+    if not a.plan:+        st = ab(a.target, "status", {}, timeout=60)+        raw = json.dumps(st)+        if "Users\\\\" in raw:+            user = raw.split("Users\\\\", 1)[1].split("\\\\", 1)[0]+    base = WINDIR.format(user=user)+    w = {+        "board": f"{base}/rp2040-breakout.kicad_pcb",+        "sch": f"{base}/rp2040-breakout.kicad_sch",+        "pro": f"{base}/rp2040-breakout.kicad_pro",+        "board_copy": f"{base}/format-upgrade-copy.kicad_pcb",+        "symlib": f"C:/Users/{user}/Documents/KiCad/10.0/symbols/Adom.kicad_sym",+        "out": base + "/out",+    }+    M = matrix(w)++    if a.plan:+        for verb, spec in sorted(M.items(), key=lambda kv: (kv[1].get("phase", 9), kv[0])):+            tag = f"phase {spec['phase']}" if "phase" in spec else "SKIP"+            print(f"{tag:8} kicad_{verb:24} {json.dumps(spec.get('args', {}))[:70]}"+                  f"  {('# ' + spec['note']) if spec.get('note') else ''}"+                  f"{('# SKIP: ' + spec['skip']) if spec.get('skip') else ''}")+        print(f"\n{len(M)} verbs in matrix. {SPECIALS_DOC}")+        return 0++    # coverage check against the LIVE bridge+    missing = [v for v in (live or []) if v not in M]+    if missing:+        print(f"COVERAGE GAP: live verbs with no test and no skip reason: {missing}")+        # keep going — report at the end, fail exit code++    only = set(x.strip() for x in a.only.split(",") if x.strip())+    results, t0 = {}, time.time()++    # phase 0: push fixtures+    print(f"[0] pushing rp2040 fixture to {base} ...")+    files = [str(PACK / f) for f in ("rp2040-breakout.kicad_pcb", "rp2040-breakout.kicad_sch",+                                     "rp2040-breakout.kicad_pro", "rp2040-breakout.kicad_prl")]+    r = ab(a.target, "send_files", {"filePaths": files, "dest": base}, timeout=200)+    if not ok(r):+        print("FATAL: fixture push failed:", json.dumps(r)[:300]); return 2+    # board copy for format_upgrade (server-side copy via read+write is overkill; re-send under new name)+    ab(a.target, "send_files", {"filePaths": [files[0]], "dest": base + "/copy"}, timeout=120)+    w_copy_src = base + "/copy/rp2040-breakout.kicad_pcb"++    def run(verb, spec):+        if only and verb not in only:+            return+        if spec.get("skip"):+            results[verb] = {"result": "SKIP", "reason": spec["skip"]}+            return+        if a.skip_gui and spec.get("phase", 9) >= 3:+            results[verb] = {"result": "SKIP", "reason": "--skip-gui"}+            return+        args = dict(spec.get("args", {}))+        special = args.pop("__special", None)+        if special == "click_3d_toolbar":+            wi = ab(a.target, "kicad_window_info", {}, timeout=60)+            eds = (wi.get("data") or {}).get("editors") or []+            v3 = next((e for e in eds if "3D" in (e.get("title") or "")), None)+            if not v3:+                results[verb] = {"result": "SKIP", "reason": "no 3D viewer window to click"}+                return+            args = {"hwnd": v3["hwnd"], "x": 280, "y": 100, "relative": False,+                    "reason": "daily verb test: zoom-in toolbar"}+        elif special == "install_symbol":+            args = {"symbolText": TEST_SYMBOL}+        elif special == "install_footprint":+            args = {"footprintText": TEST_FOOTPRINT, "footprintName": "ADOM_VERBTEST"}+        elif special == "install_library":+            args = {"symbols": [TEST_SYMBOL]}+        elif special == "place_footprint":+            args = {"filePath": w["board_copy"], "footprint": "Adom:ADOM_VERBTEST",+                    "x": 10, "y": 10}+        if verb == "format_upgrade":+            args = {"filePath": w_copy_src}+        if verb == "demo" and a.full:+            args = {"all": True}+        t = spec.get("timeout", 150)+        r = ab(a.target, f"kicad_{verb}", args, timeout=t)+        check = CHECKS.get(spec.get("check", "ok"), ok)+        passed = False+        try:+            passed = bool(check(r))+        except Exception:+            passed = False+        results[verb] = {"result": "PASS" if passed else "FAIL",+                         "elapsed": None,+                         "response_head": json.dumps(r)[:400]}+        print(f"  kicad_{verb:24} {'PASS' if passed else 'FAIL'}")++    for phase in (1, 2, 3, 4, 5, 6):+        entries = [(v, s) for v, s in M.items() if s.get("phase") == phase]+        if not entries:+            continue+        print(f"[{phase}] {len(entries)} verbs")+        for verb, spec in sorted(entries):+            run(verb, spec)+        if phase == 3 and not a.skip_gui:+            time.sleep(2)++    # skips recorded for non-phase entries+    for verb, spec in M.items():+        if spec.get("skip") and verb not in results:+            results[verb] = {"result": "SKIP", "reason": spec["skip"]}++    fails = [v for v, r in results.items() if r["result"] == "FAIL"]+    passes = [v for v, r in results.items() if r["result"] == "PASS"]+    skips = [v for v, r in results.items() if r["result"] == "SKIP"]+    report = {+        "target": a.target, "started": datetime.now(timezone.utc).isoformat(),+        "elapsed_s": round(time.time() - t0, 1),+        "totals": {"pass": len(passes), "fail": len(fails), "skip": len(skips)},+        "coverage_gap": missing, "failures": fails, "results": results,+    }+    (outdir / "report.json").write_text(json.dumps(report, indent=2))+    md = [f"# KiCad bridge verb test — {a.target} — {report['started'][:16]}",+          f"**{len(passes)} pass / {len(fails)} fail / {len(skips)} skip** in {report['elapsed_s']}s", ""]+    for v in sorted(results):+        r = results[v]+        md.append(f"- `kicad_{v}` — **{r['result']}**"+                  + (f" ({r.get('reason')})" if r.get("reason") else ""))+    if missing:+        md.append(f"\n**COVERAGE GAP** (live verbs with no test/skip): {missing}")+    (outdir / "report.md").write_text("\n".join(md))+    print(f"\n{len(passes)} pass / {len(fails)} fail / {len(skips)} skip"+          f" -> {outdir}/report.md")+    return 1 if (fails or missing) else 0+++if __name__ == "__main__":+    sys.exit(main())