main
Colby Knox 0.31.0: physical_properties, preferences, select_items, cancel_run join (#29); proposals may carry the platform tier e3abbdb 8d ago
#!/usr/bin/env python3
"""Generate the standard's verb files from the reference bridge's catalog and the agreed proposals.

The Altium bridge's verbs are the base: reference/altium-<version>.json is its `altium_describe` reply, and each verb
gets a tier (tools/tiers.json). Verbs proposed by any bridge are added from tools/proposed/<thread>.json once a thread
other than the proposer's has agreed to them (agreedBy). Each bridge's coverage, Altium's included, comes from its verb
list (reference/<bridge>-<version>.json) and the hand-made map of verbs that do the same job under another name
(tools/equivalents/<bridge>.json). One file per owner, so two threads never push the same file.

Writes verbs/standard.json, verbs/coverage.json, docs/coverage.md and the coverage block in README.md.
Run it after refreshing any reference file:  python3 tools/generate.py
"""
import glob
import json
import os
import re

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TIERS = {
    "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",
    "native": "Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Every tool has one; the bridge exposes it under this name.",
    "checks": "Engineering computed from the board model, not by the tool: one shared engine, so every bridge that serves the model gets them all.",
    "app": "Driving the application itself (launch, licence, dialogs, windows, views). Each bridge has its own; the names are shared where the job is.",
    "platform": "Specific to one tool's platform (Altium 365, OutJobs, integrated libraries). Not part of the standard; listed so nothing is lost.",
}
ORDER = ["core", "native", "checks", "app", "platform"]


def load(name):
    with open(os.path.join(ROOT, name)) as f:
        return json.load(f)


def latest(bridge):
    # by version number, not by text: 1.0.10 is newer than 1.0.9
    def key(f):
        v = os.path.basename(f)[len(bridge) + 1:-len(".json")]
        return [int(x) if x.isdigit() else x for x in re.split(r"[.-]", v)]
    files = sorted(glob.glob(os.path.join(ROOT, "reference", f"{bridge}-*.json")), key=key)
    return load(os.path.relpath(files[-1], ROOT))


def strip(name, prefix):
    return name[len(prefix):] if name.startswith(prefix) else name


# exact and partial count as covered; unsupported (the verb answers not_supported for the whole job) is listed, not counted
MATCHES = ("exact", "partial", "unsupported")
PROPOSAL_FIELDS = ["name", "tier", "summary", "input", "output", "hint", "proposedBy", "since", "agreedBy"]


def proposals(tiered):
    """tools/proposed/<thread>.json, checked. A proposal lands only when a thread other than its proposer agreed to it."""
    entries, problems, seen = [], [], set()
    for path in sorted(glob.glob(os.path.join(ROOT, "tools", "proposed", "*.json"))):
        thread = os.path.basename(path)[:-len(".json")]
        for i, p in enumerate(load(os.path.relpath(path, ROOT))):
            name = p.get("name") or f"{thread} entry {i}"
            missing = [k for k in PROPOSAL_FIELDS if k not in p]
            if missing:
                problems.append(f"{name}: missing {', '.join(missing)}")
                continue
            if p["proposedBy"] != thread:
                problems.append(f"{name}: proposedBy {p['proposedBy']!r} but filed in tools/proposed/{thread}.json")
            if p["tier"] not in ORDER:
                problems.append(f"{name}: tier {p['tier']!r} is not one of core, native, checks, app, platform")
            if not [t for t in p["agreedBy"] if t != p["proposedBy"]]:
                problems.append(f"{name}: agreedBy names no thread other than its proposer ({p['proposedBy']})")
            # a tiered Altium verb was in the standard first; an untiered one of the same name is Altium implementing this
            if name in tiered:
                problems.append(f"{name}: already a standard verb from the reference; credit the proposer's verb in tools/equivalents/ instead")
            if name in seen:
                problems.append(f"{name}: proposed twice")
            seen.add(name)
            entries.append(p)
    if problems:
        raise SystemExit("tools/proposed/ refused:\n  " + "\n  ".join(problems))
    return entries


def main():
    ref = latest("altium")
    tiers = load("tools/tiers.json")
    eq = {os.path.basename(f)[:-len(".json")]: load(os.path.relpath(f, ROOT))
          for f in glob.glob(os.path.join(ROOT, "tools", "equivalents", "*.json"))}
    bridges = {b: latest(b) for b in ("altium", "kicad", "fusion360")}
    others = {b: o for b, o in bridges.items() if b != "altium"}
    prefixes = {"altium": "altium_", "kicad": "kicad_", "fusion360": "fusion_"}
    have = {b: {strip(v if isinstance(v, str) else v["name"], prefixes[b]) for v in o["verbs"]} for b, o in bridges.items()}
    proposed = proposals(tiers)
    proposed_names = {p["name"] for p in proposed}
    untiered = sorted(strip(v["name"], "altium_") for v in ref["verbs"]
                      if strip(v["name"], "altium_") not in tiers and strip(v["name"], "altium_") not in proposed_names)
    if untiered:
        raise SystemExit(f"reference/altium-{ref['version']}.json has verbs with no tier: give each one in tools/tiers.json\n  "
                         + ", ".join(untiered))

    standard = []
    for v in ref["verbs"]:
        name = strip(v["name"], "altium_")
        if name in proposed_names:
            continue  # Altium implements an agreed proposal: the proposal is the spec, and Altium is credited exact
        standard.append({
            "name": name, "tier": tiers[name], "source": "altium", "summary": v.get("summary", ""),
            "input": v.get("input", {}), "output": v.get("output", {}), "hint": v.get("hint", ""),
            "pitfalls": v.get("pitfalls", []), "related": [strip(r, "altium_") for r in v.get("related", [])],
            "example": v.get("example", {}), "longRunning": v.get("longRunning", False), "timeoutSeconds": v.get("timeoutSeconds"),
        })
        # the reference still answers some verbs in mils; the standard is mm (docs/conventions.md)
        if re.search(r"Mils?\b", json.dumps([v.get("input", {}), v.get("output", {})])):
            standard[-1]["unitsNote"] = "The reference bridge answers this verb in mils (xMil). A bridge following the standard answers in mm under Mm names (xMm)."
    for p in proposed:
        standard.append({
            "name": p["name"], "tier": p["tier"], "source": "proposed", "summary": p["summary"],
            "input": p["input"], "output": p["output"], "hint": p["hint"], "pitfalls": p.get("pitfalls", []),
            "related": p.get("related", []), "example": p.get("example", {}), "longRunning": p.get("longRunning", False),
            "timeoutSeconds": p.get("timeoutSeconds"), "proposedBy": p["proposedBy"], "since": p["since"], "agreedBy": p["agreedBy"],
        })
    standard.sort(key=lambda s: (ORDER.index(s["tier"]), s["name"]))
    # an equivalent filed under a name the standard lacks (a typo, a renamed verb) would credit nothing, silently
    names = {s["name"] for s in standard}
    for b, section in eq.items():
        for k in section:
            if k not in names:
                print(f"warning: tools/equivalents/{b}.json: {k} is not a standard verb, not counted")

    coverage = []
    for s in standard:
        row = {"name": s["name"], "tier": s["tier"], "source": s["source"]}
        for b in bridges:
            row[b] = None
            # the bridge's own equivalents file comes before a name match: a verb can share a standard name and do
            # another job (kicad_edit_footprint edited placed parts; the standard edit_footprint is the library verb)
            if s["name"] in eq.get(b, {}):
                verb, match, *note = eq[b][s["name"]]
                if match not in MATCHES:
                    raise SystemExit(f"tools/equivalents/{b}.json: {s['name']}: match {match!r} is not one of {', '.join(MATCHES)}")
                parts = [strip(p.strip(), prefixes[b]) for p in re.split(r"\s*[/+]\s*", verb)]
                # an equivalent counts only when its verbs are in that bridge's reference catalog
                absent = [p for p in parts if p not in have[b]]
                if absent:
                    print(f"warning: {b} {s['name']}: {', '.join(absent)} not in reference/{b}-{bridges[b]['version']}.json, not counted")
                else:
                    row[b] = {"verb": " / ".join(prefixes[b] + p for p in parts), "match": match}
                    if note:
                        row[b]["note"] = note[0]
                    # a stale entry hides the bridge's own same-named verb (altium-lane, 2026-10-30: four sheet verbs read
                    # partial for six releases after Altium shipped them exact); keep it only when that verb does another job,
                    # and say so in the entry's note, which quiets this
                    if parts != [s["name"]] and s["name"] in have[b] and not note:
                        print(f"warning: {b} {s['name']}: tools/equivalents/{b}.json maps it to {row[b]['verb']} ({match}), but the bridge has its own {prefixes[b]}{s['name']}: drop the entry, or give it a note saying the same-named verb does another job")
            if row[b] is None and s["name"] in have[b]:
                row[b] = {"verb": prefixes[b] + s["name"], "match": "exact"}
        coverage.append(row)

    meta = {"standard": load("package.json")["version"],
            "reference": {"bridge": "altium", "version": ref["version"]}, "proposed": len(proposed),
            "compared": {b: o["version"] for b, o in others.items()}, "tiers": TIERS}
    os.makedirs(os.path.join(ROOT, "verbs"), exist_ok=True)
    with open(os.path.join(ROOT, "verbs", "standard.json"), "w") as f:
        json.dump({**meta, "verbs": standard}, f, indent=1)
        f.write("\n")
    with open(os.path.join(ROOT, "verbs", "coverage.json"), "w") as f:
        json.dump({**meta, "verbs": coverage}, f, indent=1)
        f.write("\n")

    # counts per tier and bridge
    def count(b, tier, matches=("exact", "partial")):
        return sum(1 for r in coverage if r["tier"] == tier and r.get(b) and r[b]["match"] in matches)
    lines = ["| Tier | Verbs | Altium | KiCad | Fusion 360 |", "|---|---|---|---|---|"]
    tot = {"altium": 0, "kicad": 0, "fusion360": 0, "all": 0}
    for t in ORDER:
        n = sum(1 for r in coverage if r["tier"] == t)
        a, k, fu = count("altium", t), count("kicad", t), count("fusion360", t)
        lines.append(f"| **{t}** | {n} | {a} | {k} | {fu} |")
        if t != "platform":
            tot["all"] += n; tot["altium"] += a; tot["kicad"] += k; tot["fusion360"] += fu
    lines.append(f"| standard (all but platform) | {tot['all']} | {tot['altium']} | {tot['kicad']} | {tot['fusion360']} |")
    summary = "\n".join(lines)

    # docs/coverage.md: the full table
    md = [f"# Coverage: every standard verb on each bridge",
          "",
          f"Altium bridge {ref['version']} (the reference), KiCad bridge {others['kicad']['version']}, Fusion 360 bridge {others['fusion360']['version']}. "
          f"{len(proposed)} verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.",
          "`exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing.",
          "Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand.", "", summary, ""]
    for t in ORDER:
        md += [f"## {t}", "", TIERS[t], "", "| Verb | What it does | Altium | KiCad | Fusion 360 |", "|---|---|---|---|---|"]
        for r, s in zip(coverage, standard):
            if r["tier"] != t:
                continue
            cell = lambda c: "" if not c else ("unsupported" if c["match"] == "unsupported" else f"`{c['verb']}`" + (" (partial)" if c["match"] == "partial" else ""))
            summ = s["summary"].replace("|", "/").replace("\n", " ")
            if len(summ) > 160:
                summ = summ[:157].rsplit(" ", 1)[0] + "..."
            mark = " *proposed*" if r["source"] == "proposed" else ""
            md.append(f"| `{r['name']}`{mark} | {summ} | {cell(r.get('altium'))} | {cell(r.get('kicad'))} | {cell(r.get('fusion360'))} |")
        md.append("")
    with open(os.path.join(ROOT, "docs", "coverage.md"), "w") as f:
        f.write("\n".join(md))

    # README coverage block
    readme = os.path.join(ROOT, "README.md")
    if os.path.exists(readme):
        text = open(readme).read()
        block = f"<!-- coverage:start (tools/generate.py writes this) -->\n{summary}\n<!-- coverage:end -->"
        text = re.sub(r"<!-- coverage:start.*?<!-- coverage:end -->", lambda _: block, text, flags=re.S)
        open(readme, "w").write(text)
    print(f"standard: {len(standard)} verbs ({tot['all']} in the standard, {len(proposed)} proposed); "
          f"altium covers {tot['altium']}, kicad {tot['kicad']}, fusion {tot['fusion360']}")


if __name__ == "__main__":
    main()