gitcad 0.7.7__py3-none-any.whl

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.
gitcad/merge3.py ADDED
@@ -0,0 +1,289 @@
1
+ """Semantic 3-way merge for design documents (ADR-0016).
2
+
3
+ Merge units are semantic, keyed by stable identity: features by id for
4
+ models, components by ref and connectivity by PIN for schematics. Both
5
+ sides touching different units merges cleanly; both sides changing the
6
+ same unit differently is a structured conflict — never a text marker
7
+ inside canonical JSON (that would make the file unparseable for every
8
+ tool including the resolver).
9
+
10
+ Result contract: ``{"ok": bool, "merged": text|None, "conflicts": [...]}``.
11
+ A clean merge's output is re-parsed through the document's own loader
12
+ before being returned — a merge that doesn't rebuild is a driver bug,
13
+ never shipped state.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+
20
+ from gitcad.canonical import canonical_json
21
+ from gitcad.release import _kind
22
+
23
+
24
+ def _cell3(base, ours, theirs):
25
+ """Classic 3-way cell. Values are canonical strings (None = absent).
26
+ Returns ("take", value) or ("conflict", ours, theirs)."""
27
+ if ours == theirs:
28
+ return ("take", ours)
29
+ if ours == base:
30
+ return ("take", theirs)
31
+ if theirs == base:
32
+ return ("take", ours)
33
+ return ("conflict", ours, theirs)
34
+
35
+
36
+ def merge_documents(base: str, ours: str, theirs: str) -> dict:
37
+ kind_b = _kind(base)
38
+ if not (_kind(ours) == _kind(theirs) == kind_b):
39
+ return {"ok": False, "merged": None, "conflicts": [
40
+ {"unit": "document", "reason": "kind changed between branches"}]}
41
+ if kind_b == "document":
42
+ return _merge_model(base, ours, theirs)
43
+ if kind_b == "schematic":
44
+ return _merge_schematic(base, ours, theirs)
45
+ if kind_b == "board":
46
+ return _merge_board(base, ours, theirs)
47
+ # parts: whole-document 3-way, honest coarse fallback (ADR-0016)
48
+ verdict = _cell3(base, ours, theirs)
49
+ if verdict[0] == "take":
50
+ return {"ok": True, "merged": verdict[1], "conflicts": []}
51
+ return {"ok": False, "merged": None, "conflicts": [
52
+ {"unit": kind_b, "reason": "both branches changed this document "
53
+ "(fine-grained merge for this kind is a later stage)"}]}
54
+
55
+
56
+ # -- boards: refs/names are identity; copper's CONTENT is its identity --------
57
+
58
+ def _merge_board(base: str, ours: str, theirs: str) -> dict:
59
+ """Fine-grained board merge (ADR-0016 upgrade):
60
+
61
+ - components by ref, mounting holes by name, net classes by class name —
62
+ classic 3-way cells (both changed differently = conflict);
63
+ - tracks/vias/zones have no stable id, and don't need one: their content
64
+ IS their identity, so they merge as element SETS — a moved track is a
65
+ removal plus an addition and merges cleanly; the only way to conflict
66
+ is at the named units above (or the outline, a whole-value cell);
67
+ - outline/thickness/mask_expansion: whole-value cells.
68
+ """
69
+ from gitcad.ecad.board import Board
70
+
71
+ bb, bo, bt = (Board.loads(x) for x in (base, ours, theirs))
72
+ conflicts: list[dict] = []
73
+
74
+ def keyed_merge(unit: str, base_map: dict, ours_map: dict, theirs_map: dict,
75
+ canon) -> dict:
76
+ out = {}
77
+ for k in {**base_map, **ours_map, **theirs_map}:
78
+ verdict = _cell3(canon(base_map.get(k)), canon(ours_map.get(k)),
79
+ canon(theirs_map.get(k)))
80
+ if verdict[0] == "conflict":
81
+ side = lambda v: json.loads(v) if v else None # noqa: E731
82
+ conflicts.append({"unit": unit, "key": k,
83
+ "ours": side(verdict[1]),
84
+ "theirs": side(verdict[2])})
85
+ elif verdict[1] is not None:
86
+ out[k] = json.loads(verdict[1])
87
+ return out
88
+
89
+ from dataclasses import asdict
90
+
91
+ canon_d = lambda x: canonical_json(asdict(x)) if x is not None else None # noqa: E731
92
+ comps = keyed_merge("component",
93
+ {c.ref: c for c in bb.components},
94
+ {c.ref: c for c in bo.components},
95
+ {c.ref: c for c in bt.components}, canon_d)
96
+ holes = keyed_merge("mounting_hole",
97
+ {m.name: m for m in bb.mounting_holes},
98
+ {m.name: m for m in bo.mounting_holes},
99
+ {m.name: m for m in bt.mounting_holes}, canon_d)
100
+ canon_j = lambda x: canonical_json(x) if x is not None else None # noqa: E731
101
+ classes = keyed_merge("net_class", bb.net_classes, bo.net_classes,
102
+ bt.net_classes, canon_j)
103
+
104
+ def element_set(unit: str, base_l: list, ours_l: list, theirs_l: list) -> list:
105
+ b_set = {canonical_json(asdict(x)) for x in base_l}
106
+ o_set = {canonical_json(asdict(x)) for x in ours_l}
107
+ t_set = {canonical_json(asdict(x)) for x in theirs_l}
108
+ # element-wise 3-way: keep if (in base and neither side removed it)
109
+ # or (added by either side)
110
+ kept = ((b_set & o_set & t_set)
111
+ | (o_set - b_set) | (t_set - b_set))
112
+ return [json.loads(x) for x in sorted(kept)]
113
+
114
+ tracks = element_set("track", bb.tracks, bo.tracks, bt.tracks)
115
+ vias = element_set("via", bb.vias, bo.vias, bt.vias)
116
+ zones = element_set("zone", bb.zones, bo.zones, bt.zones)
117
+
118
+ scalars = {}
119
+ for attr in ("name", "outline", "thickness", "mask_expansion"):
120
+ verdict = _cell3(canonical_json(getattr(bb, attr)),
121
+ canonical_json(getattr(bo, attr)),
122
+ canonical_json(getattr(bt, attr)))
123
+ if verdict[0] == "conflict":
124
+ conflicts.append({"unit": attr,
125
+ "ours": json.loads(verdict[1]),
126
+ "theirs": json.loads(verdict[2])})
127
+ else:
128
+ scalars[attr] = json.loads(verdict[1])
129
+
130
+ if conflicts:
131
+ return {"ok": False, "merged": None, "conflicts": conflicts}
132
+
133
+ merged_doc = {"schema": Board.SCHEMA, "board": {
134
+ **scalars,
135
+ "components": [comps[k] for k in sorted(comps)],
136
+ "tracks": tracks, "vias": vias, "zones": zones,
137
+ "mounting_holes": [holes[k] for k in sorted(holes)],
138
+ "net_classes": classes,
139
+ }}
140
+ text = canonical_json(merged_doc, indent=2) + "\n"
141
+ Board.loads(text) # the rebuild gate
142
+ return {"ok": True, "merged": text, "conflicts": []}
143
+
144
+
145
+ # -- models: features by stable id --------------------------------------------
146
+
147
+ def _merge_model(base: str, ours: str, theirs: str) -> dict:
148
+ from gitcad.document import Document
149
+
150
+ b = {f.id: f for f in Document.loads(base).features}
151
+ o = {f.id: f for f in Document.loads(ours).features}
152
+ t = {f.id: f for f in Document.loads(theirs).features}
153
+ canon = lambda f: canonical_json(f.to_dict()) if f else None # noqa: E731
154
+
155
+ conflicts: list[dict] = []
156
+ keep: dict[str, dict] = {}
157
+ for fid in {**b, **o, **t}:
158
+ verdict = _cell3(canon(b.get(fid)), canon(o.get(fid)), canon(t.get(fid)))
159
+ if verdict[0] == "conflict":
160
+ side = lambda v: json.loads(v) if v else None # noqa: E731
161
+ conflicts.append({"unit": "feature", "id": fid,
162
+ "op": (o.get(fid) or t.get(fid) or b.get(fid)).op,
163
+ "ours": side(verdict[1]), "theirs": side(verdict[2])})
164
+ elif verdict[1] is not None:
165
+ keep[fid] = json.loads(verdict[1])
166
+ if conflicts:
167
+ return {"ok": False, "merged": None, "conflicts": conflicts}
168
+
169
+ # Order: base survivors, then ours-added, then theirs-added…
170
+ order = [fid for fid in b if fid in keep]
171
+ order += [fid for fid in o if fid in keep and fid not in b]
172
+ order += [fid for fid in t if fid in keep and fid not in b and fid not in o]
173
+ # …then a stable topological pass so inputs precede their users.
174
+ placed: list[str] = []
175
+ ready: set[str] = set()
176
+ pending = list(order)
177
+ while pending:
178
+ progressed = False
179
+ for fid in list(pending):
180
+ if all(ref in ready for ref in keep[fid].get("inputs", [])):
181
+ placed.append(fid)
182
+ ready.add(fid)
183
+ pending.remove(fid)
184
+ progressed = True
185
+ if not progressed:
186
+ return {"ok": False, "merged": None, "conflicts": [
187
+ {"unit": "feature", "id": pending[0],
188
+ "reason": "input dependency cycle or input deleted by the "
189
+ "other branch"}]}
190
+ merged_text = canonical_json(
191
+ {"schema": "gitcad/document@1",
192
+ "features": [keep[fid] for fid in placed]}, indent=2) + "\n"
193
+ Document.loads(merged_text) # the rebuild gate: must parse or we crash here
194
+ return {"ok": True, "merged": merged_text, "conflicts": []}
195
+
196
+
197
+ # -- schematics: components by ref, connectivity by pin -----------------------
198
+
199
+ def _pin_map(sch) -> dict[str, str]:
200
+ return {pr: net for net, prs in sch.nets.items() for pr in prs}
201
+
202
+
203
+ def _merge_schematic(base: str, ours: str, theirs: str) -> dict:
204
+ from gitcad.ecad.schematic import Schematic
205
+
206
+ sb, so, st = (Schematic.loads(x) for x in (base, ours, theirs))
207
+ canon = lambda c: canonical_json( # noqa: E731
208
+ {"ref": c.ref, "value": c.value, "footprint": c.footprint,
209
+ "pins": [p.__dict__ for p in c.pins], "attrs": c.attrs}) if c else None
210
+
211
+ conflicts: list[dict] = []
212
+ comps: dict[str, dict] = {}
213
+ cb = {c.ref: c for c in sb.components}
214
+ co = {c.ref: c for c in so.components}
215
+ ct = {c.ref: c for c in st.components}
216
+ for ref in {**cb, **co, **ct}:
217
+ verdict = _cell3(canon(cb.get(ref)), canon(co.get(ref)), canon(ct.get(ref)))
218
+ if verdict[0] == "conflict":
219
+ side = lambda v: json.loads(v) if v else None # noqa: E731
220
+ conflicts.append({"unit": "component", "ref": ref,
221
+ "ours": side(verdict[1]), "theirs": side(verdict[2])})
222
+ elif verdict[1] is not None:
223
+ comps[ref] = json.loads(verdict[1])
224
+
225
+ pb, po, pt = _pin_map(sb), _pin_map(so), _pin_map(st)
226
+ pins: dict[str, str] = {}
227
+ for pr in {**pb, **po, **pt}:
228
+ verdict = _cell3(pb.get(pr), po.get(pr), pt.get(pr))
229
+ if verdict[0] == "conflict":
230
+ conflicts.append({"unit": "pin", "pin": pr,
231
+ "ours": verdict[1], "theirs": verdict[2]})
232
+ elif verdict[1] is not None and pr.split(".", 1)[0] in comps:
233
+ pins[pr] = verdict[1]
234
+
235
+ specs: dict[str, dict] = {}
236
+ for key in {**sb.net_specs, **so.net_specs, **st.net_specs}:
237
+ cj = lambda d: canonical_json(d) if d is not None else None # noqa: E731
238
+ verdict = _cell3(cj(sb.net_specs.get(key)), cj(so.net_specs.get(key)),
239
+ cj(st.net_specs.get(key)))
240
+ if verdict[0] == "conflict":
241
+ conflicts.append({"unit": "net_spec", "net": key,
242
+ "ours": verdict[1], "theirs": verdict[2]})
243
+ elif verdict[1] is not None:
244
+ specs[key] = json.loads(verdict[1])
245
+
246
+ if conflicts:
247
+ return {"ok": False, "merged": None, "conflicts": conflicts}
248
+
249
+ name3 = _cell3(sb.name, so.name, st.name)
250
+ merged = Schematic(name=name3[1] if name3[0] == "take" else so.name)
251
+ from gitcad.ecad.schematic import Pin, SchComponent
252
+
253
+ for ref in sorted(comps):
254
+ c = comps[ref]
255
+ merged.components.append(SchComponent(
256
+ ref=c["ref"], value=c["value"], footprint=c["footprint"],
257
+ pins=[Pin(**p) for p in c["pins"]], attrs=c["attrs"]))
258
+ for pr, net in pins.items():
259
+ merged.connect(net, pr)
260
+ merged.net_specs = specs
261
+ text = merged.dumps()
262
+ Schematic.loads(text) # the rebuild gate
263
+ return {"ok": True, "merged": text, "conflicts": []}
264
+
265
+
266
+ def main() -> None: # pragma: no cover - git merge driver entrypoint
267
+ """git merge driver: gitcad-merge %O %A %B — result written to %A.
268
+
269
+ .gitattributes: *.gitcad.json merge=gitcad
270
+ .git/config: [merge "gitcad"]
271
+ name = gitcad semantic merge
272
+ driver = gitcad-merge %O %A %B
273
+ """
274
+ import sys
275
+ from pathlib import Path
276
+
277
+ base_p, ours_p, theirs_p = (Path(p) for p in sys.argv[1:4])
278
+ result = merge_documents(base_p.read_text(encoding="utf-8"),
279
+ ours_p.read_text(encoding="utf-8"),
280
+ theirs_p.read_text(encoding="utf-8"))
281
+ if result["ok"]:
282
+ ours_p.write_text(result["merged"], encoding="utf-8")
283
+ sys.exit(0)
284
+ report = ours_p.with_suffix(ours_p.suffix + ".gitcad-conflict.json")
285
+ report.write_text(canonical_json({"conflicts": result["conflicts"]},
286
+ indent=2) + "\n", encoding="utf-8")
287
+ print(f"gitcad-merge: {len(result['conflicts'])} semantic conflict(s) — "
288
+ f"see {report.name}", file=sys.stderr)
289
+ sys.exit(1)
gitcad/pcba.py ADDED
@@ -0,0 +1,114 @@
1
+ """PCBA parts — the Fusion-360 duality, gitcad-style.
2
+
3
+ At the assembly level a PCBA is a mechanical part: envelope, mounting-hole
4
+ ports, a 3D body. ``pcba_verify`` is what "entering" it means for checks —
5
+ one call runs the complete electrical workflow gate over the part's
6
+ referenced sources:
7
+
8
+ - ERC + electrical envelopes (ADR-0015) per schematic
9
+ - board validation + DRC + copper connectivity
10
+ - schematic↔board parity (the ECO check) for every schematic
11
+
12
+ The part manifest is the front door; the electrical truth lives in the
13
+ files it references, exactly like a mech part references its .model.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+
21
+ from gitcad.errors import GitcadError
22
+
23
+
24
+ def is_pcba(part_text: str) -> bool:
25
+ try:
26
+ doc = json.loads(part_text)
27
+ except Exception:
28
+ return False
29
+ return (doc.get("schema", "").startswith("gitcad/part")
30
+ and (doc.get("body") or {}).get("kind") == "pcba")
31
+
32
+
33
+ def pcba_sources(part_text: str, root: str) -> dict:
34
+ """Resolve the PCBA's referenced files: {"board": Path, "schematics": [Path]}."""
35
+ from gitcad.part import PartManifest
36
+
37
+ part = PartManifest.loads(part_text)
38
+ body = part.body or {}
39
+ if body.get("kind") != "pcba":
40
+ raise GitcadError(f"part {part.name!r} is not a pcba (body.kind="
41
+ f"{body.get('kind')!r})")
42
+ rootp = Path(root)
43
+ board = rootp / body.get("board", "")
44
+ if not body.get("board") or not board.is_file():
45
+ raise GitcadError(f"pcba board file missing: {body.get('board')!r}")
46
+ schematics = []
47
+ for rel in body.get("schematics", []):
48
+ p = rootp / rel
49
+ if not p.is_file():
50
+ raise GitcadError(f"pcba schematic file missing: {rel!r}")
51
+ schematics.append(p)
52
+ return {"part": part, "board": board, "schematics": schematics}
53
+
54
+
55
+ def pcba_verify(part_text: str, root: str) -> dict:
56
+ """The electrical workflow's gate, as one call.
57
+
58
+ Multi-schematic semantics: a board's netlist can span many sheets, so
59
+ ERC, envelopes, and parity run on the MERGED system schematic (nets
60
+ union by name — the cross-sheet contract, ADR proved on the real
61
+ 4-sheet Altair where per-sheet checks flag inter-sheet signals as
62
+ false positives). Per-sheet checks would lie; the merged system is
63
+ the electrical truth of this PCBA. Coverage is honest: zero referenced
64
+ schematics is visible, never silently green."""
65
+ from gitcad.ecad import (Board, Schematic, board_parity, check_connectivity,
66
+ check_envelopes, merge_schematics)
67
+ from gitcad.ecad.drc import run_drc
68
+
69
+ src = pcba_sources(part_text, root)
70
+ board = Board.loads(src["board"].read_text(encoding="utf-8"))
71
+ checks: dict = {}
72
+ violations: list[str] = []
73
+
74
+ r = board.validate()
75
+ checks["board:validate"] = "ok" if r.ok else "FAIL"
76
+ violations += [f"board:{v}" for v in r.violations]
77
+ d = run_drc(board)
78
+ checks["board:drc"] = "ok" if d.ok else "FAIL"
79
+ violations += [f"drc:{v}" for v in d.violations]
80
+ c = check_connectivity(board)
81
+ checks["board:connectivity"] = "ok" if c.ok else "FAIL"
82
+ violations += [f"connectivity:{v}" for v in c.violations]
83
+
84
+ sheets = [Schematic.loads(p.read_text(encoding="utf-8"))
85
+ for p in src["schematics"]]
86
+ if sheets:
87
+ system = sheets[0] if len(sheets) == 1 else \
88
+ merge_schematics(f"{src['part'].name}-system", sheets)
89
+ e = system.erc()
90
+ checks["system:erc"] = "ok" if e.ok else "FAIL"
91
+ violations += [f"erc:{v}" for v in e.violations]
92
+ env = check_envelopes(system)
93
+ checks["system:envelope"] = "ok" if env.ok else "FAIL"
94
+ violations += [f"envelope:{v}" for v in env.violations]
95
+ p = board_parity(system, board)
96
+ checks["system:parity"] = "ok" if p.ok else "FAIL"
97
+ violations += [f"parity:{v}" for v in p.violations]
98
+
99
+ checks["schematics_checked"] = len(sheets)
100
+
101
+ # waivers: <partname>.waivers next to the part — suppression that shows
102
+ waivers_file = Path(root) / f"{src['part'].name}.waivers"
103
+ waived: list[dict] = []
104
+ if waivers_file.is_file():
105
+ from gitcad.waivers import load_waivers, waive
106
+
107
+ ws = load_waivers(waivers_file.read_text(encoding="utf-8"))
108
+ violations, waived, unused = waive(violations, ws)
109
+ checks["waived"] = len(waived)
110
+ if unused:
111
+ violations += [f"waiver-unused:{m}" for m in unused]
112
+
113
+ return {"ok": not violations, "part": src["part"].name,
114
+ "checks": checks, "violations": violations, "waived": waived}
gitcad/release.py ADDED
@@ -0,0 +1,223 @@
1
+ """Release machinery + semantic diff — the git-native thesis, completed.
2
+
3
+ ``release()`` is Project-Releaser-as-code (feature-map A8/B5): run EVERY
4
+ check the project defines — model validation, ERC, schematic-board parity,
5
+ DRC, fab-readiness — and only on all-green produce the immutable artifact
6
+ set (STEP/STL/drawings for models, Gerber/drill/PnP for boards) plus a
7
+ manifest pinning every input and output by sha256. A release either passes
8
+ everything or it does not exist; there is no "release with known failures".
9
+
10
+ ``semantic_diff()`` is the PR review surface ADR-0004 promised: not a text
11
+ diff but a *meaning* diff — features added/removed/changed by stable id,
12
+ volume delta from real geometry, board item deltas, and for parts the
13
+ interface-semver classification with the required version bump.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from gitcad.canonical import canonical_json
24
+ from gitcad.document import Document
25
+ from gitcad.errors import GitcadError
26
+ from gitcad.kernel import get_kernel
27
+ from gitcad.seams import Kernel
28
+
29
+ from gitcad._version import __version__ as _gitcad_version
30
+
31
+
32
+ def _kind(text: str) -> str:
33
+ schema = json.loads(text).get("schema", "")
34
+ for k in ("document", "board", "schematic", "part"):
35
+ if schema.startswith(f"gitcad/{k}"):
36
+ return k
37
+ raise GitcadError(f"unknown schema {schema!r}")
38
+
39
+
40
+ def _sha(path: Path) -> str:
41
+ return hashlib.sha256(path.read_bytes()).hexdigest()
42
+
43
+
44
+ # -- semantic diff ------------------------------------------------------------
45
+
46
+ def semantic_diff(old_text: str, new_text: str, kernel: Kernel | None = None) -> dict:
47
+ """A meaning-level diff between two revisions of the same document."""
48
+ kind_old, kind_new = _kind(old_text), _kind(new_text)
49
+ if kind_old != kind_new:
50
+ return {"kind": "mixed", "error": f"cannot diff {kind_old} against {kind_new}"}
51
+ out: dict = {"kind": kind_old}
52
+
53
+ if kind_old == "document":
54
+ old, new = Document.loads(old_text), Document.loads(new_text)
55
+ old_ids = {f.id: f for f in old.features}
56
+ new_ids = {f.id: f for f in new.features}
57
+ out["features_added"] = [{"id": i, "op": new_ids[i].op} for i in new_ids if i not in old_ids]
58
+ out["features_removed"] = [{"id": i, "op": old_ids[i].op} for i in old_ids if i not in new_ids]
59
+ out["features_changed"] = [
60
+ {"id": i, "op": new_ids[i].op}
61
+ for i in new_ids
62
+ if i in old_ids and canonical_json(new_ids[i].to_dict()) != canonical_json(old_ids[i].to_dict())
63
+ ]
64
+ kernel = kernel or get_kernel()
65
+ if not kernel.name.startswith("null"):
66
+ try:
67
+ v_old = kernel.measure(old.build(kernel).final(old))["volume"] if len(old) else 0.0
68
+ v_new = kernel.measure(new.build(kernel).final(new))["volume"] if len(new) else 0.0
69
+ out["volume_mm3"] = {"old": round(v_old, 3), "new": round(v_new, 3),
70
+ "delta": round(v_new - v_old, 3)}
71
+ except Exception as exc:
72
+ out["volume_mm3"] = {"error": f"{type(exc).__name__}: {exc}"}
73
+
74
+ elif kind_old == "board":
75
+ from gitcad.ecad import Board
76
+
77
+ old_b, new_b = Board.loads(old_text), Board.loads(new_text)
78
+ old_refs = {c.ref for c in old_b.components}
79
+ new_refs = {c.ref for c in new_b.components}
80
+ out["components_added"] = sorted(new_refs - old_refs)
81
+ out["components_removed"] = sorted(old_refs - new_refs)
82
+ out["tracks"] = {"old": len(old_b.tracks), "new": len(new_b.tracks)}
83
+ out["vias"] = {"old": len(old_b.vias), "new": len(new_b.vias)}
84
+ out["outline_changed"] = old_b.outline != new_b.outline
85
+
86
+ elif kind_old == "part":
87
+ from gitcad.part import PartManifest, classify_change
88
+
89
+ old_p, new_p = PartManifest.loads(old_text), PartManifest.loads(new_text)
90
+ bump, reasons = classify_change(old_p.interface, new_p.interface)
91
+ out["required_bump"] = bump
92
+ out["reasons"] = reasons
93
+ out["version"] = {"old": old_p.version, "new": new_p.version}
94
+
95
+ else: # schematic
96
+ from gitcad.ecad import Schematic
97
+
98
+ old_s, new_s = Schematic.loads(old_text), Schematic.loads(new_text)
99
+ out["components_added"] = sorted({c.ref for c in new_s.components}
100
+ - {c.ref for c in old_s.components})
101
+ out["components_removed"] = sorted({c.ref for c in old_s.components}
102
+ - {c.ref for c in new_s.components})
103
+ out["nets"] = {"old": len(old_s.nets), "new": len(new_s.nets)}
104
+
105
+ out["identical"] = old_text == new_text
106
+ return out
107
+
108
+
109
+ # -- release ------------------------------------------------------------------
110
+
111
+ @dataclass
112
+ class ReleaseResult:
113
+ version: str
114
+ ok: bool
115
+ checks: dict = field(default_factory=dict)
116
+ failures: list[str] = field(default_factory=list)
117
+ artifacts: dict[str, str] = field(default_factory=dict) # name -> sha256
118
+ manifest_path: str = ""
119
+
120
+
121
+ def release(sources: list[str], outdir: str, version: str) -> ReleaseResult:
122
+ """Check everything, then (and only then) produce the artifact set."""
123
+ out = Path(outdir)
124
+ result = ReleaseResult(version=version, ok=False)
125
+ kernel = None
126
+
127
+ docs: list[tuple[Path, str, str]] = [] # (path, kind, text)
128
+ for src in sources:
129
+ p = Path(src)
130
+ text = p.read_text(encoding="utf-8")
131
+ docs.append((p, _kind(text), text))
132
+
133
+ schematics = [(p, t) for p, k, t in docs if k == "schematic"]
134
+ boards = [(p, t) for p, k, t in docs if k == "board"]
135
+
136
+ # -- phase 1: every check must pass ---------------------------------------
137
+ for p, kind, text in docs:
138
+ label = p.name
139
+ if kind == "document":
140
+ kernel = kernel or get_kernel(require="occt")
141
+ doc = Document.loads(text)
142
+ build = doc.build(kernel)
143
+ for fid, shape in build.shapes.items():
144
+ r = kernel.validate(shape)
145
+ if not r.ok:
146
+ result.failures.append(f"{label}:validate:{fid}:{r.violations}")
147
+ result.checks[f"{label}:validate"] = "ok"
148
+ elif kind == "board":
149
+ from gitcad.ecad import Board, check_connectivity, run_drc
150
+
151
+ board = Board.loads(text)
152
+ r = board.validate()
153
+ if not r.ok:
154
+ result.failures.extend(f"{label}:fab:{v}" for v in r.violations)
155
+ d = run_drc(board)
156
+ if not d.ok:
157
+ result.failures.extend(f"{label}:drc:{v}" for v in d.violations)
158
+ c = check_connectivity(board)
159
+ if not c.ok:
160
+ result.failures.extend(f"{label}:connectivity:{v}" for v in c.violations)
161
+ result.checks[f"{label}:fab"] = "ok" if r.ok else "FAIL"
162
+ result.checks[f"{label}:drc"] = "ok" if d.ok else "FAIL"
163
+ result.checks[f"{label}:connectivity"] = "ok" if c.ok else "FAIL"
164
+ elif kind == "schematic":
165
+ from gitcad.ecad import Schematic
166
+
167
+ r = Schematic.loads(text).erc()
168
+ if not r.ok:
169
+ result.failures.extend(f"{label}:erc:{v}" for v in r.violations)
170
+ result.checks[f"{label}:erc"] = "ok" if r.ok else "FAIL"
171
+
172
+ # cross-checks: every schematic against every board (parity)
173
+ if schematics and boards:
174
+ from gitcad.ecad import Board, Schematic, board_parity
175
+
176
+ for sp, st in schematics:
177
+ for bp, bt in boards:
178
+ r = board_parity(Schematic.loads(st), Board.loads(bt))
179
+ key = f"parity:{sp.name}<->{bp.name}"
180
+ result.checks[key] = "ok" if r.ok else "FAIL"
181
+ if not r.ok:
182
+ result.failures.extend(f"{key}:{v}" for v in r.violations)
183
+
184
+ if result.failures:
185
+ return result # no artifacts on red — a release either passes or isn't
186
+
187
+ # -- phase 2: artifacts ----------------------------------------------------
188
+ out.mkdir(parents=True, exist_ok=True)
189
+ for p, kind, text in docs:
190
+ stem = p.name.replace(".gitcad.json", "").replace(".json", "")
191
+ if kind == "document":
192
+ from gitcad.drawing import make_drawing
193
+
194
+ doc = Document.loads(text)
195
+ shape = doc.build(kernel).final(doc)
196
+ step = out / f"{stem}.step"
197
+ kernel.export_step(shape, str(step))
198
+ stl = out / f"{stem}.stl"
199
+ kernel.export_stl(shape, str(stl))
200
+ pdf = out / f"{stem}.pdf"
201
+ pdf.write_bytes(make_drawing(shape, kernel, title=f"{stem} {version}").to_pdf())
202
+ for f in (step, stl, pdf):
203
+ result.artifacts[f.name] = _sha(f)
204
+ elif kind == "board":
205
+ from gitcad.ecad import Board, export_fab
206
+
207
+ files = export_fab(Board.loads(text), str(out / f"{stem}-fab"))
208
+ for _, fpath in files.items():
209
+ fp = Path(fpath)
210
+ result.artifacts[f"{stem}-fab/{fp.name}"] = _sha(fp)
211
+
212
+ manifest = {
213
+ "generator": f"gitcad {_gitcad_version}",
214
+ "version": version,
215
+ "sources": {p.name: hashlib.sha256(t.encode()).hexdigest() for p, _, t in docs},
216
+ "checks": result.checks,
217
+ "artifacts": result.artifacts,
218
+ }
219
+ mpath = out / "release-manifest.json"
220
+ mpath.write_text(canonical_json(manifest, indent=2) + "\n", newline="\n", encoding="utf-8")
221
+ result.manifest_path = str(mpath)
222
+ result.ok = True
223
+ return result