app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
kicad-bridge-test: end-to-end verb suite (all 48 verbs, coverage-enforced, rp2040 fixture) for daily pre-release testing
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())