skill
molecule-publish
Public Made by Adomby adom
Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.
← Commit history
2.4.0: component-graph GATE (molecule-pipeline #4) - runnable plan/gate script, MPN identity enforced, reuse-over-recreate, empty-dep publishes refused
2 files changed
+294−1
SKILL.md+30−1@@ -139,7 +139,26 @@ plain one shadows it and every import silently gets an unanchored model. (This is exactly what broke the COSMIIC boards.) Matching the name ensures your anchored GLB is the one `model_3d_path` resolves to. -## Stage 2.5 — Component dependencies (AUTOMATED, BEFORE publish)+## Stage 2.5 — Component dependencies (ENFORCED, BEFORE publish)++> **This is a GATE, not a convention** (molecule-pipeline #4). Boards kept+> publishing with `dependencies: {}` — the page renders, the 3D and schematics+> show, and Nucleus/APM displays an empty BOM panel. A publish that skips this+> LOOKS successful, which is why it must be machine-checked.+>+> ```bash+> # 1. plan: parse the BOM, classify, probe the global namespace by MPN+> python3 scripts/component_graph.py plan <board.kicad_pcb|.brd> --out component-plan.json+> # 2. build/reuse pages per the plan (Stage 2.6), assemble the library, wire deps+> # 3. GATE: refuses to let you publish a broken graph (non-zero exit)+> python3 scripts/component_graph.py gate package.json --plan component-plan.json \+> --expect-board <board.kicad_pcb>+> ```+> The gate FAILS on: electrical BOM parts with empty `dependencies`, a board that+> depends on raw parts instead of its `-library` page, any part with no resolved+> MPN, a slug keyed on an LCSC code or a value string, and a stale plan from a+> different board. The ONLY bypass is `--allow-no-components`, which is an+> explicit statement that this board has no component pages on purpose. APM lists a board/molecule's components from `package.json.dependencies` (one-hop expand). This stage is NOT optional and NOT the user's job — a@@ -166,6 +185,16 @@ in APM with no BOM panel (#247, the Taprite carriers). Do it every publish: publishes. Merge everything BEFORE the board's Stage 3 publish so each cycle produces exactly ONE registry version. +- **Resolve a real MPN for every electrical part.** An LCSC/distributor code+ (`c11702`) or a value string (`R_10K_0402`, `100nF`) is a property, not an+ identity — the gate refuses both (#142). Resolve codes with+ `adom-jlcpcb search <code>` before minting a page; a part you cannot resolve+ is FLAGGED for a human, never silently dropped.+- **Reuse beats recreate.** The component namespace is shared across owners, so+ `plan` probes it by resolved MPN and wires the EXISTING page (whatever its+ owner) into the library instead of publishing a duplicate.+- **Throttle component publishes.** The registry rate-limits after ~10 rapid+ publishes; pace them (~2 s apart) on boards with large BOMs. - Mapping helper: `skills/publish-board/kicad_board_bundle.py` on the `adom/adom-project-manager` page (`adom-wiki repo clone adom/adom-project-manager`). Mind its Value-fallback caveat: value-named symbols like `R_4.3K_0402` are
scripts/component_graph.pyadded+264@@ -0,0 +1,264 @@+#!/usr/bin/env python3+"""component_graph.py - ENFORCE the board -> library -> components graph.++Stages 2.5/2.6 were documented but not enforced, so boards kept publishing with+`dependencies: {}` - the publish looks successful (page renders, 3D + schematics+show) while Nucleus/APM shows an empty BOM panel (molecule-pipeline #4).+This script turns that convention into a gate a publish has to pass.++ plan <board> [--sch S] [--owner O] parse BOM -> classify -> probe the wiki+ namespace -> emit plan.json + a table+ of reused / create / FLAGGED parts+ gate <package.json> [--plan P] HARD GATE, non-zero exit on failure:+ - electrical BOM rows but no deps+ - board deps that are not a library page+ - library deps missing electrical parts+ - any slug keyed on an LCSC code or a+ value string instead of an MPN (#142)+ opt out ONLY with --allow-no-components++Stdlib only. Wiki probing shells to `adom-wiki` (skipped with --offline).+"""+import argparse+import json+import os+import re+import subprocess+import sys+import time++# ── identity rules (#142: key by MPN, never by value or distributor code) ──+LCSC_RE = re.compile(r'^c\d{3,}$', re.I)+VALUE_RE = re.compile(+ r'^(?:[rcl]_)?\d+(?:\.\d+)?\s*(?:[kmunp]|meg)?(?:ohm|r|f|h)?(?:_\d{4})?$', re.I)+VALUE_NAMED_RE = re.compile(r'^[rcl]_[\d.]+[kmunp]?_?\d{4}$', re.I)++# ── classification: what actually belongs in the dependency graph ──+NON_ELECTRICAL_REFS = re.compile(r'^(LOGO|FID|MH|H|G|TP|NT|REF\*\*)\d*$', re.I)+NON_ELECTRICAL_FP = re.compile(+ r'logo|fiducial|mounting\s*hole|test\s*point|silk|graphic|icon|keepout', re.I)+FURNITURE_REFS = re.compile(r'^(MP|MC)\d+$', re.I) # Adom machine pins/contacts+++def sh(cmd, timeout=60):+ try:+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+ return r.returncode, r.stdout, r.stderr+ except Exception as e:+ return 1, "", str(e)+++def parse_bom(board_path):+ """Rows from a .kicad_pcb (footprint blocks) or an EAGLE .brd (XML elements).+ Returns [{ref, value, footprint, lcsc, mpn}] with whatever the file carries."""+ txt = open(board_path, encoding="utf-8", errors="replace").read()+ rows = []+ if board_path.lower().endswith((".kicad_pcb", ".kicad_mod")):+ for m in re.finditer(r'\(footprint "([^"]+)"', txt):+ start = m.start()+ depth, i = 0, start+ while i < len(txt):+ if txt[i] == '(':+ depth += 1+ elif txt[i] == ')':+ depth -= 1+ if depth == 0:+ break+ i += 1+ blk = txt[start:i + 1]+ def prop(name):+ mm = re.search(r'\(property "%s" "([^"]*)"' % name, blk)+ return mm.group(1) if mm else ""+ rows.append({+ "ref": prop("Reference"),+ "value": prop("Value"),+ "footprint": m.group(1),+ "lcsc": prop("LCSC") or prop("LCSC Part") or prop("JLCPCB"),+ "mpn": prop("MPN") or prop("Manufacturer Part Number") or prop("MFR#"),+ "dnp": "(dnp" in blk or '"dnp"' in blk,+ })+ else: # EAGLE .brd XML+ for m in re.finditer(r'<element\s+([^>]*?)/?>', txt):+ attrs = dict(re.findall(r'(\w+)="([^"]*)"', m.group(1)))+ rows.append({+ "ref": attrs.get("name", ""),+ "value": attrs.get("value", ""),+ "footprint": attrs.get("package", ""),+ "lcsc": "", "mpn": "", "dnp": False,+ })+ return [r for r in rows if r["ref"]]+++def classify(row):+ """electrical | furniture | skip - only `electrical` is graph-mandatory."""+ ref, fp = row["ref"], row["footprint"]+ if row.get("dnp"):+ return "skip"+ if NON_ELECTRICAL_REFS.match(ref) or NON_ELECTRICAL_FP.search(fp):+ return "skip"+ if FURNITURE_REFS.match(ref):+ return "furniture"+ return "electrical"+++def identity(row):+ """(mpn, problem). An LCSC code or a value string is NOT an identity (#142)."""+ mpn = (row.get("mpn") or "").strip()+ if mpn:+ return mpn, None+ val = (row.get("value") or "").strip()+ if not val:+ return None, "no MPN and no value - unresolvable from the board file"+ if LCSC_RE.match(val) or LCSC_RE.match((row.get("lcsc") or "").strip()):+ return None, (f"only an LCSC/distributor code ({val or row.get('lcsc')}) - "+ "resolve the manufacturer part number "+ "(adom-jlcpcb search <code>) before creating a page")+ if VALUE_RE.match(val) or VALUE_NAMED_RE.match(val):+ return None, (f"value string '{val}' is a property, not an identity - "+ "resolve the real MPN (#142)")+ return val, None+++def probe_wiki(mpn, offline=False):+ """Global-namespace probe by resolved MPN -> existing '<owner>/<slug>' or None.+ Reuse beats recreate: the component namespace is shared across owners."""+ if offline or not mpn:+ return None+ slug = re.sub(r'[^a-z0-9]+', '-', mpn.lower()).strip('-')+ code, out, _ = sh(["adom-wiki", "api", f"/api/v1/search?q={slug}&type=component"])+ if code != 0:+ return None+ try:+ res = (json.loads(out).get("response") or json.loads(out)).get("results") or []+ except Exception:+ return None+ for r in res:+ if (r.get("slug") or "").lower() == slug:+ return f"{r.get('owner')}/{r.get('slug')}"+ return None+++def cmd_plan(a):+ rows = parse_bom(a.board)+ plan = {"board": os.path.basename(a.board), "parts": [],+ "counts": {"electrical": 0, "furniture": 0, "skip": 0,+ "reuse": 0, "create": 0, "flagged": 0}}+ for row in rows:+ kind = classify(row)+ plan["counts"][kind] = plan["counts"].get(kind, 0) + 1+ if kind == "skip":+ continue+ mpn, problem = identity(row)+ existing = probe_wiki(mpn, a.offline)+ status = ("flagged" if problem else "reuse" if existing else "create")+ if kind == "electrical":+ plan["counts"][status] += 1+ plan["parts"].append({"ref": row["ref"], "kind": kind, "mpn": mpn,+ "value": row.get("value"), "footprint": row.get("footprint"),+ "status": status, "page": existing, "problem": problem})++ w = max([len(p["ref"]) for p in plan["parts"]] + [4])+ print(f"{'REF'.ljust(w)} {'STATUS'.ljust(8)} {'KIND'.ljust(10)} IDENTITY")+ for p in sorted(plan["parts"], key=lambda x: (x["status"] != "flagged", x["ref"])):+ ident = p["page"] or p["mpn"] or (p["problem"] or "")+ print(f"{p['ref'].ljust(w)} {p['status'].ljust(8)} {p['kind'].ljust(10)} {ident}")+ c = plan["counts"]+ print(f"\nelectrical={c['electrical']} furniture={c['furniture']} skipped={c['skip']}"+ f" -> reuse={c['reuse']} create={c['create']} FLAGGED={c['flagged']}")+ if c["flagged"]:+ print("\nFLAGGED parts have no resolvable MPN. Resolve them (or explicitly drop\n"+ "them from the graph) before publishing - the gate refuses them.")+ if a.out:+ json.dump(plan, open(a.out, "w"), indent=2)+ print(f"\nplan -> {a.out}")+ return 1 if c["flagged"] else 0+++def cmd_gate(a):+ pkg = json.load(open(a.package))+ deps = pkg.get("dependencies") or {}+ slug = pkg.get("slug", "")+ fails, warns = [], []++ # the board's OWN slug must be MPN-shaped for components (#142)+ if pkg.get("type") == "component" and pkg.get("component", {}).get("category") != "board":+ if LCSC_RE.match(slug):+ fails.append(f"slug '{slug}' is an LCSC/distributor code, not an MPN (#142)")+ if VALUE_NAMED_RE.match(slug) or VALUE_RE.match(slug):+ fails.append(f"slug '{slug}' is a value string, not an MPN (#142)")++ plan = json.load(open(a.plan)) if a.plan and os.path.exists(a.plan) else None+ # A plan from a DIFFERENT board silently "passes" a board it never inspected -+ # the exact class of mistake this gate exists to stop. Compare stems.+ if plan and a.expect_board:+ stem = re.sub(r'[^a-z0-9]+', '', os.path.splitext(plan.get("board", ""))[0].lower())+ want = re.sub(r'[^a-z0-9]+', '', a.expect_board.lower())+ if stem and want and stem not in want and want not in stem:+ fails.append(f"plan is for '{plan.get('board')}' but this package is "+ f"'{a.expect_board}' - re-run `plan` for THIS board")+ electrical = [p for p in (plan or {}).get("parts", []) if p["kind"] == "electrical"]+ flagged = [p for p in electrical if p["status"] == "flagged"]++ if a.allow_no_components:+ warns.append("--allow-no-components: dependency graph intentionally skipped")+ elif plan is None:+ warns.append("no --plan given: cannot verify the graph covers the BOM "+ "(run `component_graph.py plan` first)")+ if not deps:+ fails.append("board publishes with dependencies:{} and no plan to justify it")+ else:+ if electrical and not deps:+ fails.append(f"{len(electrical)} electrical BOM parts but dependencies:{{}} - "+ "APM/Nucleus will show an empty BOM panel (molecule-pipeline #4)")+ if flagged:+ fails.append(f"{len(flagged)} part(s) have no resolved MPN: "+ + ", ".join(f"{p['ref']}({p['problem'][:40]})" for p in flagged[:4]))+ # the board should depend on ONE library page, not raw parts (#15)+ libs = [d for d in deps if d.endswith("-library")]+ if electrical and not libs:+ fails.append("board deps contain no '<board-slug>-library' page - the graph must be "+ "board -> library -> components (#15)")+ if libs and len(deps) > len(libs):+ warns.append("board depends on raw component pages as well as its library; "+ "the library should carry the parts")++ for f in fails:+ print(f"FAIL: {f}")+ for w in warns:+ print(f"WARN: {w}")+ if fails:+ print("\nGATE FAILED - do not publish. Fix the graph, or pass --allow-no-components "+ "if this board genuinely has no component pages.")+ return 1+ print("GATE PASSED" + (" (with warnings)" if warns else ""))+ return 0+++def main():+ ap = argparse.ArgumentParser(description=__doc__,+ formatter_class=argparse.RawDescriptionHelpFormatter)+ sub = ap.add_subparsers(dest="cmd", required=True)++ p = sub.add_parser("plan", help="parse the BOM, classify, probe the wiki namespace")+ p.add_argument("board")+ p.add_argument("--sch")+ p.add_argument("--owner")+ p.add_argument("--out", default="component-plan.json")+ p.add_argument("--offline", action="store_true", help="skip wiki namespace probing")+ p.set_defaults(fn=cmd_plan)++ g = sub.add_parser("gate", help="HARD GATE before publish (non-zero exit = do not publish)")+ g.add_argument("package", help="the board's package.json")+ g.add_argument("--plan", default="component-plan.json")+ g.add_argument("--expect-board", help="board filename this package should match "+ "(guards against passing a stale plan from another board)")+ g.add_argument("--allow-no-components", action="store_true",+ help="explicit opt-out for a board that genuinely has no component pages")+ g.set_defaults(fn=cmd_gate)++ a = ap.parse_args()+ sys.exit(a.fn(a))+++if __name__ == "__main__":+ main()