#!/usr/bin/env python3 """Validate extraction output against the pintable schema.""" import json import re import sys from pathlib import Path SCHEMA_PATH = Path(__file__).parent / "schema.json" def validate(data: dict) -> list[str]: """Return list of validation errors (empty = valid).""" errors = [] schema = json.loads(SCHEMA_PATH.read_text()) for field in schema.get("required", []): if field not in data: errors.append(f"Missing required field: {field}") if "component_subtype" in data: st = data["component_subtype"] if not isinstance(st, str) or "." not in st: errors.append(f"component_subtype must be dotted path, got: {st!r}") if "package_info" in data: pkg = data["package_info"] for f in ["base_family", "package", "pin_count"]: if f not in pkg: errors.append(f"package_info missing required field: {f}") if "pin_count" in pkg and not isinstance(pkg["pin_count"], int): errors.append(f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}") if "pintable" in data: pins = data["pintable"] if not isinstance(pins, list) or len(pins) == 0: errors.append("pintable must be a non-empty array") else: numbers = [] for i, pin in enumerate(pins): if "number" not in pin: errors.append(f"pintable[{i}] missing required field: number") if "name" not in pin: errors.append(f"pintable[{i}] missing required field: name") if "number" in pin: numbers.append(pin["number"]) dupes = [n for n in set(numbers) if numbers.count(n) > 1] if dupes: errors.append(f"Duplicate pin numbers: {dupes}") names = { str(p.get("number")): str(p.get("name") or "").upper() for p in pins if "number" in p } pin1 = names.get("1", "") looks_like_rf_die = bool( re.search(r"\bANT\b|^CHIP_PU$|^XTAL", pin1) and any("XTAL" in n for n in names.values()) ) mpn = str(data.get("mpn") or "") is_module_mpn = bool(re.search(r"WROOM|WROVER|\bMODULE\b|\bSIP\b", mpn, re.I)) if looks_like_rf_die and is_module_mpn: errors.append( "Pin 1 looks like a bare RF SoC ball (ANT/CHIP_PU) with XTAL " "pins in the table. Module footprints (WROOM) use pad 1 = GND; " "extract the module landing-pad table, not the die map." ) if "absolute_maximum_ratings" in data: ratings = data["absolute_maximum_ratings"] if ratings is not None and not isinstance(ratings, list): errors.append("absolute_maximum_ratings must be an array") elif isinstance(ratings, list): for i, row in enumerate(ratings): if not isinstance(row, dict): errors.append(f"absolute_maximum_ratings[{i}] must be an object") continue for f in ("parameter", "unit", "source_page"): if f not in row: errors.append( f"absolute_maximum_ratings[{i}] missing required field: {f}" ) if "layout_rules" in data and data["layout_rules"] is not None: if not isinstance(data["layout_rules"], list): errors.append("layout_rules must be an array") else: kinds = { "decoupling_proximity", "thermal_via", "keepout", "length_match", "impedance", "max_length", "spacing", "ref_plane", "si_via", "layer", "series_resistor", "return_path", "si", "emi", "common_mode", "shield", } for i, row in enumerate(data["layout_rules"]): if not isinstance(row, dict): errors.append(f"layout_rules[{i}] must be an object") continue kind = row.get("kind") if kind not in kinds: errors.append(f"layout_rules[{i}] unknown kind: {kind!r}") continue dist = row.get("max_distance_mm") if dist is not None and dist is not False: if isinstance(dist, bool): errors.append( f"layout_rules[{i}].max_distance_mm must be a number or null" ) elif isinstance(dist, (int, float)): if float(dist) <= 0: errors.append( f"layout_rules[{i}].max_distance_mm must be > 0" ) else: try: v = float(str(dist).strip()) except (TypeError, ValueError): errors.append( f"layout_rules[{i}].max_distance_mm must be numeric " f"or null (got {dist!r}) — do not invent distances; " f"use null when the PDF only says 'close'" ) else: if v <= 0: errors.append( f"layout_rules[{i}].max_distance_mm must be > 0" ) via = row.get("min_via_count") if via is not None and via is not False and not isinstance(via, bool): if isinstance(via, int): if via <= 0: errors.append( f"layout_rules[{i}].min_via_count must be > 0" ) else: try: iv = int(float(str(via).strip())) except (TypeError, ValueError): errors.append( f"layout_rules[{i}].min_via_count must be an integer " f"or null (got {via!r})" ) else: if iv <= 0: errors.append( f"layout_rules[{i}].min_via_count must be > 0" ) page = row.get("source_page") if page is not None and not isinstance(page, int): errors.append( f"layout_rules[{i}].source_page must be an integer or null" ) same = row.get("same_layer") if same is not None and not isinstance(same, bool): errors.append( f"layout_rules[{i}].same_layer must be a boolean or null" ) si_kinds = { "length_match", "impedance", "max_length", "spacing", "ref_plane", "si_via", "layer", "series_resistor", "return_path", "si", } nc = row.get("net_class") if kind in si_kinds and not (isinstance(nc, str) and nc.strip()): errors.append( f"layout_rules[{i}] SI kind {kind!r} requires net_class " f"(usb2|usb3|eth_mdi|rgmii|sgmii|ddr3|hdmi|pcie|lvds)" ) note = str(row.get("note") or "") pin = str(row.get("pin") or "") if kind == "series_resistor" and ( re.search(r"[µu]F", note, re.I) or re.search(r"\b(EN|CHIP_PU|CHIP_EN|STRAP|ILIM)\b", f"{note} {pin}", re.I) ): errors.append( f"layout_rules[{i}] series_resistor is HS termination, " f"not EN/CHIP_PU RC or strap" ) return errors if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python3 validate.py ''") sys.exit(1) try: data = json.loads(sys.argv[1]) except json.JSONDecodeError as e: print(f"INVALID JSON: {e}") sys.exit(1) errors = validate(data) if errors: print("VALIDATION FAILED:") for err in errors: print(f" - {err}") sys.exit(1) else: print("VALIDATION PASSED")