@tachikomagundam/abathur 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/readjudicate.js +28 -0
  4. package/dist/commands/retract.js +53 -0
  5. package/dist/commands/tombstone.js +28 -15
  6. package/dist/core/evolve/run-bench.js +7 -1
  7. package/dist/core/evolve/run-rows.js +14 -0
  8. package/dist/core/ledger.js +2 -0
  9. package/dist/core/promote.js +61 -46
  10. package/dist/core/readjudicate.js +247 -0
  11. package/dist/core/retract.js +166 -0
  12. package/dist/core/stats.js +7 -0
  13. package/dist/core/worktree.js +1 -1
  14. package/dist/genomes/toy-smoke/init.mjs +1 -1
  15. package/dist/test/bundle.test.js +1 -1
  16. package/dist/test/fixture-loop.test.js +1 -1
  17. package/dist/test/fixtures-self.js +1 -1
  18. package/dist/test/fixtures-wt.js +1 -1
  19. package/dist/test/friction.test.js +3 -3
  20. package/dist/test/graft.test.js +1 -1
  21. package/dist/test/historian-run-scenario.test.js +2 -2
  22. package/dist/test/promote.test.js +222 -5
  23. package/dist/test/readjudicate.test.js +212 -0
  24. package/dist/test/reflect.test.js +1 -1
  25. package/dist/test/repopath-seams.test.js +1 -0
  26. package/dist/test/seam-gates.test.js +6 -0
  27. package/dist/test/self-snapshot.test.js +2 -2
  28. package/dist/test/stats.test.js +17 -0
  29. package/docs/AGENTISM.md +58 -0
  30. package/docs/release/README.md +9 -0
  31. package/docs/release/TEMPLATE.md +10 -0
  32. package/docs/release/v0.2.7.md +7 -0
  33. package/docs/release/v0.2.8.md +8 -0
  34. package/graders/historian/judge-poststage.mjs +8 -3
  35. package/graders/pcb-agent/bench-approver.py +77 -0
  36. package/graders/pcb-agent/grader.mjs +130 -0
  37. package/graders/pcb-agent/mutate.sh +165 -0
  38. package/graders/pcb-agent/r20-bench.sh +27 -0
  39. package/graders/pcb-agent/reset-sandbox.sh +7 -0
  40. package/graders/pcb-agent/run-scenario-r20.sh +77 -0
  41. package/graders/pcb-agent/run-scenario.sh +94 -0
  42. package/graders/pcb-agent/seed/esp32s3-r12-BRIEF.txt +51 -0
  43. package/graders/pcb-agent/seed/spec.json +129 -0
  44. package/graders/pcb-agent/seed/tools/board_author_pcb.py +237 -0
  45. package/graders/pcb-agent/seed/tools/board_author_sch.py +118 -0
  46. package/graders/pcb-agent/seed/tools/board_finalize.sh +23 -0
  47. package/graders/pcb-agent/seed/tools/board_gate.py +69 -0
  48. package/graders/pcb-agent/seed/tools/board_gate_pcb.py +23 -0
  49. package/graders/pcb-agent/seed/tools/board_route.py +324 -0
  50. package/graders/pcb-agent/seed/tools/board_zone_pour.py +312 -0
  51. package/graders/pcb-agent/seed/tools/claims_lint.py +417 -0
  52. package/graders/pcb-agent/seed-r20-arena.sh +54 -0
  53. package/graders/pcb-agent/seed-sandbox.sh +13 -0
  54. package/package.json +1 -1
  55. package/plugin/abathur-command.md +3 -3
  56. package/plugin/abathur.ts +6 -6
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """r17 deterministic PCB author: netlist.json + spec footprints -> placed .kicad_pcb.
3
+ RM-56 doctrine: no bytes through a model. Embeds real .kicad_mod libs from the
4
+ KiCad AppDir, injects (net N "NAME") per pad (name-match, else positional match
5
+ with WARN), shelf placement by footprint bbox + clearance, Edge.Cuts rect.
6
+ DRC gate (board_gate_pcb.py) evaluates placement-class errors; unconnected_items
7
+ are expected pre-routing and reported separately."""
8
+ import json, re, os, sys
9
+ from collections import defaultdict
10
+
11
+ ALIASES = {}
12
+ _ali = os.path.join(os.path.dirname(os.path.abspath(__file__)), "board_author_aliases.json")
13
+ if os.path.exists(_ali):
14
+ ALIASES = {tuple(k.split("|")): v for k, v in json.load(open(_ali)).items()}
15
+
16
+ FP_ROOT = "/home/lab/.local/AppDir/share/kicad/footprints"
17
+ PLACE_MARGIN = 3.0 # mm between footprint bboxes (courtyard-ish)
18
+ GRID = 0.1
19
+
20
+
21
+ def snap(v):
22
+ return round(v / GRID) * GRID
23
+
24
+
25
+ def forms(s):
26
+ """top-level s-expr blocks inside a ( ... ) wrapper string."""
27
+ out = []
28
+ i = 0
29
+ n = len(s)
30
+ while i < n:
31
+ if s[i] == "(":
32
+ depth = 0
33
+ j = i
34
+ instr = False
35
+ while j < n:
36
+ ch = s[j]
37
+ if ch == '"' and (j == 0 or s[j - 1] != "\\"):
38
+ instr = not instr
39
+ elif not instr:
40
+ if ch == "(":
41
+ depth += 1
42
+ elif ch == ")":
43
+ depth -= 1
44
+ if depth == 0:
45
+ break
46
+ j += 1
47
+ out.append(s[i:j + 1])
48
+ i = j + 1
49
+ else:
50
+ i += 1
51
+ return out
52
+
53
+
54
+ def sub_block(s, token):
55
+ """return (block, start, end) of first block starting with token, else None"""
56
+ for b in forms(s):
57
+ if b.startswith(f"({token}"):
58
+ return b
59
+ return None
60
+
61
+
62
+ def bbox_of(km):
63
+ """rough extent from pads + courtyard graphic lines/rects."""
64
+ xs, ys = [], []
65
+ for m in re.finditer(r'\(pad[^()]*?\(at ([-\d.]+)(?: ([-\d.]+))?\)(?:\s*\(size ([-\d.]+) ([-\d.]+))?', km):
66
+ x, y = float(m.group(1)), float(m.group(2) or 0)
67
+ w, h = (float(m.group(3)), float(m.group(4))) if m.group(3) else (1.0, 1.0)
68
+ xs += [x - w / 2, x + w / 2]
69
+ ys += [y - h / 2, y + h / 2]
70
+ for m in re.finditer(r'\((?:xy|start|end|at) ([-\d.]+) ([-\d.]+)', km):
71
+ xs.append(float(m.group(1)))
72
+ ys.append(float(m.group(2)))
73
+ if not xs:
74
+ return -1, -1, 2, 2
75
+ pad = 0.5
76
+ return min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad
77
+
78
+
79
+ def resolve_footprint(spec_fp):
80
+ lib, name = spec_fp.split(":", 1)
81
+ p = os.path.join(FP_ROOT, f"{lib}.pretty", f"{name}.kicad_mod")
82
+ return p if os.path.exists(p) else None
83
+
84
+
85
+ def assign_nets(netlist):
86
+ nets = sorted({n for c in netlist["components"] for n in c["pins"].values()})
87
+ code = {n: i + 1 for i, n in enumerate(nets)}
88
+ return nets, code
89
+
90
+
91
+ def author(netlist_path, out_path, rot_by_ref=None):
92
+ d = json.load(open(netlist_path))
93
+ comps = d["components"]
94
+ nets, ncode = assign_nets(d)
95
+ uuidn = [0]
96
+
97
+ def U():
98
+ uuidn[0] += 1
99
+ x = f"{uuidn[0]:032x}"
100
+ return f"{x[:8]}-{x[8:12]}-{x[12:16]}-{x[16:20]}-{x[20:32]}"
101
+
102
+ # ---- layout pass: shelf by bbox heights
103
+ items = []
104
+ warn = []
105
+ for c in sorted(comps, key=lambda c: (c["ref"][0], int(re.sub(r"\D", "", c["ref"]) or 0))):
106
+ fpp = resolve_footprint(c["footprint"])
107
+ if not fpp:
108
+ warn.append(f"NO-FOOTPRINT {c['ref']} {c['footprint']}")
109
+ continue
110
+ km = open(fpp).read()
111
+ x0, y0, x1, y1 = bbox_of(km)
112
+ items.append([c, km, snap(x1 - x0) + PLACE_MARGIN, snap(y1 - y0) + PLACE_MARGIN, (x0, y0)])
113
+ COL_W = 120.0
114
+ cx, cy, rowh = 10.0, 10.0, 0.0
115
+ placed = []
116
+ for it in items:
117
+ if cx + it[2] > COL_W:
118
+ cx = 10.0
119
+ cy += rowh
120
+ rowh = 0.0
121
+ placed.append((it, snap(cx), snap(cy)))
122
+ cx += it[2]
123
+ rowh = max(rowh, it[3])
124
+ board_w, board_h = snap(COL_W + 20), snap(cy + rowh + 20)
125
+
126
+ # ---- emit
127
+ L = ['(kicad_pcb (version 20260206) (generator "board_author_pcb") (generator_version "10.0")',
128
+ ' (general (thickness 1.6) (legacy_teardrops no))',
129
+ ' (paper "A3")',
130
+ ' (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (32 "B.Adhes" user "B.Adhesive") '
131
+ '(33 "F.Adhes" user "F.Adhesive") (34 "B.Paste" user) (35 "F.Paste" user) '
132
+ '(36 "B.SilkS" user "B.Silkscreen") (37 "F.SilkS" user "F.Silkscreen") '
133
+ '(38 "B.Mask" user) (39 "F.Mask" user) (40 "Dwgs.User" user "User.Drawings") '
134
+ '(44 "Edge.Cuts" user) (45 "Margin" user "BoardOutline") (46 "B.CrtYd" user "B.Courtyard") '
135
+ '(47 "F.CrtYd" user "F.Courtyard") (48 "B.Fab" user) (49 "F.Fab" user) (50 "User.1" user))',
136
+ ' (setup (pad_to_mask_clearance 0))',
137
+ ' (net_class "Default" "" (clearance 0.2) (trace_width 0.25) (via_dia 0.6) (via_drill 0.3) (uvia_dia 0.3) (uvia_drill 0.1)'
138
+ + " " + "".join(f'(add_net "{esc(n)}")' for n in sorted(nets)) + ')']
139
+ L.append(' (net 0 "")')
140
+ for n in nets:
141
+ L.append(f' (net {ncode[n]} "{n.replace(chr(92), chr(92)*2).replace(chr(34), chr(92)+chr(34))}")')
142
+ # Edge.Cuts rectangle
143
+ pts = [(5, 5), (board_w, 5), (board_w, board_h - 5), (5, board_h - 5), (5, 5)]
144
+ for i in range(4):
145
+ L.append(f' (gr_line (start {pts[i][0]} {pts[i][1]}) (end {pts[i+1][0]} {pts[i+1][1]})'
146
+ f' (stroke (width 0.1) (type solid)) (layer "Edge.Cuts") (uuid "{U()}"))')
147
+ for (it, px, py) in placed:
148
+ c, km, _, _, (x0, y0) = it
149
+ ref = c["ref"]
150
+ pins = c["pins"]
151
+ # pad name -> net number string; match footprint pad names
152
+ fp_padnames = re.findall(r'\(pad "([^"]*)"', km) # file order; '' = np/mount hole
153
+ named = [n for n in fp_padnames if n]
154
+ mapping = {}
155
+ alias = ALIASES.get((ref, c["footprint"]))
156
+ if alias:
157
+ mapping = dict(alias)
158
+ elif set(pins) <= set(named):
159
+ pass # identity match on covered pins; extra pads (shell/np) stay net 0
160
+ elif len(named) == len(pins):
161
+ order_pins = sorted(pins, key=lambda pin: int(pin) if pin.isdigit() else 0)
162
+ for fpn, spp in zip(named, order_pins):
163
+ mapping.setdefault(fpn, spp)
164
+ warn.append(f"POSITIONAL-MAP {ref} {c['footprint']}: {len(named)} pads zipped to spec pins — NO authority table, verify datasheet")
165
+ else:
166
+ warn.append(f"NO-AUTHORITY-MAP {ref} {c['footprint']}: pads {sorted(set(named))[:8]} vs spec pins {sorted(pins)[:8]} -> design-data GAP, net 0 (refusing to guess)")
167
+ # origin so that bbox min lands at px,py
168
+ ox, oy = snap(px - x0), snap(py - y0)
169
+ head = f' (footprint "{esc(c["footprint"])}" (layer "F.Cu") (uuid "{U()}") (at {ox} {oy})'
170
+ head += (f' (property "Reference" "{esc(ref)}" (at 0 -1 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))'
171
+ f' (property "Value" "{esc(c.get("mpn", ref))}" (at 0 1 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))')
172
+ body = km[km.index("(attr"):] if "(attr" in km else km[km.rindex(")"):]
173
+ # inject nets into pad blocks
174
+ out_km = []
175
+ pos = 0
176
+ for pb in forms(body):
177
+ m = re.match(r'\(pad "([^"]+)"', pb)
178
+ s, e = body.find(pb, pos), body.find(pb, pos) + len(pb)
179
+ if m:
180
+ padname = m.group(1)
181
+ pin = None if padname == "" else mapping.get(padname, padname if padname in pins else None)
182
+ if pin is not None:
183
+ net = pins[pin]
184
+ ins = f' (net {ncode[net]} "{esc(net)}")'
185
+ pb = pb[:-1] + ins + ")"
186
+ pos = e
187
+ out_km.append((s, pb))
188
+ rebuilt = body
189
+ for s, pb in reversed(out_km):
190
+ e = s + None if False else s
191
+ # simpler: replace each original pad block string once
192
+ # (do textual replacement with count=1 on the ORIGINAL blocks)
193
+ for orig_pb in [b for b in forms(body) if b.startswith('(pad ')]:
194
+ new_pb = next(p for (s, p) in out_km if s == body.find(orig_pb))
195
+ rebuilt = rebuilt.replace(orig_pb, new_pb, 1)
196
+ rebuilt = rebuilt[:rebuilt.rindex(")")] # strip trailing close of fp file
197
+ L.append(head + "\n" + rebuilt + "\n )")
198
+ L.append(')\n')
199
+ open(out_path, "w").write("\n".join(x for x in L if x) + "\n")
200
+ pro = out_path[:-len(".kicad_pcb")] + ".kicad_pro"
201
+ import json as _j
202
+ _j.dump({
203
+ "board": {"design_settings": {
204
+ "defaults": {"board_outline_line_width": 0.1, "copper_line_width": 0.2,
205
+ "solder_mask_clearance": 0.0, "solder_mask_min_width": 0.0,
206
+ "allow_soldermask_bridges_in_footprints": True},
207
+ "drc_exclusions": [],
208
+ "rules": {"min_hole_to_hole": 0.15, "min_through_hole_diameter": 0.15,
209
+ "min_clearance": 0.2, "min_connection": 0.0,
210
+ "min_copper_edge_clearance": 0.1, "min_hole_clearance": 0.15,
211
+ "min_silk_clearance": 0.0, "min_text_height": 0.8,
212
+ "min_text_thickness": 0.12, "min_track_width": 0.15,
213
+ "min_via_annular_width": 0.1, "min_via_diameter": 0.45,
214
+ "solder_mask_to_copper_clearance": 0.0, "use_height_for_length_calcs": True}}},
215
+ "libraries": {"fp_files": []},
216
+ "meta": {"filename": pro.split("/")[-1], "version": 3},
217
+ "net_settings": {"classes": [{"name": "Default", "clearance": 0.2,
218
+ "trace_width": 0.25, "via_dia": 0.6, "via_drill": 0.2, "uvia_dia": 0.3,
219
+ "uvia_drill": 0.1, "allow_soldermask_bridges_in_footprints": True}],
220
+ "meta": {"version": 3}},
221
+ "pcbnew": {"last_paths": {}},
222
+ "sheets": [], "text_variables": {}},
223
+ open(pro, "w"), indent=1)
224
+ return nets, warn, board_w, board_h
225
+
226
+
227
+ def esc(s):
228
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
229
+
230
+
231
+ if __name__ == "__main__":
232
+ nl = sys.argv[1]
233
+ out = sys.argv[2]
234
+ nets, warn, w, h = author(nl, out)
235
+ print(f"authored {out}: nets={len(nets)} board={w}x{h}mm")
236
+ for x in warn:
237
+ print("WARN", x)
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """r17 deterministic schematic author (RM-56: netlist bytes NEVER through a model).
3
+ Topology: NO wires. Every pin gets one global label anchored EXACTLY on the pin
4
+ end, rotation 180 (text left, away from body). Anchor == pin end by construction;
5
+ no dangling labels (r13 D1 defect class), no collinear merges. Grid 0.635 mm.
6
+ Usage: board_author_sch.py netlist.json -o out.kicad_sch"""
7
+ import json, re, argparse
8
+ from collections import defaultdict
9
+
10
+ G = 0.635
11
+ COLPITCH = 99.06 # column pitch (41-pin U1 fans 52mm left of anchor col)
12
+ ROWPITCH = 76.2 # row pitch; keep > 2.54 * maxpins/2 stack of neighbours
13
+ PINPITCH = 2.54 # pin vertical spacing (label height 1.27 leaves 1.27 gap)
14
+
15
+
16
+ def snap(v):
17
+ return round(v / G) * G
18
+
19
+
20
+ def esc(s):
21
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
22
+
23
+
24
+ class Uuid:
25
+ def __init__(self):
26
+ self.n = 0
27
+
28
+ def __call__(self):
29
+ self.n += 1
30
+ x = f"{self.n:032x}"
31
+ return f"{x[:8]}-{x[8:12]}-{x[12:16]}-{x[16:20]}-{x[20:32]}"
32
+
33
+
34
+ def pin_order(pins):
35
+ return sorted(pins, key=lambda p: int(p) if p.isdigit() else 0)
36
+
37
+
38
+ def build(netlist_path, out_path):
39
+ d = json.load(open(netlist_path))
40
+ comps = d["components"]
41
+ u = Uuid()
42
+ order = sorted(comps, key=lambda c: (c["ref"][0], int(re.sub(r"\D", "", c["ref"]) or 0)))
43
+ pos = {}
44
+ for slot, c in enumerate(order):
45
+ row, col = divmod(slot, 4)
46
+ pos[c["ref"]] = (snap(60 + col * COLPITCH), snap(60 + row * ROWPITCH))
47
+ # symbol defs: single-sided fan-left, PINPITCH spacing
48
+ prefix_pins = {}
49
+ pinset = {}
50
+ for c in order:
51
+ ref = c["ref"]
52
+ ps = pin_order(c["pins"])
53
+ key = "SR_" + re.sub(r"\d+$", "", ref)
54
+ if key in prefix_pins and prefix_pins[key] != ps:
55
+ key = "SR_" + ref
56
+ prefix_pins.setdefault(key, ps)
57
+ c["_key"] = key
58
+ sx, sy = pos[ref]
59
+ n = len(ps)
60
+ # world y-down vs lib-symbol-local y-UP: the def emits oy=+off (up) per
61
+ # pin slot i; the label anchor in SHEET coords must therefore SUBTRACT.
62
+ pinset[ref] = [(p, snap(sx - 5.08), snap(sy - ((n - 1) / 2 - i) * PINPITCH))
63
+ for i, p in enumerate(ps)]
64
+ labels = []
65
+ for c in order:
66
+ for pnum, net in c["pins"].items():
67
+ px, py = next((ax, ay) for (pp, ax, ay) in pinset[c["ref"]] if pp == pnum)
68
+ labels.append((net, px, py))
69
+ L = ['(kicad_sch (version 20250114) (generator "board_author_sch") (generator_version "10.0.5")',
70
+ f' (uuid "{u()}")', ' (paper "A3")', ' (lib_symbols']
71
+ for key, ps in sorted(prefix_pins.items()):
72
+ n = len(ps)
73
+ L.append(f' (symbol "solar_ray:{esc(key)}"')
74
+ L.append(' (pin_numbers (hide yes))')
75
+ L.append(' (pin_names (offset 1.016))')
76
+ L.append(' (exclude_from_sim no) (in_bom yes) (on_board yes)')
77
+ L.append(' (property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))')
78
+ L.append(' (property "Value" "V" (at 0 0 0) (effects (font (size 1.27 1.27))))')
79
+ L.append(' (property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))')
80
+ L.append(' (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))')
81
+ L.append(f' (symbol "{esc(key)}_1_1"')
82
+ for i, p in enumerate(ps):
83
+ oy = snap(((n - 1) / 2 - i) * PINPITCH)
84
+ L.append(f' (pin passive line (at {-5.08} {oy} 0) (length 5.08)'
85
+ f' (name "~" (effects (font (size 1.016 1.016))))'
86
+ f' (number "{esc(p)}" (effects (font (size 1.016 1.016)))))')
87
+ L.append(' )')
88
+ L.append(' (embedded_fonts no)')
89
+ L.append(' )')
90
+ L.append(' )')
91
+ for c in order:
92
+ ref = c["ref"]
93
+ sx, sy = pos[ref]
94
+ L.append(f' (symbol (lib_id "solar_ray:{esc(c["_key"])}") (at {sx} {sy} 0) (unit 1)')
95
+ L.append(' (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (fields_autoplaced no)')
96
+ L.append(f' (uuid "{u()}")')
97
+ for k, v, xo in (("Reference", ref, -2.54), ("Value", c.get("mpn", ref), 2.54),
98
+ ("Footprint", c.get("footprint", ""), 0.0), ("Datasheet", "", 0.0)):
99
+ L.append(f' (property "{k}" "{esc(v)}" (at {snap(sx+xo)} {snap(sy+5.08)} 0)'
100
+ f' (effects (font (size 1.27 1.27)) (hide yes)))')
101
+ for (p, _, _) in pinset[ref]:
102
+ L.append(f' (pin "{esc(p)}" (uuid "{u()}"))')
103
+ L.append(' )')
104
+ for net, x, y in labels:
105
+ L.append(f' (global_label "{esc(net)}" (shape passive) (at {x} {y} 180)'
106
+ f' (effects (font (size 1.27 1.27)) (justify left)) (uuid "{u()}"))')
107
+ L += [' (sheet_instances', ' (path "/" (page "1"))', ' )', ' (embedded_fonts no)', ')']
108
+ open(out_path, "w").write("\n".join(L) + "\n")
109
+ return len(order), len(labels)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ ap = argparse.ArgumentParser()
114
+ ap.add_argument("netlist")
115
+ ap.add_argument("-o", "--out", required=True)
116
+ a = ap.parse_args()
117
+ s, l = build(a.netlist, a.out)
118
+ print(f"emitted {a.out}: symbols={s} labels={l}")
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+ # r17 finalization pipeline: routed board -> pour -> native refill -> DRC gate -> fab exports
3
+ set -e
4
+ IN="${1:?routed kicad_pcb}"
5
+ OUT="${2:?output board pcb}"
6
+ RUN="${3:-evolution-round-17}"
7
+ cd /home/lab/workspace/pcb-control
8
+ python3 tools/board_zone_pour.py "$IN" "$OUT"
9
+ cp /tmp/sr-c4.kicad_pro "$(dirname "$OUT")/$(basename "$OUT" .kicad_pcb).kicad_pro"
10
+ D=/home/lab/workspace/pcb-control/reports/recovery/$RUN
11
+ kicad-cli pcb drc "$OUT" --refill-zones --save-board --format json --severity-error --output "$D/$(basename "$OUT").drc-refill.json" >/dev/null 2>&1
12
+ kicad-cli pcb drc "$OUT" --format json --severity-error --output "$D/$(basename "$OUT").drc-final.json" >/dev/null 2>&1
13
+ python3 - "$D/$(basename "$OUT").drc-final.json" <<'EOF'
14
+ import json, sys, collections
15
+ d = json.load(open(sys.argv[1]))
16
+ v = d.get('violations', []); u = d.get('unconnected_items', [])
17
+ print(f"GATE: errors={len(v)} {dict(collections.Counter(x.get('type','?') for x in v))} unconnected={len(u)}")
18
+ EOF
19
+ mkdir -p "$D/fab-out"
20
+ kicad-cli pcb export gerbers "$OUT" -o "$D/fab-out/gerbers.zip" >/dev/null
21
+ kicad-cli pcb export drill "$OUT" -o "$D/fab-out/" >/dev/null
22
+ kicad-cli pcb export pos "$OUT" -o "$D/fab-out/pos.pos" --units mm >/dev/null
23
+ echo "fab exports -> $D/fab-out/"; ls "$D/fab-out/" | head
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """r17 board gate: ERC-clean + netlist-exact roundtrip for an authored sheet.
3
+ Exit 0 only when: ERC 0 errors, 0 unconnected, and EVERY spec net appears in the
4
+ export with EXACTLY its spec membership (and no extra nets). Closes the r13 D1
5
+ defect class deterministically (no model in the byte path)."""
6
+ import json, re, subprocess, sys, tempfile, os
7
+ from collections import defaultdict
8
+
9
+ def main(sch, netlist_json):
10
+ spec = json.load(open(netlist_json))
11
+ got = defaultdict(set)
12
+ for c in spec["components"]:
13
+ for p, net in c["pins"].items():
14
+ got[net].add(f"{c['ref']}.{p}")
15
+ with tempfile.TemporaryDirectory() as td:
16
+ erc = os.path.join(td, "erc.json"); net = os.path.join(td, "netlist.net")
17
+ r1 = subprocess.run(["kicad-cli", "sch", "erc", sch, "--format", "json",
18
+ "--severity-error", "--output", erc], capture_output=True, text=True)
19
+ r2 = subprocess.run(["kicad-cli", "sch", "export", "netlist", sch, "-o", net],
20
+ capture_output=True, text=True)
21
+ if r1.returncode or not os.path.exists(erc):
22
+ print(f"FAIL ERC_RUN rc={r1.returncode} {r1.stderr[:200]}"); return 1
23
+ if r2.returncode or not os.path.exists(net):
24
+ print(f"FAIL EXPORT rc={r2.returncode} {r2.stderr[:200]}"); return 1
25
+ d = json.load(open(erc))
26
+ sh = d["sheets"][0].get("erc", {})
27
+ v, u = sh.get("violations", []), sh.get("unconnected", [])
28
+ if v or u:
29
+ print(f"FAIL ERC violations={len(v)} unconnected={len(u)}")
30
+ for x in v[:6]: print(" ", json.dumps(x)[:180])
31
+ return 1
32
+ txt = open(net).read()
33
+ # parenthesis-balanced extraction of each (net ...) block (regex cannot
34
+ # nest; truncation risk seen in dev round — never parse s-exprs with a
35
+ # lazy match across closers).
36
+ exp = defaultdict(set)
37
+ i = 0
38
+ while True:
39
+ i = txt.find("(net\n", i)
40
+ if i < 0: break
41
+ depth = 0; j = i; instr = False
42
+ while j < len(txt):
43
+ ch = txt[j]
44
+ if ch == '"' and txt[j-1] != "\\": instr = not instr
45
+ elif not instr:
46
+ if ch == "(": depth += 1
47
+ elif ch == ")":
48
+ depth -= 1
49
+ if depth == 0: break
50
+ j += 1
51
+ block = txt[i:j+1]
52
+ m = re.search(r'\(name "([^"]+)"\)', block)
53
+ if m:
54
+ for ref, pin in re.findall(r'ref "([^"]+)"\)\s*\(\s*pin "([^"]+)"', block):
55
+ exp[m.group(1)].add(f"{ref}.{pin}")
56
+ i = j + 1
57
+ miss = {n for n in got if exp.get(n) != got[n]}
58
+ extra = set(exp) - set(got)
59
+ if miss or extra:
60
+ print(f"FAIL ROUNDTRIP {len(got)-len(miss)}/{len(got)} exact extra_nets={sorted(extra)[:5]}")
61
+ for n in sorted(miss)[:6]:
62
+ print(" DIFF", n, "spec-only", sorted(got[n]-exp.get(n, set()))[:3],
63
+ "exp-only", sorted(exp.get(n, set())-got[n])[:3])
64
+ return 1
65
+ print(f"PASS erc=0/0 nets={len(got)}/{len(got)} exact (members {sum(len(x) for x in got.values())} pins)")
66
+ return 0
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main(sys.argv[1], sys.argv[2]))
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ """r17 pcb placement gate: kicad-cli DRC must report 0 error-level violations.
3
+ unconnected_items are EXPECTED pre-routing (counted, not failing)."""
4
+ import json, subprocess, sys, os, tempfile
5
+ board = sys.argv[1]
6
+ assert board.endswith(".kicad_pcb")
7
+ pro = board[:-10] + ".kicad_pro"
8
+ if not os.path.exists(pro):
9
+ print("FAIL no sibling .kicad_pro (design constraints live there)"); sys.exit(1)
10
+ with tempfile.TemporaryDirectory() as td:
11
+ out = os.path.join(td, "drc.json")
12
+ r = subprocess.run(["kicad-cli", "pcb", "drc", board, "--format", "json",
13
+ "--severity-error", "--output", out], capture_output=True, text=True)
14
+ if r.returncode or not os.path.exists(out):
15
+ print(f"FAIL load/drc rc={r.returncode} {r.stderr[:200]}"); sys.exit(1)
16
+ d = json.load(open(out))
17
+ v = d.get("violations", [])
18
+ u = d.get("unconnected_items", [])
19
+ if v:
20
+ print(f"FAIL DRC err={len(v)}")
21
+ for x in v[:8]: print(" ", x.get("description", "")[:100])
22
+ sys.exit(1)
23
+ print(f"PASS placement-DRC 0 errors; pending-route unconnected={len(u)}")