Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""`akcli` verification commands: ``check diff verify pinmap expected relink-symbols``.
Lint-style checks and net-equivalence proofs over one or two schematics, the
``expected`` pin-table extractor that feeds ``pinmap --expected``, and the
``relink-symbols`` lib_symbols re-embedder. Heavy imports stay LAZY per handler.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .. import config as _config
from .. import report as _report
from ..errors import EXIT
from ._shared import (
_add_exit_policy_flags,
_dumps,
_emit,
_ExitWith,
_findings_exit,
_load_cfg,
_load_schematic,
_require_path,
_schematic_meta,
_stamp,
)
def _default_check_names(path, cfg) -> list[str]:
"""The check set ``akcli check PATH`` runs with no explicit selector.
``erc``/``power``/``bom``/``nets`` always; ``layout``/``integrity`` are
KiCad-only so they're added for a ``.kicad_sch`` input; ``pairs`` runs
unless ``[check].pairs = false`` opts out. Shared with ``akcli gate``'s
check gate so both commands run the exact same set — a narrower ad hoc
list (like ``release preflight``'s fixed erc/power/bom/nets four-tuple)
is a DIFFERENT, intentionally lighter selection, not this one.
"""
which = ["erc", "power", "bom", "nets"]
if str(path).lower().endswith(".kicad_sch"):
which.extend(["layout", "integrity"])
if cfg.check.get("pairs", True):
which.append("pairs")
return which
def _run_check(name: str, sch, cfg, args: argparse.Namespace) -> list:
"""Run one check by name, importing lazily; missing ERC degrades gracefully."""
if name == "erc":
try:
from ..checks import erc # lazy; may not exist yet
except ImportError:
sys.stderr.write("note: ERC check unavailable in this build; skipped\n")
return []
return erc.run(sch, cfg)
if name == "power":
from ..checks import power
return power.run(sch, cfg)
if name == "bom":
from ..checks import bom
return bom.run(sch, cfg)
if name == "layout":
from ..checks import layout
clearance = (getattr(cfg, "check", None) or {}).get("group_clearance", 0)
return layout.run(args.path, group_clearance_mil=float(clearance or 0))
if name == "nets":
from ..checks import nets
out = nets.run(sch, cfg)
# geom near-miss lint reads the raw s-expression primitives, so it is
# KiCad-only; the suffix gate keeps default Altium runs quiet.
if str(args.path).lower().endswith(".kicad_sch"):
from ..checks import geom
out.extend(geom.run(args.path))
return out
if name == "libsync":
from ..checks import libsync
return libsync.run(args.path, lib_dirs=getattr(args, "symbols", None))
if name == "pairs":
from ..checks import pairs
return pairs.run(sch, cfg)
if name == "integrity":
# The write gate's document checks (dangling endpoints, duplicate
# uuids/designators, unresolved lib_ids, broken instance paths, NC
# conflicts) — run read-only over ANY .kicad_sch, not just files akcli
# itself just wrote (imports and hand-edited files need them most).
if not str(args.path).lower().endswith(".kicad_sch"):
return []
from .. import sexpr as _sexpr
from ..writers import connectivity as _connectivity
text = Path(args.path).read_text(encoding="utf-8")
# the path enables the checks that need the filesystem (a (sheet ...)
# whose child file is missing)
return _connectivity.verify(_sexpr.parse(text), path=args.path)
return []
def _cmd_check(args: argparse.Namespace) -> int:
path = _require_path(args.path)
sch = _load_schematic(path)
cfg = _load_cfg(args, path)
which: list[str] = []
if args.erc:
which.append("erc")
if args.power:
which.append("power")
if args.bom:
which.append("bom")
if getattr(args, "layout", False):
which.append("layout")
if getattr(args, "nets", False):
which.append("nets")
if getattr(args, "libsync", False):
which.append("libsync")
if getattr(args, "pairs", False):
which.append("pairs")
if getattr(args, "integrity", False):
which.append("integrity")
intent_file = getattr(args, "intent", None)
if intent_file == "@config":
# bare `--intent`: the project's standing intent contract from config
intent_file = cfg.paths.get("intent")
if not intent_file:
raise _ExitWith(
EXIT["USAGE"],
"ERROR: --intent given without a file and no [paths] intent "
"in akcli.toml — pass --intent FILE or add the config key")
contract_file = getattr(args, "contract", None)
if not which and not intent_file and not contract_file:
# --intent/--contract alone are pure assertions, like any other selector.
which = _default_check_names(path, cfg)
findings: list = []
for name in which:
findings.extend(_run_check(name, sch, cfg, args))
if intent_file:
from ..checks import intent as intent_mod
spec = intent_mod.load(intent_file) # BAD_CONFIG/PROTOCOL_MISMATCH via main
findings.extend(intent_mod.run(sch, spec))
if contract_file:
from ..checks import contract as contract_mod
doc = contract_mod.load(contract_file) # BAD_CONFIG/PROTOCOL_MISMATCH via main
findings.extend(contract_mod.run(sch, doc))
waiver_detail: list[dict] = []
findings, waived, demoted = _report.apply_waivers(
findings, cfg.waivers, detail_out=waiver_detail)
meta = _schematic_meta(sch)
# Always surface waiver activity so a run cleaned only by waivers is never
# mistaken for an intrinsically clean board.
meta["config_waived"] = f"{waived} ({demoted} demoted)"
if waiver_detail:
# "config-waived: 1" alone was misread as a clean BOM on a real
# board — name every silenced finding and the waiver's reason.
meta["config_waived_detail"] = "; ".join(
f"{d['code']}[{','.join(d['refs']) or '*'}] {d['action']}"
+ (f" ({d['reason']})" if d["reason"] else "")
for d in waiver_detail)
for d in waiver_detail:
sys.stderr.write(
f"waived: {d['code']} [{','.join(d['refs']) or '*'}] "
f"{d['action']}"
+ (f" — {d['reason']}" if d["reason"] else "") + "\n")
fmt = getattr(args, "format", None) or ("json" if args.json else "text")
_emit(_report.render(findings, fmt, meta, source=str(path)))
# one exit policy for every findings-emitting command (see _shared)
return _findings_exit(findings, args)
def _cmd_diff(args: argparse.Namespace) -> int:
a = _load_schematic(_require_path(args.path, "first schematic"))
b = _load_schematic(_require_path(args.other, "second schematic"))
from ..checks import diff as diffmod
rep = diffmod.run(a, b)
findings = rep.findings()
bom_changes = None
if getattr(args, "bom", False):
from ..parts import bom_jlc # lazy; offline (no lookups)
bom_changes = bom_jlc.bom_diff(a, b)
if args.json:
doc = rep.export()
if bom_changes is not None:
doc["bom_changes"] = bom_changes
_emit(_dumps(doc))
else:
_emit(_report.render(findings, "text", {}))
if bom_changes is not None:
_emit(f"BOM changes ({len(bom_changes)}):")
if not bom_changes:
_emit(" (none)")
for d in bom_changes:
_emit(f" {d['kind']}: {d['refs']} — {d['detail']}")
_emit(" (cost delta: --lock each revision and diff the "
"lockfiles with --against-lock)")
return _findings_exit(findings, args)
def _cmd_verify_schpcb(args: argparse.Namespace, sch_path, pcb_path) -> int:
"""`verify <sch> <board.kicad_pcb>` — schematic <-> PCB equivalence."""
from ..checks import schpcb
from ..readers import kicad as kreader
sch = _load_schematic(sch_path)
pcb = kreader.read_pcb(str(pcb_path))
findings = schpcb.run(sch, pcb)
if not getattr(args, "strict", False):
findings = [f for f in findings if f.code != "SCHPCB_VALUE_MISMATCH"]
errors = [f for f in findings
if f.severity in (_report.Severity.ERROR, _report.Severity.CRITICAL)]
equivalent = not errors
if args.json:
_emit(_dumps(_stamp({
"equivalent": equivalent,
"mode": "sch-pcb",
"schematic": str(sch_path),
"board": str(pcb_path),
"components": {"sch": len(sch.components), "pcb": len(pcb.footprints)},
"pads": len(pcb.pads),
"findings": [
{"code": f.code, "severity": f.severity.value,
"message": f.message, "anchors": f.anchors}
for f in findings
],
})))
else:
lines = [f"SCH-PCB EQUIVALENCE: {'PASS' if equivalent else 'FAIL'}",
f" components: {len(sch.components)} (sch) vs "
f"{len(pcb.footprints)} (pcb) pads: {len(pcb.pads)}"]
for f in findings:
lines.append(f" {f.severity.value.upper():<8} {f.code}: {f.message}")
if not findings:
lines.append(" refdes, footprints and net partition all match")
_emit("\n".join(lines))
if getattr(args, "exit_zero", False):
return EXIT["OK"]
return EXIT["OK"] if equivalent else EXIT["FINDINGS"]
def _cmd_verify(args: argparse.Namespace) -> int:
"""`verify <a> <b>` — net-equivalence proof between two schematics,
or schematic <-> PCB equivalence when ``<b>`` is a ``.kicad_pcb``.
Schematic mode PASS means: same component set, and every net's pin
membership is identical (net *names* may differ — conversions rename
unnamed nets). With --strict, changed component values/footprints also
fail. PCB mode compares refdes/footprint assignment and the pad-level net
PARTITION (net names untrusted; --strict adds value mismatches).
"""
a_path = _require_path(args.path, "first schematic")
b_path = _require_path(args.other, "second file")
if str(b_path).lower().endswith(".kicad_pcb"):
return _cmd_verify_schpcb(args, a_path, b_path)
a = _load_schematic(a_path)
b = _load_schematic(b_path)
from ..checks import diff as diffmod
rep = diffmod.run(a, b)
comp_ok = not rep.added_components and not rep.removed_components
nets_ok = (not rep.added_nets and not rep.removed_nets
and not rep.member_changed_nets)
strict_ok = not (getattr(args, "strict", False) and rep.changed_components)
equivalent = comp_ok and nets_ok and strict_ok
if args.json:
_emit(_dumps(_stamp({
"equivalent": equivalent,
"strict": bool(getattr(args, "strict", False)),
"components": {"a": len(a.components), "b": len(b.components)},
"nets": {"a": len(a.nets), "b": len(b.nets)},
"renamed_nets": [[n.name_a, n.name_b] for n in rep.renamed_nets],
"summary": rep.summary(),
"detail": rep.export(),
})))
else:
lines = [f"CONVERSION PROOF: {'PASS' if equivalent else 'FAIL'}",
f" components: {len(a.components)} vs {len(b.components)}"
f" (+{len(rep.added_components)} −{len(rep.removed_components)}"
f" ~{len(rep.changed_components)})",
f" nets: {len(a.nets)} vs {len(b.nets)}"
f" (+{len(rep.added_nets)} −{len(rep.removed_nets)}"
f" membership-changed {len(rep.member_changed_nets)})"]
if rep.renamed_nets:
names = ", ".join(f"{n.name_a}→{n.name_b}"
for n in rep.renamed_nets[:8])
lines.append(f" renamed (connectivity identical): "
f"{len(rep.renamed_nets)} [{names}]")
if not equivalent:
for c in rep.added_components[:10]:
lines.append(f" + component only in B: {c.designator_b}")
for c in rep.removed_components[:10]:
lines.append(f" − component only in A: {c.designator_a}")
for n in rep.added_nets[:10]:
lines.append(f" + net only in B: {n.name_b}")
for n in rep.removed_nets[:10]:
lines.append(f" − net only in A: {n.name_a}")
for n in rep.member_changed_nets[:10]:
lines.append(
f" ~ net {n.name_a or n.name_b}: "
f"+{[f'{d}.{p}' for d, p in n.added_members]} "
f"−{[f'{d}.{p}' for d, p in n.removed_members]}")
if rep.changed_components and not getattr(args, "strict", False):
lines.append(f" note: {len(rep.changed_components)} component(s) "
"differ in value/footprint (connectivity unaffected; "
"--strict makes this fail)")
if rep.low_confidence:
lines.append(" note: low-confidence net matching — inspect "
"`akcli diff` output")
_emit("\n".join(lines))
if equivalent or getattr(args, "exit_zero", False):
return EXIT["OK"]
return EXIT["FINDINGS"]
def _load_expected(path_str: str) -> dict:
"""Load an external pin->signal table from CSV or JSON."""
p = Path(path_str)
data = p.read_text(encoding="utf-8") # FileNotFound -> exit 4 via main
if p.suffix.lower() == ".json":
obj = json.loads(data)
if isinstance(obj, dict):
return {str(k): str(v) for k, v in obj.items()}
if isinstance(obj, list):
out: dict = {}
for row in obj:
if not isinstance(row, dict):
continue
key = row.get("pin") or row.get("Pin") or row.get("number")
val = row.get("signal") or row.get("net") or row.get("name")
if key is not None and val is not None:
out[str(key)] = str(val)
return out
raise _ExitWith(EXIT["USAGE"], "ERROR: expected JSON must be an object or array")
# CSV
import csv as _csv
rows = list(_csv.reader(data.splitlines()))
out = {}
start = 0
if rows and rows[0] and rows[0][0].strip().lower() in ("pin", "number", "#"):
start = 1
for r in rows[start:]:
if len(r) >= 2 and r[0].strip():
out[r[0].strip()] = r[1].strip()
return out
def _cmd_pinmap(args: argparse.Namespace) -> int:
path = _require_path(args.path)
sch = _load_schematic(path)
cfg = _load_cfg(args, path)
if getattr(args, "mcu", None):
cfg = _config.Config(
mcu_designator=args.mcu,
rails=cfg.rails,
paths=cfg.paths,
erc_waivers=cfg.erc_waivers,
waivers=cfg.waivers,
source_path=cfg.source_path,
grid_nm=cfg.grid_nm,
)
expected = _load_expected(args.expected) if getattr(args, "expected", None) else None
from ..checks import pinmap
findings = pinmap.run(sch, cfg, expected)
fmt = "json" if args.json else "text"
_emit(_report.render(findings, fmt, _schematic_meta(sch)))
return _findings_exit(findings, args)
def _cmd_expected(args: argparse.Namespace) -> int:
"""Extract an expected pin->signal table from a .dts/.overlay or pinout .md.
Bridges the adapters into the ``pinmap --expected`` pipeline: the emitted
JSON object ({pin: signal}) is exactly what ``--expected`` consumes. The
schematic stays authoritative; this table is advisory input.
"""
src = getattr(args, "input", None)
if not src:
raise _ExitWith(EXIT["USAGE"], "ERROR: missing input file (.dts/.overlay or .md)")
path = Path(src)
if not path.is_file():
raise _ExitWith(EXIT["NOT_FOUND"], f"ERROR: file not found: {src}")
suffix = path.suffix.lower()
if suffix in (".dts", ".dtsi", ".overlay"):
from ..adapters import dts as dts_adapter # lazy
table = dts_adapter.to_expected_table(dts_adapter.parse_dts(path))
elif suffix in (".md", ".markdown"):
from ..adapters import pinout_md # lazy
table = pinout_md.parse_pinout_md(
path,
key_header=getattr(args, "key_header", None),
value_header=getattr(args, "value_header", None),
)
else:
raise _ExitWith(
EXIT["USAGE"],
f"ERROR: unsupported input {suffix!r} (want .dts/.dtsi/.overlay or .md)",
)
payload = _dumps({k: table[k] for k in sorted(table)})
out = getattr(args, "output", None)
if out:
Path(out).write_text(payload + "\n", encoding="utf-8", newline="\n")
sys.stderr.write(f"wrote {len(table)} pin assignment(s) to {out}\n")
else:
_emit(payload)
if not table:
# An empty table would make `pinmap --expected` vacuously pass — treat
# "nothing extracted" as a finding, not a success.
sys.stderr.write(f"WARNING: no pin assignments found in {src}\n")
return EXIT["FINDINGS"]
return EXIT["OK"]
def _cmd_relink(args: argparse.Namespace) -> int:
"""`relink-symbols <sch>` — re-embed stale lib_symbols cache entries.
Dry-run by default; ``--apply`` splices the fresh blocks in behind the
net-membership equivalence gate (``VERIFY_FAILED`` -> exit 6, file
untouched) and leaves ``<name>.bak``. ``missing-lib`` entries exit 6 like
a failed op — scope with ``--only`` to silence intentionally-unavailable
nicknames.
"""
target = _require_path(args.target, "target .kicad_sch")
if not str(target).lower().endswith(".kicad_sch"):
raise _ExitWith(EXIT["USAGE"], "ERROR: relink-symbols works on a .kicad_sch")
from .. import relink
actions = relink.plan(target, lib_dirs=getattr(args, "libs", None),
only=getattr(args, "only", None))
do_apply = bool(getattr(args, "apply", False))
res = None
if do_apply:
# VERIFY_FAILED (gate refusal) / SYMBOL_NOT_FOUND -> exit 6 via main
res = relink.apply(str(target), actions)
replaces = [a for a in actions if a["status"] == "replace"]
missing = [a for a in actions if a["status"] == "missing-lib"]
if res is not None and res.get("written"):
from .. import journal as _journal
_journal.record(target, "relink-symbols", "applied",
op_count=len(res.get("replaced", [])),
backup=(Path(res["backup"]).name
if res.get("backup") else None))
if args.json:
# new_sexpr is a full symbol block; strip it for readability
slim = [{k: v for k, v in a.items() if k != "new_sexpr"} for a in actions]
_emit(_dumps(_stamp({
"actions": slim,
"applied": bool(res and res["written"]),
"replaced": (res or {}).get("replaced", []),
"backup": (res or {}).get("backup"),
})))
else:
lines = []
for a in actions:
detail = f" — {a['detail']}" if a.get("detail") else ""
lines.append(f" {a['status']:<11} {a['lib_id']} "
f"[{a['source'] or 'no source'}]{detail}")
if not lines:
lines.append(" (no embedded lib_symbols entries matched)")
if res is not None and res["written"]:
bak = Path(res["backup"]).name if res.get("backup") else None
lines.append(f"status: APPLIED — re-embedded {len(res['replaced'])} "
f"symbol(s) into {target.name}"
+ (f" (backup {bak}; `akcli undo` reverts)" if bak else ""))
elif do_apply:
lines.append("status: nothing to replace — file untouched")
elif replaces:
lines.append(f"status: dry-run — {len(replaces)} replacement(s) "
"pending; re-run with --apply")
else:
lines.append("status: dry-run — nothing to replace")
_emit("\n".join(lines))
return EXIT["OPLIST"] if missing else EXIT["OK"]
def register(sub, common) -> None:
p = sub.add_parser("check", parents=[common], help="run ERC/power/BOM/layout checks")
p.add_argument("path", nargs="?", help="input schematic")
p.add_argument("--erc", action="store_true", help="run ERC checks")
p.add_argument("--power", action="store_true", help="run power-rail checks")
p.add_argument("--bom", action="store_true", help="run BOM-hygiene checks")
p.add_argument("--layout", action="store_true",
help="run geometric-overlap lint (.kicad_sch only)")
p.add_argument("--nets", action="store_true",
help="run connectivity-hygiene checks (single-pin nets, "
"off-grid pins, wire/pin/label attachment near-misses)")
p.add_argument("--integrity", action="store_true",
help="run the write gate's document checks read-only "
"(dangling endpoints, duplicate uuids/designators, "
"unresolved lib_ids, instance paths, no-connect "
"conflicts; .kicad_sch only)")
p.add_argument("--pairs", action="store_true",
help="run differential-pair / bus-continuity checks "
"(_P/_N, D+/D-, CAN_H/_L partners; D0..D7 index gaps)")
p.add_argument("--intent", metavar="FILE", nargs="?", const="@config",
help="assert a JSON design-intent file (see `akcli nets "
"--intent-snapshot`) against the built netlist; bare "
"--intent uses [paths] intent from akcli.toml")
p.add_argument("--contract", metavar="FILE",
help="assert a TOML design-contract file (require/forbid "
"pin-net, pin-pair topology, values, NC, approved "
"exceptions with owner/expiry)")
p.add_argument("--libsync", action="store_true",
help="check embedded lib_symbols freshness (pin-signature "
"drift vs --symbols sources; old-format heuristic "
"without them)")
p.add_argument("--symbols", metavar="PATH", action="append",
help="symbol source dir or .kicad_sym for --libsync "
"(repeatable)")
_add_exit_policy_flags(p)
p.add_argument("--format", choices=["text", "json", "sarif", "junit"],
help="output format (sarif: GitHub code scanning; junit: CI test reporters)")
p.set_defaults(handler=_cmd_check)
p = sub.add_parser("diff", parents=[common], help="net-level diff of two schematics")
p.add_argument("--bom", action="store_true",
help="also report the BOM line-level delta (added/removed "
"lines, value/id edits, fitted<->dnp class flips; "
"offline)")
p.add_argument("path", nargs="?", help="schematic A")
p.add_argument("other", nargs="?", help="schematic B")
_add_exit_policy_flags(p)
p.set_defaults(handler=_cmd_diff)
p = sub.add_parser("verify", parents=[common],
help="net-equivalence proof: two schematics, or "
"schematic vs .kicad_pcb (pad-net partition)")
p.add_argument("path", nargs="?", help="schematic A (the reference)")
p.add_argument("other", nargs="?",
help="schematic B (the candidate) or a .kicad_pcb board")
p.add_argument("--strict", action="store_true",
help="also fail on component value/footprint differences "
"(sch-pcb mode: include value mismatches)")
p.add_argument("--exit-zero", action="store_true", help="always exit 0")
p.set_defaults(handler=_cmd_verify)
p = sub.add_parser("pinmap", parents=[common], help="MCU pin->net map + cross-check")
p.add_argument("path", nargs="?", help="input schematic")
p.add_argument("--mcu", metavar="REF", help="MCU designator (overrides config)")
p.add_argument("--expected", metavar="FILE",
help="expected pin->signal table (.csv or .json)")
_add_exit_policy_flags(p)
p.set_defaults(handler=_cmd_pinmap)
p = sub.add_parser("expected", parents=[common],
help="extract an expected pin->signal table from "
".dts/.overlay or pinout .md (output is ALWAYS "
"JSON — it feeds `pinmap --expected`; the global "
"--json flag is a no-op here)")
p.add_argument("input", nargs="?", help="input file (.dts, .dtsi, .overlay, .md)")
p.add_argument("-o", "--output", metavar="FILE",
help="write JSON here instead of stdout (feed to pinmap --expected)")
p.add_argument("--key-header", metavar="NAME",
help="markdown: explicit pin/GPIO column header")
p.add_argument("--value-header", metavar="NAME",
help="markdown: explicit signal/net column header")
p.set_defaults(handler=_cmd_expected)
p = sub.add_parser("relink-symbols", parents=[common],
help="re-embed stale lib_symbols cache entries from "
"fresh .kicad_sym libraries (dry-run unless --apply)")
p.add_argument("target", nargs="?", help="the .kicad_sch to relink")
p.add_argument("--libs", metavar="DIR", action="append",
help="symbol library dir or .kicad_sym file (repeatable); "
"default: the KiCad.app SharedSupport symbols dir "
"if it exists")
p.add_argument("--only", metavar="NICKS",
help="comma-separated library nicknames (or full lib_ids) "
"to consider")
p.add_argument("--apply", action="store_true",
help="write the replacements (default: preview only; "
"net-membership equivalence gated, leaves <name>.bak)")
p.set_defaults(handler=_cmd_relink)