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.
"""Design contracts: topology/semantic rules ERC cannot express (SPEC: Phase 3).
A contract file is TOML (stdlib ``tomllib``; zero dependencies) holding
``[[contract]]`` rules with datasheet-backed semantics:
.. code-block:: toml
protocol_version = 1
[[contract]]
id = "reg-feedback-node"
evidence = ["ACME-REG datasheet, Table 7"]
require = [ { pin = "U1.FB2", net = "VFB2" } ]
forbid = [ { pin = "U1.FB1", net = "RAIL_A" } ]
[[contract]]
id = "usb-c-flip-pair"
require_same_net = [ ["J1.A6", "J1.B6"], ["J1.A7", "J1.B7"] ]
forbid_same_net = [ ["J1.A2", "J1.B11"] ]
[[contract]]
id = "shunt-value"
component = "R7"
value = "150k"
[[contract]]
id = "gpio3-unused"
nc = ["U1.GPIO3"]
[[contract]]
id = "thermal-via-exception"
waived = true
reason = "exposed-pad thermal vias approved"
owner = "hw-lead"
expires = "2027-01-01"
Rule vocabulary per contract: ``require`` / ``forbid`` (pin-on-net),
``require_same_net`` / ``forbid_same_net`` (pin-pair topology), ``component`` +
``value`` (exact, case/space-insensitive; or a list of accepted values), and
``nc`` (pin must not join a multi-pin net). ``severity`` defaults to error.
The report distinguishes three outcomes, never conflating them: PASS
(``CONTRACT_PASS``, info), FAIL (``CONTRACT_*`` at the rule's severity) and
SKIPPED-BY-EXCEPTION (``CONTRACT_WAIVED``, note; an expired exception raises
``CONTRACT_EXCEPTION_EXPIRED`` instead of silently passing).
Pin names: ``REF.PIN`` matches the pin *number* first, then the pin *name*
(e.g. ``U1.FB2``), so datasheet-named rules read naturally.
"""
from __future__ import annotations
from .. import contract as _contract
import datetime as _dt
import os
from ..errors import fail
from ..model import Schematic
from ..report import Finding, Severity, anchor
__all__ = ["load", "run"]
PROTOCOL_VERSION = _contract.PROTOCOL_VERSIONS["contract-check"]
_SEVERITIES = {
"info": Severity.INFO, "note": Severity.NOTE,
"warning": Severity.WARNING, "error": Severity.ERROR,
"critical": Severity.CRITICAL,
}
def load(path: os.PathLike | str) -> dict:
"""Load + structurally validate a contract TOML file."""
import tomllib
from pathlib import Path
try:
doc = tomllib.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as exc:
fail("BAD_CONFIG", f"contract file {path}: {exc}")
pv = doc.get("protocol_version", PROTOCOL_VERSION)
if isinstance(pv, int) and pv > PROTOCOL_VERSION:
fail("PROTOCOL_MISMATCH",
f"contract protocol_version {pv} > supported {PROTOCOL_VERSION}")
rules = doc.get("contract")
if not isinstance(rules, list) or not rules:
fail("BAD_CONFIG", f"contract file {path}: no [[contract]] rules")
seen: set[str] = set()
for i, rule in enumerate(rules):
if not isinstance(rule, dict):
fail("BAD_CONFIG", f"contract file {path}: rule #{i} is not a table")
rid = rule.get("id")
if not rid or not isinstance(rid, str):
fail("BAD_CONFIG", f"contract file {path}: rule #{i} has no id")
if rid in seen:
fail("BAD_CONFIG", f"contract file {path}: duplicate id {rid!r}")
seen.add(rid)
sev = rule.get("severity", "error")
if sev not in _SEVERITIES:
fail("BAD_CONFIG",
f"contract {rid!r}: unknown severity {sev!r} "
f"(expected {'/'.join(_SEVERITIES)})")
return doc
class _Netview:
"""Pin/net lookups over a schematic, with pin-NAME fallback."""
def __init__(self, sch: Schematic) -> None:
self.sch = sch
self.by_ref = {c.designator: c for c in sch.components}
# (ref, pin_number) -> net index
self.net_of: dict[tuple[str, str], int] = {}
for i, net in enumerate(sch.nets):
for m in net.members:
self.net_of[m] = i
# net name/alias -> index
self.net_by_name: dict[str, int] = {}
for i, net in enumerate(sch.nets):
if net.name:
self.net_by_name.setdefault(net.name, i)
for a in net.aliases:
self.net_by_name.setdefault(a, i)
def pin_key(self, spec: str) -> tuple[str, str] | None:
"""Resolve ``REF.PIN`` (number first, then name) to a (ref, number) key."""
if "." not in spec:
return None
ref, pin = spec.split(".", 1)
comp = self.by_ref.get(ref)
if comp is None:
return None
for p in comp.pins:
if p.number == pin:
return (ref, p.number)
matches = [p for p in comp.pins if (p.name or "") == pin]
if len(matches) == 1:
return (ref, matches[0].number)
return None
def net_index(self, key: tuple[str, str]) -> int | None:
return self.net_of.get(key)
def _norm_value(v: str) -> str:
return "".join(str(v).split()).lower()
def _check_rule(rule: dict, view: _Netview) -> list[str]:
"""Evaluate one contract rule; returns failure messages (empty = pass)."""
fails: list[str] = []
sch = view.sch
def _resolve(spec: str) -> tuple[str, str] | None:
key = view.pin_key(spec)
if key is None:
fails.append(f"pin {spec!r} not found on the schematic")
return key
for item in rule.get("require", []):
key = _resolve(str(item.get("pin", "")))
if key is None:
continue
want = str(item.get("net", ""))
idx = view.net_index(key)
actual = (sch.nets[idx].name if idx is not None else None)
want_idx = view.net_by_name.get(want)
if want_idx is None:
fails.append(f"required net {want!r} does not exist")
elif idx != want_idx:
fails.append(f"{item.get('pin')}: on {actual or '(no net)'}, "
f"required {want!r}")
for item in rule.get("forbid", []):
key = _resolve(str(item.get("pin", "")))
if key is None:
continue
banned = str(item.get("net", ""))
idx = view.net_index(key)
if idx is not None and view.net_by_name.get(banned) == idx:
fails.append(f"{item.get('pin')}: is on forbidden net {banned!r}")
for pair in rule.get("require_same_net", []):
keys = [_resolve(str(p)) for p in pair[:2]]
if None in keys or len(pair) < 2:
continue
ia, ib = view.net_index(keys[0]), view.net_index(keys[1])
if ia is None or ib is None or ia != ib:
na = sch.nets[ia].name if ia is not None else "(no net)"
nb = sch.nets[ib].name if ib is not None else "(no net)"
fails.append(f"{pair[0]} and {pair[1]} must share a net "
f"(got {na!r} vs {nb!r})")
elif len(pair) >= 3 and sch.nets[ia].name != pair[2]:
fails.append(f"{pair[0]}/{pair[1]}: net is "
f"{sch.nets[ia].name!r}, expected {pair[2]!r}")
for pair in rule.get("forbid_same_net", []):
keys = [_resolve(str(p)) for p in pair[:2]]
if None in keys or len(pair) < 2:
continue
ia, ib = view.net_index(keys[0]), view.net_index(keys[1])
if ia is not None and ia == ib:
fails.append(f"{pair[0]} and {pair[1]} must NOT share a net "
f"(both on {sch.nets[ia].name or 'an unnamed net'!r})")
if "component" in rule and "value" in rule:
ref = str(rule["component"])
comp = view.by_ref.get(ref)
if comp is None:
fails.append(f"component {ref!r} not found")
else:
accepted = rule["value"]
if not isinstance(accepted, list):
accepted = [accepted]
got = _norm_value(comp.value or "")
if got not in {_norm_value(v) for v in accepted}:
fails.append(f"{ref}: value {comp.value!r}, expected "
+ " or ".join(repr(str(v)) for v in accepted))
for spec in rule.get("nc", []):
key = _resolve(str(spec))
if key is None:
continue
idx = view.net_index(key)
if idx is not None and len(sch.nets[idx].members) > 1:
fails.append(f"{spec}: must be unconnected but joins "
f"{sch.nets[idx].name or 'an unnamed net'} "
f"({len(sch.nets[idx].members)} pins)")
return fails
def run(sch: Schematic, doc: dict, *, today: _dt.date | None = None) -> list[Finding]:
"""Evaluate every contract; PASS/FAIL/WAIVED are all explicit findings."""
view = _Netview(sch)
findings: list[Finding] = []
today = today or _dt.date.today()
for rule in doc.get("contract", []):
rid = str(rule.get("id"))
evidence = rule.get("evidence") or []
suffix = f" [evidence: {'; '.join(map(str, evidence))}]" if evidence else ""
if rule.get("waived"):
expires = rule.get("expires")
expired = False
if expires:
try:
expired = _dt.date.fromisoformat(str(expires)) < today
except ValueError:
expired = True
if expired:
findings.append(Finding(
"CONTRACT_EXCEPTION_EXPIRED", Severity.WARNING,
f"{rid}: exception expired ({expires}); owner "
f"{rule.get('owner', '?')} must re-review — "
f"{rule.get('reason', 'no reason recorded')}{suffix}"))
else:
findings.append(Finding(
"CONTRACT_WAIVED", Severity.NOTE,
f"{rid}: skipped by approved exception "
f"(owner {rule.get('owner', '?')}"
+ (f", expires {expires}" if expires else "")
+ f") — {rule.get('reason', 'no reason recorded')}{suffix}"))
continue
fails = _check_rule(rule, view)
if fails:
sev = _SEVERITIES[rule.get("severity", "error")]
for msg in fails:
findings.append(Finding(
"CONTRACT_FAIL", sev, f"{rid}: {msg}{suffix}",
anchors=[anchor("net", rid)]))
else:
findings.append(Finding(
"CONTRACT_PASS", Severity.INFO, f"{rid}: holds{suffix}"))
return findings