Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
7315b96d0This file is pinned to the selected release. Download the original to open it in your design tool.
"""Machine-computed phase gates — turn the 12 checkpoints from asserted verdicts into gates
that actually RUN the checks, and hard-block manufacturing export until they pass AND a human
has recorded approval.
A gate does not *store* a judgement; it *computes* one by running the phase's real checks
(ERC on the netlist, DFM/DRC on the board, spacing on the placement) and folding the harmonized
findings (pcbflow.findings) into a single GateOutcome. Outcomes compose with an explicit status
hierarchy so a project only PASSES when every gate passes.
Status hierarchy (which status wins when combining), most-dominant first:
BLOCKED — a tool/environment problem: the check could not run
EMPTY — the artifact to check is missing (nothing to judge)
FAIL — a real design violation (error-severity finding)
WARN — advisory only (warnings, no errors)
PASS — clean
Safety: export NEVER signs off a DRC and NEVER orders a board. It refuses unless (a) every gate
is PASS and (b) a human approval-evidence file exists — approval is *recorded*, not automated.
Pure Python 3 standard library.
"""
from dataclasses import dataclass, field
from pathlib import Path
from . import dfm, erc, findings
from .enet import Enet
# most-dominant first — the combined status is the first present in this order
_PRECEDENCE = ("BLOCKED", "EMPTY", "FAIL", "WARN", "PASS")
@dataclass
class GateOutcome:
name: str
status: str # one of _PRECEDENCE
summary: str
details: list = field(default_factory=list)
@property
def passed(self):
return self.status == "PASS"
def combine(outcomes):
"""Fold gate outcomes into one status (worst-dominant). No outcomes → EMPTY."""
present = {o.status for o in outcomes}
for s in _PRECEDENCE:
if s in present:
return s
return "EMPTY"
def _from_findings(name, fs):
"""error → FAIL, warning-only → WARN, clean → PASS. The gate judgement is *computed* here."""
rep = findings.report(fs)
status = "FAIL" if rep["errors"] else ("WARN" if rep["warnings"] else "PASS")
details = [f"[{f['severity']}] {f['rule_id']} {f['where']}: {f['summary']}"
for f in findings.sort_findings(fs)]
return GateOutcome(name, status, f"{rep['errors']} error(s), {rep['warnings']} warning(s)",
details)
# ── per-phase gates (each RUNS the check) ───────────────────────────
def gate_schematic(enet_path):
"""Phase 5: structural verify + ERC on the .enet netlist."""
net = Enet.load(enet_path)
struct = net.verify()
fs = erc.run_erc(net)
outcome = _from_findings("schematic", fs)
if struct: # structural problems are hard errors
outcome.status = "FAIL" if outcome.status != "BLOCKED" else outcome.status
outcome.details = [f"structure: {s}" for s in struct] + outcome.details
outcome.summary = f"{len(struct)} structure issue(s); " + outcome.summary
return outcome
def gate_placement(placement_json):
"""Phases 6/9: real-geometry pad-spacing audit."""
import json
doc = json.loads(Path(placement_json).read_text())
fs = findings.spacing_findings(doc.get("parts", []), min_gap=doc.get("min_gap", 0.5),
whitelist=doc.get("whitelist", []))
return _from_findings("placement", fs)
def gate_dfm(board_features_json):
"""Phase 11/12 (offline): DFM against the JLCPCB profile from a board-features JSON."""
import json
design = json.loads(Path(board_features_json).read_text())
b = design.get("board", {})
rs = dfm.RuleSet.jlcpcb(layers=b.get("layers", 2), copper_oz=b.get("copper_oz", "1oz"))
return _from_findings("dfm", dfm.run_dfm(design, rs))
def gate_drc(board_path, drc_runner):
"""Phase 12: DRC in KiCad (the ground truth). `drc_runner(board)->dict` is injected so this
stays testable and gates.py never shells out itself. Missing runner/tool → BLOCKED, not a
silent pass."""
if drc_runner is None:
return GateOutcome("drc", "BLOCKED", "no DRC runner available (kicad-cli)", [])
res = drc_runner(str(board_path))
if res.get("ok"):
return GateOutcome("drc", "PASS", f"DRC clean → {res.get('report','')}", [])
if res.get("violations"):
return GateOutcome("drc", "FAIL", f"DRC violations → {res.get('report','')}", [])
return GateOutcome("drc", "BLOCKED", res.get("reason", "DRC could not run"), [])
# ── project-level gate + the export hard-block ──────────────────────
def _find(project_dir, pattern):
hits = sorted(Path(project_dir).rglob(pattern))
return hits[0] if hits else None
def _routing_outcomes(project_dir, drc_runner=None):
"""The routing quality suite (G1): DRC + silkscreen-over-pad + rulebook (pour/width/length/
return-path). Returns [] when there is no board."""
board = _find(project_dir, "*.kicad_pcb")
if not board:
return []
from . import routing_check, routing_rules, silk_check
from .enet import Enet
outs = [gate_drc(board, drc_runner),
_from_findings("silk", silk_check.run(str(board)))]
enet = _find(project_dir, "*.enet")
if enet:
outs.append(_from_findings("routing",
routing_check.run(Enet.load(str(enet)), str(board),
routing_rules.RoutingRules.beside(str(enet)))))
return outs
def project_gate(project_dir, drc_runner=None):
"""Run every gate whose artifact is present in the project. Returns a list of GateOutcome.
A missing netlist is EMPTY (not silently skipped) — export must not clear an unverifiable board."""
outcomes = []
enet = _find(project_dir, "*.enet")
outcomes.append(gate_schematic(enet) if enet
else GateOutcome("schematic", "EMPTY", "no .enet netlist found", []))
place = _find(project_dir, "placement*.json")
if place:
outcomes.append(gate_placement(place))
bf = _find(project_dir, "board_features*.json")
if bf:
outcomes.append(gate_dfm(bf))
outcomes += _routing_outcomes(project_dir, drc_runner) # DRC + silk + routing rules (G1)
return outcomes
def compute_phase_gate(project_dir, phase, drc_runner=None):
"""Compute the gate for a single phase by running its check against the project's artifacts.
Phases with no automatable check (or a missing artifact) return EMPTY — the human records
those verdicts explicitly (never a silent auto-pass)."""
if phase == 5:
enet = _find(project_dir, "*.enet")
return gate_schematic(enet) if enet else \
GateOutcome("schematic", "EMPTY", "no .enet netlist found", [])
if phase in (6, 7, 8, 9):
place = _find(project_dir, "placement*.json")
return gate_placement(place) if place else \
GateOutcome("placement", "EMPTY", "no placement*.json found", [])
if phase in (11, 12):
outs = _routing_outcomes(project_dir, drc_runner)
bf = _find(project_dir, "board_features*.json")
if bf:
outs.append(gate_dfm(bf))
if outs:
return GateOutcome("routing", combine(outs),
"; ".join(f"{o.name}:{o.status}" for o in outs),
[d for o in outs for d in o.details])
return GateOutcome("manufacturability", "EMPTY", "no board (.kicad_pcb / board_features*.json)", [])
return GateOutcome(f"phase{phase}", "EMPTY", "no computed gate for this phase — record manually", [])
# gate status → the project verdict vocabulary (project.py: PASS/CONDITIONAL/FAIL/PENDING)
_VERDICT = {"PASS": "PASS", "WARN": "CONDITIONAL", "FAIL": "FAIL", "EMPTY": "PENDING",
"BLOCKED": "PENDING"}
def status_to_verdict(status):
return _VERDICT.get(status, "PENDING")
def approval_error(approval_path):
"""Validate the human approval-evidence file. Returns an error string, or None if valid.
Required fields: approved_by, approved_at_utc, scope — all non-empty."""
import json
if not approval_path:
return ("no approval evidence — manufacturing export is blocked until a human records "
"approval (a JSON file with approved_by, approved_at_utc, scope)")
p = Path(approval_path)
if not p.exists():
return f"approval file not found: {p}"
try:
doc = json.loads(p.read_text())
except ValueError as e:
return f"approval file is not valid JSON: {e}"
missing = [k for k in ("approved_by", "approved_at_utc", "scope") if not doc.get(k)]
return f"approval evidence missing fields: {', '.join(missing)}" if missing else None
def export_check(project_dir, approval_path, drc_runner=None):
"""The manufacturing-export gate. Returns (cleared: bool, outcomes, reasons).
HARD-BLOCKS unless (a) every project gate is PASS and (b) valid human approval exists. It
never signs a DRC and never orders a board — it only clears the human to produce the files.
"""
outcomes = project_gate(project_dir, drc_runner=drc_runner)
blocking = [o for o in outcomes if o.status != "PASS"]
reasons = [f"gate '{o.name}' is {o.status}: {o.summary}" for o in blocking]
ap_err = approval_error(approval_path)
if ap_err:
reasons.append(ap_err)
return (not reasons), outcomes, reasons