← Commit history

0.9.279: Wiki #71 second cause, measured on ConfRoomROG with KiCad's own parser: one malformed symbol makes KiCad refuse a whole .kicad_sym (Symbol Editor lists the library EMPTY) while the bridge's structural reader called it clean. New handlers/kicad_parse_check.py runs kicad-cli sym export svg (read-only) as KiCad's verdict and inventory. kicad_install_symbol and kicad_install_library refuse a file KiCad refuses before writing (kicad_refuses_symbol / kicad_refuses_library); kicad_adom_library_status, kicad_lint_library and the kicad_show_library error carry kicadParser; kicad_adom_library_heal mode:prune drops the symbols KiCad refuses (dryRun names them, original kept as .pruned.bak). Lint's checkmark now says whether KiCad was consulted. Also measured: KiCad's library index is process-wide; a reopened editor frame keeps the old symbol set until kicad.exe fully restarts.

John Lauer ·bad5c5b2e7 ·1mo ago ·parent bcbc9bd
7 files changed +264−11
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.278\ No newline at end of file+0.9.279\ No newline at end of file
SKILL.md+6−1
@@ -100,6 +100,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_send_key` | `send_key` | Single key to an EXACT `hwnd` (e.g. Enter/Escape to a dialog). For modifier CHORDS ("ctrl+s", "alt+3") use Bridge's `desktop_press_key` — don't route chords here. | `hwnd`, `key` | | `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` | | `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered | — |+| `kicad_adom_library_heal` | `adom_library_heal` | Reconcile the Adom library: `mode` merge (default) / repoint / prune; `dryRun` previews. From 0.9.279 `prune` drops symbols KiCad refuses | `mode`, `dryRun` |  ## The `show_*` family — START HERE for "show me X" @@ -158,7 +159,7 @@ authoritative check and knows about no-connect flags. | `kicad_export_molecule` | One-shot molecule pack: STEP + silk renders + sch/pro discovery (Adom molecule pipeline) | project/part | | `kicad_lint_board` | DRC pre-check on a `.kicad_pcb` (kicad-cli, read-only) | `filePath` | | `kicad_lint_schematic` | ERC pre-check on a `.kicad_sch` (kicad-cli, read-only) | `filePath` |-| `kicad_lint_library` | Lint a symbol/footprint library | `libraryPath` |+| `kicad_lint_library` | Lint a symbol/footprint library. From 0.9.279 `kicadParser` is KiCad's OWN verdict (`kicad-cli sym export svg`, read-only); `ok:false` when KiCad refuses a file the structural check passed | `libraryPath` | | `kicad_run_erc` | Run ERC on a schematic (read-only) | `filePath` | | `kicad_format_upgrade` | Upgrade a file to the current KiCad file format | `filePath` | | `kicad_get_settings` | Read the bridge's user-visible settings (overlay badges, ...) | — |@@ -229,3 +230,7 @@ If you ever find yourself wanting to talk directly to the KiCad bridge process: - `adom-desktop-fusion` — sibling bridge for Fusion 360. - `adom-desktop-direct-api` — the direct API contract (port 47200 + 47201-47209 fallback, discovery file at `~/.adom/direct-api-port`). - Repo: `adom-inc/adom-desktop/plugins/kicad/`++## KiCad's verdict vs the bridge's reader (from 0.9.279)++A `.kicad_sym` the bridge's reader balances can still be one KiCad refuses, and KiCad then lists that library EMPTY in the Symbol Editor (one malformed symbol hides the whole file). `kicadParser` on `kicad_adom_library_status`, `kicad_lint_library`, install results and the `kicad_show_library` error is KiCad's own answer: `ok`, `symbols` (KiCad's inventory), `error` (KiCad's words). `kicad_install_symbol` and `kicad_install_library` refuse a file KiCad refuses (`errorCode kicad_refuses_symbol` / `kicad_refuses_library`) and write nothing. If a library is already poisoned: `kicad_adom_library_heal {"mode":"prune","dryRun":true}` names the culprit(s); without `dryRun` it rebuilds from the symbols KiCad accepts and keeps the original as `.pruned.bak`.
adom_library.py+71−1
@@ -427,6 +427,33 @@ def add_symbol(kicad_info: dict, symbol_text: str) -> dict:                          f"({len(rebuilt)} < {len(nodes)}); the library on disk was "                          "NOT touched. This is a bridge bug - report it."} +    # THE KICAD GATE (wiki #71, 2026-09-03). The balance check above is OUR+    # parser. KiCad's parser refused a library our reader called clean, and+    # one refused symbol hides the WHOLE library in the Symbol Editor. So ask+    # KiCad (kicad-cli sym export svg on a temp copy) before touching the file:+    # first the incoming symbols alone, so the error names the upload and not+    # the user's library; then the merged result. No kicad-cli = no verdict,+    # and we say so instead of pretending it passed.+    from handlers import kicad_parse_check as _kpc+    incoming_v = _kpc.verdict_text(kicad_info, _kpc.wrap_symbol_blocks([b for _n, b in incoming_symbols]))+    if incoming_v.get("available") and not incoming_v.get("ok"):+        return {"success": False, "errorCode": "kicad_refuses_symbol",+                "kicadParser": incoming_v,+                "error": (f"KiCad's own parser refuses the uploaded symbol(s) {added_names}: "+                          f"{incoming_v.get('error')}. The library on disk was NOT touched, because one "+                          f"refused symbol makes KiCad drop the whole library."),+                "_hint": ("Fix the .kicad_sym so `kicad-cli sym export svg` accepts it (common: bare "+                          "`hide yes` inside (effects ...) instead of `(hide yes)`, or a pre-20231120 "+                          "shape). kicad_lint_library reports KiCad's verdict as kicadParser.")}+    merged_v = _kpc.verdict_text(kicad_info, new_text)+    if merged_v.get("available") and not merged_v.get("ok"):+        return {"success": False, "errorCode": "kicad_refuses_merged_library",+                "kicadParser": merged_v,+                "error": (f"KiCad's parser accepts the upload alone but refuses the merged library: "+                          f"{merged_v.get('error')}. Nothing was written. The existing library is "+                          f"probably already refused by KiCad; run kicad_adom_library_status "+                          f"(kicadParser) and kicad_adom_library_heal mode:prune to drop the culprit.")}+     # One-generation backup beside the library, then an atomic-ish replace.     try:         lib_path.with_suffix(".kicad_sym.bak").write_text(existing_text, encoding="utf-8")@@ -438,6 +465,9 @@ def add_symbol(kicad_info: dict, symbol_text: str) -> dict:         "success": True,         "added": added_names,         "lib_path": str(lib_path),+        "kicadParser": ({"available": True, "ok": True, "symbols": merged_v.get("symbols"), "units": merged_v.get("units")}+                        if merged_v.get("available") else+                        {"available": False, "ok": None, "note": "kicad-cli not found; KiCad's verdict was not obtained"}),     }  @@ -544,6 +574,46 @@ def heal_split(kicad_info: dict, mode: str = "merge", dry_run: bool = False) ->     Never runs on its own: the bridge cannot know which of two libraries is the     user's real one. This exists because a human said which."""     rep = library_split_report(kicad_info)+    if mode == "prune":+        # wiki #71 (2026-09-03): KiCad drops a WHOLE library when one symbol in+        # it is malformed, and the bridge's own reader does not notice. prune+        # asks KiCad about each symbol alone and rebuilds the registered file+        # from the ones it accepts; the original is kept as .kicad_sym.pruned.bak.+        from handlers import kicad_parse_check as _kpc+        reg = rep.get("registeredPath")+        if not reg or not Path(reg).exists():+            return {"success": False, "error": "no registered Adom library file to prune", "report": rep}+        text = Path(reg).read_text(encoding="utf-8")+        whole = _kpc.verdict_file(kicad_info, reg)+        if not whole.get("available"):+            return {"success": False, "error": whole.get("error"), "kicadParser": whole}+        blocks, notes = _extract_symbols_tolerant(text)+        if whole.get("ok"):+            return {"success": True, "healed": False, "mode": "prune", "dryRun": dry_run,+                    "kicadParser": whole, "dropped": [], "kept": [n for n, _ in blocks],+                    "_hint": "KiCad already loads this library; nothing to prune."}+        per = _kpc.refused_symbols(kicad_info, blocks)+        kept = [(n, b) for n, b in blocks if n in per["accepted"]]+        out = {"success": True, "mode": "prune", "dryRun": dry_run, "registeredPath": reg,+               "kicadParserBefore": whole, "dropped": per["refused"], "kept": [n for n, _ in kept],+               "sourceNotes": notes}+        if dry_run:+            out["healed"] = False+            out["_hint"] = (f"Would rebuild {reg} with {len(kept)} symbol(s) KiCad accepts and drop "+                            f"{len(per['refused'])} it refuses (names in dropped). Nothing written.")+            return out+        new_text = _kpc.wrap_symbol_blocks([b for _n, b in kept]).replace('"adom-parse-check"', '"adom-desktop"')+        after = _kpc.verdict_text(kicad_info, new_text)+        if not after.get("ok"):+            return {**out, "success": False, "healed": False, "kicadParserAfter": after,+                    "error": "the pruned library is STILL refused by KiCad; nothing written (report this with the file)"}+        Path(reg + ".pruned.bak").write_text(text, encoding="utf-8")+        Path(reg).write_text(new_text, encoding="utf-8")+        out.update({"healed": True, "kicadParserAfter": after, "backup": reg + ".pruned.bak",+                    "_hint": (f"Rebuilt with {len(kept)} symbol(s) KiCad accepts; dropped {len(per['refused'])} "+                              f"(see dropped, with KiCad's reason each). Original saved beside it. KiCad sees "+                              f"the change after its next library load.")})+        return out     if not rep.get("split"):         return {"success": True, "healed": False, "reason": "no split", **rep}     registered = Path(rep["registeredPath"])@@ -562,7 +632,7 @@ def heal_split(kicad_info: dict, mode: str = "merge", dry_run: bool = False) ->                 "rowNowPointsAt": str(orphan).replace("\\", "/"),                 "_hint": "KiCad reads the repointed file after its next library load (restart)."}     if mode != "merge":-        return {"success": False, "error": f"unknown mode {mode!r}", "allowed": ["merge", "repoint"]}+        return {"success": False, "error": f"unknown mode {mode!r}", "allowed": ["merge", "repoint", "prune"]}     try:         orphan_text = orphan.read_text(encoding="utf-8")     except Exception as e:  # pylint: disable=broad-except
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.278",+  "version": "0.9.279",   "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
handlers/install_library.py+15
@@ -283,6 +283,21 @@ def handle_install_library(kicad_info: dict, args: dict) -> dict:     # right verb; do not refuse, because what this verb did is not wrong.     wrong_tool = _single_part_hint(source, lib_name) +    # wiki #71 (2026-09-03): ask KiCad's own parser before registering a+    # .kicad_sym. A file our reader accepts can still be one KiCad refuses,+    # and KiCad then shows the library empty with every bridge call saying ok.+    if source.is_file() and source.suffix.lower() == ".kicad_sym":+        try:+            from handlers import kicad_parse_check as _kpc+            _v = _kpc.verdict_file(kicad_info, source)+        except Exception:  # pylint: disable=broad-except+            _v = {"available": False}+        if _v.get("available") and not _v.get("ok"):+            return {"success": False, "errorCode": "kicad_refuses_library", "kicadParser": _v,+                    "error": (f"KiCad's own parser refuses {source.name}: {_v.get('error')}. Not copied, "+                              f"not registered: KiCad would list this library empty."),+                    "_hint": "Fix the file until `kicad-cli sym export svg` accepts it; kicad_lint_library shows the same verdict as kicadParser."}+     type_config = LIB_TYPE_CONFIG.get(lib_type)     if not type_config:         return {
handlers/kicad_cli_lint.py+45−7
@@ -723,8 +723,33 @@ def handle_lint_library(kicad_info: dict, args: dict) -> dict:         if v:             versions.add(v) +    # wiki #71 (2026-09-03): the parse above is the BRIDGE's reader. KiCad's own+    # parser refused a library this verb had called clean, and KiCad then showed+    # the library empty. Ask KiCad for every .kicad_sym (read-only export) and+    # let its verdict override the checkmark.+    kicad_verdicts = []+    kicad_refused = []+    try:+        from handlers import kicad_parse_check as _kpc+        for f in files_found["kicad_sym"]:+            v = _kpc.verdict_file(kicad_info, f)+            v["path"] = str(f)+            kicad_verdicts.append(v)+            if v.get("available") and not v.get("ok"):+                kicad_refused.append(v)+    except Exception:  # pylint: disable=broad-except+        pass+     # Build tiered hint-    if sym_file_errors or mod_file_errors:+    if kicad_refused:+        hint = (+            f"❌ KICAD REFUSES {len(kicad_refused)} symbol file(s) that the bridge's structural check passed: "+            + "; ".join(f"{Path(v['path']).name}: {v.get('error')}" for v in kicad_refused[:3])+            + ". KiCad lists such a library EMPTY, and one malformed symbol does that to the whole file. "+            "DO NOT install. Fix the file until `kicad-cli sym export svg` accepts it "+            "(common: bare `hide yes` inside (effects ...) instead of `(hide yes)`)."+        )+    elif sym_file_errors or mod_file_errors:         hint = (             f"❌ {len(sym_file_errors)} symbol file(s) + {len(mod_file_errors)} footprint file(s) "             f"failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, "@@ -747,16 +772,29 @@ def handle_lint_library(kicad_info: dict, args: dict) -> dict:                 f"OK to proceed with install but expect a one-time format change."             )         else:-            hint = (-                f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "-                f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "-                f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."-            )+            _kp_ok = [v for v in kicad_verdicts if v.get("available") and v.get("ok")]+            _kp_syms = sum(len(v.get("symbols") or []) for v in _kp_ok)+            if kicad_verdicts and all(v.get("available") for v in kicad_verdicts):+                hint = (+                    f"✅ KiCad's own parser loads {_kp_syms} symbol(s) from {len(_kp_ok)} file(s) (kicadParser); "+                    f"the bridge's structural check counts {symbol_count} symbol(s) + {footprint_count} footprint(s), "+                    f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "+                    f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."+                )+            else:+                hint = (+                    f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) balanced by the BRIDGE's reader, "+                    f"format version(s) {sorted(versions) if versions else 'unspecified'}. KiCad's own parser was "+                    f"NOT consulted (kicad-cli not found), so this is a structural check only, not KiCad's verdict."+                )      return {         "success": True,+        "ok": not (kicad_refused or sym_file_errors or mod_file_errors),+        "kicadParser": (kicad_verdicts[0] if len(kicad_verdicts) == 1 else kicad_verdicts) if kicad_verdicts else None,         "output": f"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), "-                  f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)",+                  f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)"+                  + (f", KiCad refuses {len(kicad_refused)} file(s)" if kicad_refused else ""),         "data": {             "kind": kind,             "source_path": str(path),
handlers/kicad_parse_check.pyadded+125
@@ -0,0 +1,125 @@+"""KiCad's OWN verdict on a symbol library, via `kicad-cli sym export svg`.++Why this module exists (wiki #71, 2026-09-03, measured on ConfRoomROG):+the bridge's structural reader (`adom_library`, `kicad_lint_library`) said a+five-symbol Adom.kicad_sym "parsed cleanly", while KiCad itself answered+"Unable to load library" and the Symbol Editor showed the Adom library EMPTY.+Three minimal test symbols carried `hide yes` as bare tokens inside+`(effects ...)`; our reader only balances parentheses, KiCad's parser does+not accept the tokens, and ONE bad symbol makes KiCad drop the WHOLE library.+The two well-formed symbols alone loaded fine.++So: our parser is not KiCad's parser. Anything that writes into a library+KiCad reads must ask KiCad first. `sym export svg` is the one kicad-cli+command that parses a .kicad_sym read-only (the `upgrade` command rewrites+the file) and prints one "Plotting symbol 'NAME' unit N" line per unit it+loaded, so it doubles as a symbol inventory from KiCad's point of view.+"""+from __future__ import annotations++import os+import re+import shutil+import subprocess+import tempfile+import time+from pathlib import Path++_PLOT_RE = re.compile(r"Plotting symbol '([^']+)' unit (\d+)")+++def cli_exe(kicad_info: dict) -> str | None:+    cli = (kicad_info or {}).get("kicad_cli_exe")+    if cli and Path(cli).exists():+        return cli+    base = (kicad_info or {}).get("base_dir")+    if base:+        for cand in (Path(base) / "bin" / "kicad-cli.exe", Path(base) / "bin" / "kicad-cli"):+            if cand.exists():+                return str(cand)+    return None+++def verdict_file(kicad_info: dict, lib_path: str | Path, timeout_s: float = 60.0) -> dict:+    """KiCad's verdict on an existing .kicad_sym.++    Returns {available, ok, symbols[], units, error, exitCode, durationMs}.+    available=False means kicad-cli was not found: no verdict either way, and+    callers must SAY that rather than treat it as a pass."""+    cli = cli_exe(kicad_info)+    if not cli:+        return {"available": False, "ok": None, "symbols": [], "units": 0,+                "error": "kicad-cli not found; KiCad's parser could not be asked"}+    lib_path = str(lib_path)+    if not Path(lib_path).exists():+        return {"available": True, "ok": False, "symbols": [], "units": 0,+                "error": f"file not found: {lib_path}"}+    out_dir = tempfile.mkdtemp(prefix="adom-kicad-parse-")+    t0 = time.monotonic()+    try:+        proc = subprocess.run([cli, "sym", "export", "svg", "-o", out_dir, lib_path],+                              capture_output=True, text=True, timeout=timeout_s,+                              creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))+        text = (proc.stdout or "") + "\n" + (proc.stderr or "")+        names: list[str] = []+        units = 0+        for m in _PLOT_RE.finditer(text):+            units += 1+            if m.group(1) not in names:+                names.append(m.group(1))+        ok = proc.returncode == 0+        err = ""+        if not ok:+            lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not _PLOT_RE.search(ln)]+            err = " | ".join(lines[:3])[:400] or f"kicad-cli exit {proc.returncode}"+        return {"available": True, "ok": ok, "symbols": names, "units": units,+                "error": err, "exitCode": proc.returncode,+                "durationMs": int((time.monotonic() - t0) * 1000)}+    except subprocess.TimeoutExpired:+        return {"available": True, "ok": False, "symbols": [], "units": 0,+                "error": f"kicad-cli did not answer within {timeout_s:.0f}s"}+    except Exception as e:  # pylint: disable=broad-except+        return {"available": True, "ok": False, "symbols": [], "units": 0,+                "error": f"{type(e).__name__}: {e}"[:300]}+    finally:+        shutil.rmtree(out_dir, ignore_errors=True)+++def verdict_text(kicad_info: dict, lib_text: str, timeout_s: float = 60.0) -> dict:+    """Same verdict for library TEXT that has not been written anywhere yet:+    the gate an install runs BEFORE it touches the user's file."""+    fd, tmp = tempfile.mkstemp(suffix=".kicad_sym", prefix="adom-candidate-")+    os.close(fd)+    try:+        Path(tmp).write_text(lib_text, encoding="utf-8")+        return verdict_file(kicad_info, tmp, timeout_s)+    finally:+        try:+            os.unlink(tmp)+        except OSError:+            pass+++def wrap_symbol_blocks(blocks: list[str]) -> str:+    return ('(kicad_symbol_lib (version 20231120) (generator "adom-parse-check")\n'+            + "\n".join(blocks) + "\n)\n")+++def refused_symbols(kicad_info: dict, symbol_blocks: list[tuple[str, str]],+                    timeout_s: float = 60.0) -> dict:+    """Which of these (name, block) symbols does KiCad refuse ON THEIR OWN?++    One bad symbol hides a whole library, so this is how a repair finds the+    culprit(s): each block is wrapped as a one-symbol library and parsed alone.+    Returns {available, accepted[], refused{name: error}}."""+    cli = cli_exe(kicad_info)+    if not cli:+        return {"available": False, "accepted": [], "refused": {}}+    accepted, refused = [], {}+    for name, blk in symbol_blocks:+        v = verdict_text(kicad_info, wrap_symbol_blocks([blk]), timeout_s)+        if v.get("ok"):+            accepted.append(name)+        else:+            refused[name] = v.get("error") or "refused"+    return {"available": True, "accepted": accepted, "refused": refused}