← 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

Drew Owens ·0ec0412d86 ·2mo ago ·parent 0ef69de
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()