Ship Sprint 0 CAD foundations so findings, KiCad hierarchy, BOM match, eval, and optional PCB ingest have a stable contract.

Extend Finding with rule_id/net/pins, flatten multi-sheet .kicad_sch, flag MPN mismatches, score simple_project, and parse .kicad_pcb without running layout DRC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 21:54:37 +02:00
co-authored by Cursor
parent d31c04ba86
commit e4fd6c3849
29 changed files with 1995 additions and 46 deletions
+222
View File
@@ -114,3 +114,225 @@ def test_kicad_mpn_fills_empty_bom(tmp_path: Path):
)
assert g.components["U1"].mpn == "MSPM0G3507SPTR"
assert "GND" in g.nets
# ---------------------------------------------------------------------------
# Hierarchical .kicad_sch (root + child sheets)
# ---------------------------------------------------------------------------
_LIB_R = """
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
"""
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
return f"""
(symbol
(lib_id "Device:R")
(at {x} {y} 0)
(unit 1)
(uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1"))
(pin "2" (uuid "p2"))
)
"""
def _sch(*body: str) -> str:
return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n"
def test_parse_single_sheet_kicad_sch(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
p = tmp_path / "one.kicad_sch"
p.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
""",
))
parts, nets, fmt = parse_netlist_any(p)
assert fmt == "kicad_sch"
assert "R1" in parts
assert ("R1", "1") in nets["GND"]
def test_hierarchical_global_gnd_merges_across_sheets(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
_parts, nets, fmt = parse_netlist_any(root)
assert fmt == "kicad_sch"
gnd = nets["GND"]
assert ("R1", "1") in gnd
assert ("C1", "1") in gnd
def test_hierarchical_label_connects_through_sheet_pin(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "analog.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(hierarchical_label "VIN" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(wire (pts (xy 0 3.81) (xy 50 3.81)))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
(pin "VIN" unspecified (at 50 3.81 180) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"))
)
""",
))
_parts, nets, _fmt = parse_netlist_any(root)
vin = nets["VIN"]
assert ("R1", "1") in vin
assert ("C1", "1") in vin
def test_local_labels_same_name_do_not_merge_across_sheets(tmp_path: Path):
from backend.pinscopex.parsers import parse_netlist_any
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("R2", "1k"),
"""
(label "FOO" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(label "FOO" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
_parts, nets, _fmt = parse_netlist_any(root)
r1_nets = [n for n, pins in nets.items() if ("R1", "1") in pins]
r2_nets = [n for n, pins in nets.items() if ("R2", "1") in pins]
assert len(r1_nets) == 1 and len(r2_nets) == 1
assert r1_nets[0] != r2_nets[0]
assert "FOO" not in nets or len(nets.get("FOO", [])) <= 1
def test_sheetfile_parent_traversal_is_rejected(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Escape" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "../outside.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="rejected"):
parse_kicad(root)
def test_missing_child_sheet_raises(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Missing" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "nope.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="Missing"):
parse_kicad(root)
def test_cyclic_sheet_include_is_rejected(tmp_path: Path):
import pytest
from backend.pinscopex.parsers_kicad import parse_kicad
child = tmp_path / "child.kicad_sch"
child.write_text(_sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Root" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "root.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
with pytest.raises(ValueError, match="[Cc]yclic"):
parse_kicad(root)