@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.1

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 (43) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +59 -0
  3. package/README.md +493 -2
  4. package/evals/announce-pcb-fallback/case.ts +67 -0
  5. package/evals/catch-swapped-nets/case.ts +69 -0
  6. package/evals/generate-imu-board/case.ts +60 -0
  7. package/evals/locked-board-fix-upstream/case.ts +70 -0
  8. package/lib/checks.ts +464 -0
  9. package/lib/config.ts +384 -0
  10. package/lib/fingerprint.ts +144 -0
  11. package/lib/footprints.ts +370 -0
  12. package/lib/kicad/cli.ts +93 -0
  13. package/lib/kicad/drc.ts +95 -0
  14. package/lib/kicad/fields.ts +65 -0
  15. package/lib/kicad/handoff.ts +112 -0
  16. package/lib/kicad/netlists.ts +311 -0
  17. package/lib/kicad/probe.cjs +21 -0
  18. package/lib/kicad/project.ts +594 -0
  19. package/lib/kicad/refdes.ts +130 -0
  20. package/lib/kicad/schematic.ts +624 -0
  21. package/lib/kicad/sexpr.ts +117 -0
  22. package/lib/land-geometry.ts +177 -0
  23. package/lib/land-patterns.ts +706 -0
  24. package/lib/layout.ts +236 -0
  25. package/lib/models.ts +284 -0
  26. package/lib/native.ts +199 -0
  27. package/lib/outputs.ts +383 -0
  28. package/lib/paste.ts +69 -0
  29. package/lib/probe.cjs +31 -0
  30. package/lib/runtime.ts +185 -0
  31. package/lib/silk.ts +141 -0
  32. package/lib/source.ts +472 -0
  33. package/lib/stepfile.ts +210 -0
  34. package/lib/suppliers.ts +39 -0
  35. package/ops/circuit.ts +270 -0
  36. package/ops/kicad.ts +636 -0
  37. package/ops/package.ts +156 -0
  38. package/package.json +81 -4
  39. package/protoboard-extension.json +313 -0
  40. package/skills/circuit-board/SKILL.md +494 -0
  41. package/skills/circuit-board/references/pcb-workflow.md +87 -0
  42. package/skills/kicad-cli/SKILL.md +210 -0
  43. package/skills/kicad-layout-review/SKILL.md +275 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The handoff record of a locked board, `<board dir>/kicad/handoff.json`
3
+ * (docs/specs/pcb-kicad-handoff.md §3), and the comparison check makes with
4
+ * it (§4).
5
+ *
6
+ * A board is locked exactly when the record exists with state "locked". It
7
+ * holds the model and the KiCad files as netlists at lock, so check can tell
8
+ * a model change (record → model) from a hand edit in KiCad (record → KiCad),
9
+ * and a change made on both sides (applied) from one still pending.
10
+ */
11
+ import type { CapabilityResult } from "@protoboardai/core";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { diffNetlists, type Change, type Netlist } from "./netlists.ts";
15
+
16
+ export const HANDOFF_SCHEMA = "protoboard.kicad-handoff/v0";
17
+
18
+ export interface Handoff {
19
+ schema: typeof HANDOFF_SCHEMA;
20
+ state: "locked" | "unlocked";
21
+ /** UHD board module id. */
22
+ board: string;
23
+ lockedAt: string;
24
+ unlockedAt?: string;
25
+ /** Re-recorded by circuit:lock --input refresh=true. */
26
+ refreshedAt?: string;
27
+ /** Digest of the board projection (core boardNetlist) at lock. */
28
+ netlistDigest: string;
29
+ /** Digest of the outline, holes, layers and thickness at lock. */
30
+ boardShape: string;
31
+ /** The model at lock, as the export named it. */
32
+ model: Netlist;
33
+ /** The KiCad board as exported (read back from the files written). */
34
+ kicad: Netlist;
35
+ /** Differences between the two at lock (converter limits), so they are not reported as hand edits later. */
36
+ exportedWith?: string[];
37
+ /** Where the exported schematic and board disagreed at lock (KiCad parity), so check reports only new ones. */
38
+ parityAtLock?: string[];
39
+ tscircuit: { sourceDigest: string; circuit: string; core: string };
40
+ tools: { kicad?: string; converter: string; provider: string };
41
+ /** Where KiCad had each footprint at lock (board frame, by instance): export measures moves made in KiCad from here (PB-1027). */
42
+ positions?: Record<string, { x: number; y: number; rot: number; side: "top" | "bottom" }>;
43
+ /** Digest of each KiCad file as exported, by path relative to kicad/. */
44
+ files: Record<string, string>;
45
+ }
46
+
47
+ export const kicadDir = (dir: string) => `${dir}/kicad`;
48
+ export const handoffFile = (dir: string) => `${kicadDir(dir)}/handoff.json`;
49
+
50
+ export function readHandoff(root: string, dir: string): Handoff | undefined {
51
+ const f = join(root, handoffFile(dir));
52
+ if (!existsSync(f)) return undefined;
53
+ try {
54
+ const h = JSON.parse(readFileSync(f, "utf8")) as Handoff;
55
+ return h?.schema === HANDOFF_SCHEMA ? h : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export const isLocked = (root: string, dir: string) => readHandoff(root, dir)?.state === "locked";
62
+
63
+ type Diagnostic = NonNullable<CapabilityResult["diagnostics"]>[number];
64
+
65
+ export interface Comparison {
66
+ /** Model changes since lock not yet in KiCad. */
67
+ pending: Change[];
68
+ /** Model changes since lock that KiCad has too. */
69
+ applied: Change[];
70
+ /** KiCad changes since lock that are not model changes (hand edits). */
71
+ handEdits: Change[];
72
+ }
73
+
74
+ /** Three-way: record → model and record → KiCad, matched by change key. */
75
+ export function compare(record: Handoff, model: Netlist, kicad: Netlist): Comparison {
76
+ const dm = diffNetlists(record.model, model);
77
+ const dk = diffNetlists(record.kicad, kicad);
78
+ const km = new Set(dm.map((c) => c.key));
79
+ const kk = new Set(dk.map((c) => c.key));
80
+ return { pending: dm.filter((c) => !kk.has(c.key)), applied: dm.filter((c) => kk.has(c.key)), handEdits: dk.filter((c) => !km.has(c.key)) };
81
+ }
82
+
83
+ const few = (cs: Change[], n = 6) => `${cs.slice(0, n).map((c) => c.message).join("; ")}${cs.length > n ? `; (+${cs.length - n} more)` : ""}`;
84
+
85
+ /** The comparison as check findings (spec §4 codes). */
86
+ export function comparisonDiagnostics(c: Comparison, ctx: { board: string; file: string; shapeChanged: boolean; module: string }): Diagnostic[] {
87
+ const out: Diagnostic[] = [];
88
+ const add = (severity: Diagnostic["severity"], code: string, message: string, file = ctx.file) => out.push({ code, severity, message, entity: { kind: "definition", id: ctx.module }, source: { file } });
89
+ const pend = (k: Change["kind"][]) => c.pending.filter((x) => k.includes(x.kind));
90
+ const hand = (k: Change["kind"][]) => c.handEdits.filter((x) => k.includes(x.kind));
91
+ const stale = pend(["part_added", "part_removed", "footprint"]);
92
+ if (stale.length) add("error", "CIRCUIT_KICAD_STALE", `the model changed since lock and the KiCad layout does not have it yet: ${few(stale)}. Update the board in KiCad (add or remove the parts, swap the footprint), or unlock and regenerate`);
93
+ if (ctx.shapeChanged) add("error", "CIRCUIT_KICAD_STALE", "the outline, mounting holes, layer count or thickness in circuit.config.ts changed since lock; change the board in KiCad to match, or unlock and regenerate");
94
+ const conn = pend(["connection", "net_renamed"]);
95
+ if (conn.length) add("warning", "CIRCUIT_KICAD_MODEL_CHANGED", `connections between existing parts changed in the model since lock, not yet in KiCad: ${few(conn)}`);
96
+ const fields = pend(["field"]);
97
+ if (fields.length) add("warning", "CIRCUIT_KICAD_FIELDS", `part fields changed in the model since lock: ${few(fields)}; update the symbols' fields in KiCad`);
98
+ if (c.applied.length) add("info", "CIRCUIT_KICAD_APPLIED", `${c.applied.length} model change(s) since lock are already in KiCad (${few(c.applied, 3)}); record them with \`protoboard run circuit:lock --module ${ctx.board} --input refresh=true\``);
99
+ const missing = hand(["part_removed"]);
100
+ if (missing.length) add("error", "CIRCUIT_KICAD_PART_MISSING", `KiCad lacks part(s) the model has: ${few(missing)} (deleted in KiCad?)`);
101
+ const extra = hand(["part_added"]);
102
+ if (extra.length) add("warning", "CIRCUIT_KICAD_PART_EXTRA", `KiCad has part(s) the model does not: ${few(extra)}; a fiducial, test point or added part belongs in the UHD board module first`);
103
+ const fp = hand(["footprint"]);
104
+ if (fp.length) add("warning", "CIRCUIT_KICAD_FOOTPRINT_CHANGED", `footprint(s) changed in KiCad: ${few(fp)}; the land pattern of record is the provider's (footprints.json for a project override)`);
105
+ const ren = hand(["net_renamed"]);
106
+ if (ren.length) add("warning", "CIRCUIT_KICAD_NET_RENAMED", `net(s) renamed in KiCad: ${few(ren)}; the names of record are the UHD board's nets`);
107
+ const moved = hand(["connection"]);
108
+ if (moved.length) add("error", "CIRCUIT_KICAD_NET_CHANGED", `connections changed in KiCad that the model does not have: ${few(moved)}. Change the UHD board module if the change is wanted; nothing is written back`);
109
+ const hf = hand(["field"]);
110
+ if (hf.length) add("warning", "CIRCUIT_KICAD_FIELDS", `part fields edited in KiCad: ${few(hf)}; the values of record are the UHD parts'`);
111
+ return out;
112
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * The three netlists a locked board is compared through
3
+ * (docs/specs/pcb-kicad-handoff.md §4), in one shape: parts by UHD instance
4
+ * (designator, footprint, fields) and nets as pins `<instance>.<pad>`.
5
+ *
6
+ * model the UHD board now, projected the way the export wrote it
7
+ * kicad the KiCad files now: the board's footprints and pads, the
8
+ * schematic's symbols, and the schematic's nets from kicad-cli
9
+ * record both of these as they were at lock (handoff.json)
10
+ *
11
+ * Reading KiCad needs no KiCad: the files are S-expressions (sexpr.ts).
12
+ */
13
+ import type { BoardNetlist } from "@protoboardai/core";
14
+ import type { FootprintOverrides } from "../footprints.ts";
15
+ import { resolveFootprint } from "../footprints.ts";
16
+ import type { NameMap } from "../source.ts";
17
+ import { EXTRA_FIELDS, type ComponentFields } from "./fields.ts";
18
+ import { libItemName } from "./project.ts";
19
+ import { child, children, head, isList, parse, property, text, type List } from "./sexpr.ts";
20
+
21
+ export interface PartRecord {
22
+ refdes: string;
23
+ /** Footprint library id, `<lib>:<entry>`. */
24
+ footprint: string;
25
+ /** Value, MPN, Manufacturer, LCSC (those set). */
26
+ fields: Record<string, string>;
27
+ }
28
+
29
+ export interface Netlist {
30
+ parts: Record<string, PartRecord>;
31
+ /** net name → pins `<instance>.<pad>`, sorted */
32
+ nets: Record<string, string[]>;
33
+ }
34
+
35
+ const COMPARED_FIELDS = ["Value", "MPN", "Manufacturer", "LCSC"] as const;
36
+
37
+ const sortNets = (nets: Record<string, Set<string>>): Record<string, string[]> =>
38
+ Object.fromEntries(
39
+ Object.entries(nets)
40
+ .filter(([, pins]) => pins.size > 0)
41
+ .sort(([a], [b]) => a.localeCompare(b))
42
+ .map(([k, v]) => [k, [...v].sort()]),
43
+ );
44
+
45
+ /** The model's netlist, as an export of it would name parts, footprints and pads. */
46
+ export function modelNetlist(input: { netlist: BoardNetlist; map?: NameMap; refdes: Record<string, string>; fields: Record<string, ComponentFields>; lib: string; overrides?: FootprintOverrides }): Netlist {
47
+ const { netlist, map, refdes, fields, lib } = input;
48
+ // instance → interface → pads, from the build's name map (tscircuit's pad numbers are KiCad's)
49
+ const pads = new Map<string, Map<string, string[]>>();
50
+ for (const m of Object.values(map?.components ?? {})) {
51
+ const byIface = new Map<string, string[]>();
52
+ for (const [port, iface] of Object.entries(m.ports)) {
53
+ if (!iface) continue;
54
+ const pad = port.replace(/^pin/, "");
55
+ byIface.set(iface, [...(byIface.get(iface) ?? []), pad]);
56
+ }
57
+ pads.set(m.instance, byIface);
58
+ }
59
+ const mapped = new Map(Object.values(map?.components ?? {}).map((m) => [m.instance, m]));
60
+ const parts: Record<string, PartRecord> = {};
61
+ for (const c of netlist.components) {
62
+ const entry = mapped.get(c.instance)?.footprint ?? resolveFootprint(c.instance, c.package ? { ...c.package, manufacturer: c.manufacturer } : undefined, input.overrides).entry?.id ?? c.instance;
63
+ const f = fields[c.instance];
64
+ parts[c.instance] = {
65
+ refdes: refdes[c.instance] ?? "",
66
+ footprint: `${lib}:${libItemName(entry)}`,
67
+ fields: Object.fromEntries(COMPARED_FIELDS.filter((k) => f?.[k]).map((k) => [k, f![k] as string])),
68
+ };
69
+ }
70
+ const nets: Record<string, Set<string>> = {};
71
+ for (const n of netlist.nets) {
72
+ const set = (nets[n.id] = new Set<string>());
73
+ for (const p of n.pins) {
74
+ const [inst, iface] = [p.slice(0, p.lastIndexOf(":")), p.slice(p.lastIndexOf(":") + 1)];
75
+ if (!inst) continue; // the board's own edge
76
+ const comp = netlist.components.find((c) => c.instance === inst);
77
+ const known = pads.get(inst)?.get(iface);
78
+ const pin = known ?? [comp?.pins.find((x) => x.interface === iface)?.pin ?? iface];
79
+ for (const pad of pin) set.add(`${inst}.${pad}`);
80
+ }
81
+ }
82
+ return { parts, nets: sortNets(nets) };
83
+ }
84
+
85
+ /** Which instance a KiCad part is: its UHD_Instance field, else its designator through the record. */
86
+ const instanceOf = (fields: Record<string, string>, ref: string, byRef: Map<string, string>) => fields.UHD_Instance || byRef.get(ref) || `?${ref}`;
87
+
88
+ const fieldsOfNode = (n: List): Record<string, string> => {
89
+ const out: Record<string, string> = {};
90
+ for (const k of [...COMPARED_FIELDS, ...EXTRA_FIELDS]) {
91
+ const v = property(n, k);
92
+ if (v) out[k] = v;
93
+ }
94
+ return out;
95
+ };
96
+
97
+ const compared = (f: Record<string, string>) => Object.fromEntries(COMPARED_FIELDS.filter((k) => f[k]).map((k) => [k, f[k]]));
98
+
99
+ /** The board file: footprints (designator, library id, fields) and the copper nets on their pads. */
100
+ export function readBoardNetlist(pcbText: string, refdes: Record<string, string>): Netlist {
101
+ const pcb = parse(pcbText);
102
+ if (head(pcb) !== "kicad_pcb") throw new Error("not a KiCad board file (no kicad_pcb)");
103
+ const byRef = new Map(Object.entries(refdes).map(([i, r]) => [r, i]));
104
+ // KiCad 6–9 number nets: (net 3 "sda") in the header, (net 3 "sda") on pads; KiCad 10 names them: (net "sda")
105
+ const parts: Record<string, PartRecord> = {};
106
+ const nets: Record<string, Set<string>> = {};
107
+ for (const fp of children(pcb, "footprint")) {
108
+ const ref = property(fp, "Reference") ?? text(children(fp, "fp_text").find((t) => text(t.items[1]) === "reference")?.items[2]) ?? "";
109
+ const f = fieldsOfNode(fp);
110
+ // board-only footprints (mounting holes, fiducials) are not components
111
+ if (!f.UHD_Instance && child(fp, "attr")?.items.some((x) => text(x) === "board_only")) continue;
112
+ const inst = instanceOf(f, ref, byRef);
113
+ parts[inst] = { refdes: ref, footprint: text(fp.items[1]) ?? "", fields: compared(f) };
114
+ for (const pad of children(fp, "pad")) {
115
+ const num = text(pad.items[1]) ?? "";
116
+ const net = child(pad, "net");
117
+ if (!num || !net) continue;
118
+ const name = text(net.items[net.items.length - 1]) ?? "";
119
+ if (!name || /^unconnected-/.test(name)) continue;
120
+ (nets[name] ??= new Set()).add(`${inst}.${num}`);
121
+ }
122
+ }
123
+ return { parts, nets: sortNets(nets) };
124
+ }
125
+
126
+ /** The schematic file: its symbols (power symbols, `#…`, left out). Its nets need KiCad (readKicadNetlist). */
127
+ export function readSchematicParts(schText: string, refdes: Record<string, string>): Record<string, PartRecord> {
128
+ const sch = parse(schText);
129
+ if (head(sch) !== "kicad_sch") throw new Error("not a KiCad schematic file (no kicad_sch)");
130
+ const byRef = new Map(Object.entries(refdes).map(([i, r]) => [r, i]));
131
+ const out: Record<string, PartRecord> = {};
132
+ for (const s of children(sch, "symbol")) {
133
+ const ref = property(s, "Reference") ?? "";
134
+ if (!ref || ref.startsWith("#")) continue;
135
+ const f = fieldsOfNode(s);
136
+ out[instanceOf(f, ref, byRef)] = { refdes: ref, footprint: property(s, "Footprint") ?? "", fields: compared(f) };
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** `kicad-cli sch export netlist --format kicadsexpr`: the schematic's nets (and its parts). */
142
+ export function readKicadNetlist(netText: string, refdes: Record<string, string>): Netlist {
143
+ const doc = parse(netText);
144
+ const byRef = new Map(Object.entries(refdes).map(([i, r]) => [r, i]));
145
+ const refToInst = new Map<string, string>();
146
+ const parts: Record<string, PartRecord> = {};
147
+ for (const comps of children(doc, "components")) {
148
+ for (const c of children(comps, "comp")) {
149
+ const ref = text(child(c, "ref")?.items[1]) ?? "";
150
+ const f: Record<string, string> = {};
151
+ for (const p of children(c, "property")) {
152
+ const k = text(child(p, "name")?.items[1]);
153
+ const v = text(child(p, "value")?.items[1]);
154
+ if (k && v) f[k] = v;
155
+ }
156
+ const fieldsNode = child(c, "fields");
157
+ for (const fld of fieldsNode ? children(fieldsNode, "field") : []) {
158
+ const k = text(child(fld, "name")?.items[1]);
159
+ const v = fld.items.slice(2).find((x) => !isList(x));
160
+ if (k && v !== undefined && text(v)) f[k] = text(v)!;
161
+ }
162
+ const value = text(child(c, "value")?.items[1]);
163
+ if (value) f.Value = value;
164
+ const inst = instanceOf(f, ref, byRef);
165
+ refToInst.set(ref, inst);
166
+ parts[inst] = { refdes: ref, footprint: text(child(c, "footprint")?.items[1]) ?? "", fields: compared(f) };
167
+ }
168
+ }
169
+ const nets: Record<string, Set<string>> = {};
170
+ for (const ns of children(doc, "nets")) {
171
+ for (const n of children(ns, "net")) {
172
+ const name = text(child(n, "name")?.items[1]) ?? "";
173
+ if (!name || /^unconnected-/.test(name)) continue;
174
+ for (const node of children(n, "node")) {
175
+ const ref = text(child(node, "ref")?.items[1]) ?? "";
176
+ const pin = text(child(node, "pin")?.items[1]) ?? "";
177
+ if (!ref || ref.startsWith("#") || !pin) continue;
178
+ (nets[name] ??= new Set()).add(`${refToInst.get(ref) ?? byRef.get(ref) ?? `?${ref}`}.${pin}`);
179
+ }
180
+ }
181
+ }
182
+ return { parts, nets: sortNets(nets) };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Differences
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export type ChangeKind = "part_added" | "part_removed" | "footprint" | "field" | "net_renamed" | "connection";
190
+
191
+ export interface Change {
192
+ kind: ChangeKind;
193
+ /** Identity across the model and KiCad sides: the same change made on both has the same key. */
194
+ key: string;
195
+ instance?: string;
196
+ message: string;
197
+ }
198
+
199
+ const normNet = (s: string) => s.replace(/^\//, "").toLowerCase().replace(/[^a-z0-9]+/g, "_");
200
+ const instOfPin = (p: string) => p.slice(0, p.lastIndexOf("."));
201
+
202
+ /** pin → its net's name and its peers (pins sharing the net), over pins whose part is in `keep`; nets of one such pin dropped. */
203
+ function groups(n: Netlist, keep: Set<string>): Map<string, { net: string; peers: string[] }> {
204
+ const out = new Map<string, { net: string; peers: string[] }>();
205
+ for (const [name, pins] of Object.entries(n.nets)) {
206
+ const kept = pins.filter((p) => keep.has(instOfPin(p)));
207
+ if (kept.length < 2) continue;
208
+ for (const p of kept) {
209
+ const prev = out.get(p);
210
+ const peers = kept.filter((x) => x !== p);
211
+ // a pin on two nets (cannot happen in copper; a hand-edited file): keep the larger group
212
+ if (!prev || prev.peers.length < peers.length) out.set(p, { net: name, peers });
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+
218
+ /**
219
+ * Pins that changed nets from `a` to `b`. Nets are matched one to one by the
220
+ * pins they share (largest overlap first), so moving one pin reports that pin,
221
+ * not every pin of the two nets it touched. `to` identifies the destination
222
+ * the same way on any side: the pins of the matched net in `a`, or `new:` and
223
+ * the pins of a net `a` has no match for.
224
+ */
225
+ function movedPins(a: Map<string, { net: string; peers: string[] }>, b: Map<string, { net: string; peers: string[] }>): { pin: string; fromNet?: string; toNet?: string; to: string; with: string[] }[] {
226
+ const nets = (g: Map<string, { net: string; peers: string[] }>) => {
227
+ const m = new Map<string, Set<string>>();
228
+ for (const [p, { net }] of g) (m.get(net) ?? m.set(net, new Set()).get(net)!).add(p);
229
+ return m;
230
+ };
231
+ const [na, nb] = [nets(a), nets(b)];
232
+ const pairs: { x: string; y: string; n: number }[] = [];
233
+ for (const [y, pins] of nb) {
234
+ const count = new Map<string, number>();
235
+ for (const p of pins) {
236
+ const x = a.get(p)?.net;
237
+ if (x !== undefined) count.set(x, (count.get(x) ?? 0) + 1);
238
+ }
239
+ for (const [x, n] of count) pairs.push({ x, y, n });
240
+ }
241
+ pairs.sort((p, q) => q.n - p.n || p.x.localeCompare(q.x) || p.y.localeCompare(q.y));
242
+ const match = new Map<string, string>(); // b net → a net
243
+ const taken = new Set<string>();
244
+ for (const { x, y } of pairs) if (!match.has(y) && !taken.has(x)) (match.set(y, x), taken.add(x));
245
+ const out: { pin: string; fromNet?: string; toNet?: string; to: string; with: string[] }[] = [];
246
+ for (const p of [...new Set([...a.keys(), ...b.keys()])].sort()) {
247
+ const from = a.get(p)?.net;
248
+ const toNet = b.get(p)?.net;
249
+ const origin = toNet === undefined ? undefined : match.get(toNet);
250
+ if (from === origin && (from !== undefined || toNet === undefined)) continue;
251
+ const to = toNet === undefined ? "none" : origin !== undefined ? [...na.get(origin)!].sort().join("|") : `new:${[...nb.get(toNet)!].sort().join("|")}`;
252
+ out.push({ pin: p, fromNet: from, toNet, to, with: b.get(p)?.peers ?? [] });
253
+ }
254
+ return out;
255
+ }
256
+
257
+ const list3 = (xs: string[], n = 4) => `${xs.slice(0, n).join(", ")}${xs.length > n ? ` (+${xs.length - n})` : ""}`;
258
+
259
+ /** What changed from `base` to `now`: parts, footprints, fields, renamed nets and moved pins. */
260
+ export function diffNetlists(base: Netlist, now: Netlist): Change[] {
261
+ const out: Change[] = [];
262
+ const both = new Set(Object.keys(base.parts).filter((i) => now.parts[i]));
263
+ for (const i of Object.keys(now.parts).sort()) if (!base.parts[i]) out.push({ kind: "part_added", key: `part+:${i}`, instance: i, message: `${i} (${now.parts[i].refdes || "no designator"}) added` });
264
+ for (const i of Object.keys(base.parts).sort()) if (!now.parts[i]) out.push({ kind: "part_removed", key: `part-:${i}`, instance: i, message: `${i} (${base.parts[i].refdes}) removed` });
265
+ for (const i of [...both].sort()) {
266
+ const [a, b] = [base.parts[i], now.parts[i]];
267
+ if (a.footprint !== b.footprint) out.push({ kind: "footprint", key: `fp:${i}:${b.footprint}`, instance: i, message: `${i} (${b.refdes}) footprint ${a.footprint} → ${b.footprint}` });
268
+ for (const k of COMPARED_FIELDS) {
269
+ if ((a.fields[k] ?? "") !== (b.fields[k] ?? "")) out.push({ kind: "field", key: `field:${i}:${k}:${b.fields[k] ?? ""}`, instance: i, message: `${i} (${b.refdes}) ${k} "${a.fields[k] ?? ""}" → "${b.fields[k] ?? ""}"` });
270
+ }
271
+ }
272
+ // nets, over the parts on both sides
273
+ const ga = groups(base, both);
274
+ const gb = groups(now, both);
275
+ const pinSet = (n: Netlist, name: string) => (n.nets[name] ?? []).filter((p) => both.has(instOfPin(p))).join("|");
276
+ const renamedFrom = new Set<string>();
277
+ const baseByPins = new Map<string, string>();
278
+ for (const name of Object.keys(base.nets)) {
279
+ const k = pinSet(base, name);
280
+ if (k.includes("|")) baseByPins.set(k, name);
281
+ }
282
+ for (const name of Object.keys(now.nets).sort()) {
283
+ const k = pinSet(now, name);
284
+ const was = baseByPins.get(k);
285
+ if (was !== undefined && was !== name && normNet(was) !== normNet(name)) {
286
+ out.push({ kind: "net_renamed", key: `rename:${k}:${normNet(name)}`, message: `net ${was} renamed ${name} (${list3(k.split("|"))})` });
287
+ renamedFrom.add(was);
288
+ }
289
+ }
290
+ for (const m of movedPins(ga, gb)) {
291
+ out.push({ kind: "connection", key: `conn:${m.pin}:${m.to}`, instance: instOfPin(m.pin), message: `pin ${m.pin}: ${m.fromNet ?? "no net"} → ${m.toNet ?? "no net"}${m.toNet && m.with.length ? ` (with ${list3(m.with, 3)})` : ""}` });
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /** The parts a schematic and a board disagree on (missing on one side, other footprint). */
297
+ export function parityProblems(sch: Record<string, PartRecord>, pcb: Record<string, PartRecord>): string[] {
298
+ const out: string[] = [];
299
+ for (const i of Object.keys(sch).sort()) {
300
+ if (!pcb[i]) out.push(`${sch[i].refdes} (${i}) is in the schematic but not on the board`);
301
+ else if (sch[i].footprint && sch[i].footprint !== pcb[i].footprint) out.push(`${sch[i].refdes} (${i}): schematic footprint ${sch[i].footprint}, board ${pcb[i].footprint}`);
302
+ }
303
+ for (const i of Object.keys(pcb).sort()) if (!sch[i]) out.push(`${pcb[i].refdes} (${i}) is on the board but not in the schematic`);
304
+ return out;
305
+ }
306
+
307
+ /** Nets the schematic and the board group differently (names aside). */
308
+ export function netParity(sch: Netlist, pcb: Netlist): string[] {
309
+ const keep = new Set([...Object.keys(sch.parts), ...Object.keys(pcb.parts)]);
310
+ return movedPins(groups(sch, keep), groups(pcb, keep)).map((m) => `pin ${m.pin}: schematic ${m.fromNet ?? "no net"}, board ${m.toNet ?? "no net"}`);
311
+ }
@@ -0,0 +1,21 @@
1
+ // Tool probe for kicad-cli (KiCad 10), found as lib/kicad/cli.ts finds it:
2
+ // PROTOBOARD_KICAD_CLI, PATH, then KiCad's usual install places. Prints one
3
+ // line; exits 1 when there is none, or when it is older than KiCad 10.
4
+ const { spawnSync } = require("node:child_process");
5
+ const { existsSync } = require("node:fs");
6
+
7
+ const places = [process.env.PROTOBOARD_KICAD_CLI, "kicad-cli", "/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli", "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", "C:\\Program Files\\KiCad\\10.0\\bin\\kicad-cli.exe"].filter(Boolean);
8
+ for (const c of places) {
9
+ if (c !== "kicad-cli" && !existsSync(c)) continue;
10
+ const r = spawnSync(c, ["version"], { encoding: "utf8", timeout: 20000 });
11
+ const v = (r.stdout || "").trim();
12
+ if (r.status !== 0 || !/^\d+\./.test(v)) continue;
13
+ if (Number(v.split(".")[0]) < 10) {
14
+ console.error(`kicad-cli ${v} at ${c}: the KiCad handoff writes KiCad 10 files; install KiCad 10`);
15
+ process.exit(1);
16
+ }
17
+ console.log(`kicad-cli ${v} (${c})`);
18
+ process.exit(0);
19
+ }
20
+ console.error("kicad-cli not found: install KiCad 10, or set PROTOBOARD_KICAD_CLI to its kicad-cli");
21
+ process.exit(1);