@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
package/ops/kicad.ts ADDED
@@ -0,0 +1,636 @@
1
+ /**
2
+ * The KiCad handoff (docs/specs/pcb-kicad-handoff.md): the `circuit`
3
+ * capability's own operations kicad, lock and unlock, and what generate,
4
+ * export and check do on a locked board.
5
+ *
6
+ * circuit:kicad a KiCad project of the current build in tscircuit/kicad/ (preview)
7
+ * circuit:lock the KiCad project into <board dir>/kicad/ with handoff.json: KiCad
8
+ * becomes the layout of record
9
+ * circuit:unlock kicad/ moves to kicad-archive/<stamp>/; tscircuit is the layout again
10
+ *
11
+ * Locked: generate refuses without --force, export makes the fabrication
12
+ * outputs with kicad-cli from kicad/, and check compares the model, the
13
+ * record and the KiCad files (handoff.ts). Nothing is written back.
14
+ */
15
+ import { boardNetlist, boardOptions, NETLIST_SCHEMA, type BoardNetlist, type CapabilityContext, type CapabilityOperationFn, type CapabilityResult, type NetlistReport } from "@protoboardai/core";
16
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join, relative } from "node:path";
19
+ import { readConstraints, type BoardConstraints } from "../lib/config.ts";
20
+ import { boardLands } from "../lib/footprints.ts";
21
+ import { chooseBodies, applyBodies, boxStep } from "../lib/models.ts";
22
+ import { digestOf, files as nativeFiles, readNative, readOverrides, sourceDigest, type Native } from "../lib/native.ts";
23
+ import { boardManifest, bomCsv, renders, type PartEdgeFrame } from "../lib/outputs.ts";
24
+ import { projectOffers, supplierColumns, supplierNumbers } from "../lib/suppliers.ts";
25
+ import { packageVersion } from "../lib/runtime.ts";
26
+ import { shiftStepAssembly, withoutShapes } from "../lib/stepfile.ts";
27
+ import { kicadCli, kicadVersion, mustKicad, reframeKicadGlb, runKicad } from "../lib/kicad/cli.ts";
28
+ import { DRC_COMMAND, drcLine, runDrc, summariseDrc, type DrcReport } from "../lib/kicad/drc.ts";
29
+ import { componentFields, lcscNumbers, type ComponentFields } from "../lib/kicad/fields.ts";
30
+ import { compare, comparisonDiagnostics, handoffFile, HANDOFF_SCHEMA, kicadDir, readHandoff, type Handoff } from "../lib/kicad/handoff.ts";
31
+ import { modelNetlist, netParity, parityProblems, readBoardNetlist, readKicadNetlist, readSchematicParts, type Netlist } from "../lib/kicad/netlists.ts";
32
+ import { kicadProject, libraryName, stabiliseUuids } from "../lib/kicad/project.ts";
33
+ import { kicadPinType, type KicadPinType } from "../lib/kicad/schematic.ts";
34
+ import { assignRefdes, parseRefdes, prefixFromPart, refdesFile, type RefdesRecord } from "../lib/kicad/refdes.ts";
35
+
36
+ type Diagnostic = NonNullable<CapabilityResult["diagnostics"]>[number];
37
+
38
+ const PROVIDER_VERSION = (() => {
39
+ try {
40
+ return (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
41
+ } catch {
42
+ return "unknown";
43
+ }
44
+ })();
45
+
46
+ const netlistOf = (ctx: CapabilityContext) => boardNetlist(ctx.module.definition, ctx.project.lookup, boardOptions(ctx.project));
47
+ const constraintsOf = (ctx: CapabilityContext, netlist: BoardNetlist) => readConstraints(ctx.constraints, netlist.envelope).constraints;
48
+ const read = (root: string, path: string) => (existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : undefined);
49
+ const failed = (summary: string, data?: unknown): CapabilityResult => ({ status: "failed", summary, ...(data ? { data } : {}) });
50
+
51
+ /** Outline, holes, layers and thickness: what a KiCad layout must be redone for. */
52
+ export const boardShapeDigest = (c: BoardConstraints | undefined) => digestOf(JSON.stringify(c ? { outline: c.outline, holes: c.holes, layers: c.layers, thickness: c.thickness_mm } : null));
53
+
54
+ /** A part's conventional designator prefix (D, Q, Y, F, BT, LS) from its UHD definition. */
55
+ export const partPrefix = (ctx: CapabilityContext, netlist: BoardNetlist) => {
56
+ const parts = new Map(netlist.components.map((c) => [c.instance, c.part]));
57
+ return (c: { instance: string }) => prefixFromPart(ctx.project.lookup(parts.get(c.instance) ?? "") as never);
58
+ };
59
+
60
+ /** Designators for the board's components, extending tscircuit/refdes.json (written when it grew; generate, circuit:kicad and lock write it). */
61
+ type Write = (path: string, content: string | Buffer, description?: string) => void;
62
+
63
+ export function refdesFor(ctx: CapabilityContext, netlist: BoardNetlist, write: boolean | Write): RefdesRecord {
64
+ const file = refdesFile(ctx.module.native);
65
+ const before = parseRefdes(read(ctx.project.root, file));
66
+ const { record, added } = assignRefdes(netlist.components, before, partPrefix(ctx, netlist));
67
+ if (write && (!before || added.length)) (typeof write === "function" ? write : ctx.writeOutput)(file, JSON.stringify(record, null, 2) + "\n", "reference designators by UHD instance (kept between KiCad exports)");
68
+ return record;
69
+ }
70
+
71
+ const fieldsFor = (ctx: CapabilityContext, netlist: BoardNetlist): Record<string, ComponentFields> => {
72
+ const lcsc = lcscNumbers(ctx.project.root, projectOffers(ctx.project));
73
+ return Object.fromEntries(netlist.components.map((c) => [c.instance, componentFields(c, lcsc)]));
74
+ };
75
+
76
+ /** Each pin's KiCad electrical type, from its UHD leaf interface (power in/out, input, output, open collector, bidirectional, passive). */
77
+ const pinTypesFor = (ctx: CapabilityContext, netlist: BoardNetlist): Record<string, Record<string, KicadPinType>> =>
78
+ Object.fromEntries(
79
+ netlist.components.map((c) => {
80
+ const def = ctx.project.lookup(c.part);
81
+ return [c.instance, Object.fromEntries(c.pins.map((p) => [p.interface, kicadPinType(p.kind, def?.interfaces.find((i) => i.id === p.interface))]))];
82
+ }),
83
+ );
84
+
85
+ /** The board's model netlist for the comparisons, as an export of it would name everything. */
86
+ export function modelSide(ctx: CapabilityContext, netlist: BoardNetlist, native: Native | undefined, refdes: RefdesRecord): Netlist {
87
+ return modelNetlist({ netlist, map: native?.build.map, refdes: refdes.assigned, fields: fieldsFor(ctx, netlist), lib: libraryName(ctx.module.id), overrides: readOverrides(ctx.project.root, ctx.module.native).overrides });
88
+ }
89
+
90
+ interface Written {
91
+ /** Project-relative folder. */
92
+ folder: string;
93
+ files: Record<string, string>;
94
+ notes: string[];
95
+ kicad?: string;
96
+ }
97
+
98
+ /**
99
+ * Write the KiCad project of the current build into `folder`: the converter's
100
+ * files with the provider's edits (project.ts), saved by kicad-cli in KiCad 10's
101
+ * format when KiCad is installed, the part bodies under 3d/.
102
+ */
103
+ function writeProject(ctx: CapabilityContext, netlist: BoardNetlist, native: Native, folder: string, write: Write = ctx.writeOutput): Written {
104
+ const root = ctx.project.root;
105
+ const board = ctx.module.id;
106
+ const refdes = refdesFor(ctx, netlist, write);
107
+ // bodies as circuit:export chooses them: the part's STEP without its interface feature shapes, else a box
108
+ const bodies = chooseBodies(ctx.project, netlist.components, ctx.module.native);
109
+ for (const b of bodies) if (b.box && !existsSync(join(root, b.box.path))) ctx.writeOutput(b.box.path, boxStep(`box ${b.box.size.join(" x ")} mm`, b.box.size), "component body box from package facts");
110
+ for (const b of bodies) {
111
+ if (!b.step || !b.stripped || !b.strip?.length) continue;
112
+ if (!existsSync(join(root, b.stripped))) {
113
+ const { text, removed } = withoutShapes(readFileSync(b.step, "utf8"), b.strip);
114
+ if (removed.length) ctx.writeOutput(b.stripped, text, `${b.label} without its interface feature shapes`);
115
+ }
116
+ if (existsSync(join(root, b.stripped))) b.step = join(root, b.stripped);
117
+ }
118
+ // a body file that is not a STEP file (a Git LFS pointer never fetched) is left out, and said so
119
+ const notStep: string[] = [];
120
+ for (const b of bodies) {
121
+ if (!b.step || !existsSync(b.step)) continue;
122
+ const head = readFileSync(b.step).subarray(0, 64).toString("latin1");
123
+ if (!/ISO-10303-21/.test(head)) {
124
+ notStep.push(`${b.instance} (${b.label}${/git-lfs/.test(head) ? ": a Git LFS pointer, not fetched" : ""})`);
125
+ b.step = undefined;
126
+ }
127
+ }
128
+ const { circuitJson } = applyBodies(native.circuitJson, native.build.map, bodies, root);
129
+ const constraints = constraintsOf(ctx, netlist);
130
+ const oz = typeof ctx.constraints.copper_oz === "number" ? ctx.constraints.copper_oz : undefined;
131
+ const modelOk = (abs: string) => existsSync(abs) && /ISO-10303-21/.test(readFileSync(abs).subarray(0, 64).toString("latin1"));
132
+ const project = kicadProject({ board, circuitJson, map: native.build.map, netlist, refdes: refdes.assigned, fields: fieldsFor(ctx, netlist), pinTypes: pinTypesFor(ctx, netlist), constraints, copperOz: oz, modelBase: join(root, ctx.module.native), modelOk });
133
+ if (notStep.length) project.notes.push(`${notStep.length} body file(s) are not STEP files and have no 3D model in KiCad: ${notStep.slice(0, 4).join(", ")}${notStep.length > 4 ? ", …" : ""}`);
134
+
135
+ // KiCad 10 saves both files in its own format (and proves it can read them)
136
+ let kicad: string | undefined;
137
+ if (kicadCli()) {
138
+ kicad = kicadVersion();
139
+ const tmp = mkdtempSync(join(tmpdir(), "pb-kicad-"));
140
+ try {
141
+ for (const [p, t] of Object.entries(project.files)) {
142
+ mkdirSync(join(tmp, p, ".."), { recursive: true });
143
+ writeFileSync(join(tmp, p), t);
144
+ }
145
+ mustKicad(["pcb", "upgrade", "--force", join(tmp, `${board}.kicad_pcb`)]);
146
+ mustKicad(["sch", "upgrade", "--force", join(tmp, `${board}.kicad_sch`)]);
147
+ // KiCad gives a new UUID to every item it reads without one: number them again, so an unchanged board exports the same files
148
+ const names = [`${board}.kicad_sch`, `${board}.kicad_pcb`];
149
+ const [sch, pcb, pro] = stabiliseUuids(board, [...names.map((f) => readFileSync(join(tmp, f), "utf8")), project.files[`${board}.kicad_pro`]], project.keep, "k");
150
+ Object.assign(project.files, { [names[0]]: sch, [names[1]]: pcb, [`${board}.kicad_pro`]: pro });
151
+ } finally {
152
+ rmSync(tmp, { recursive: true, force: true });
153
+ }
154
+ } else project.notes.push("kicad-cli not found: the files are in circuit-json-to-kicad's format (KiCad 9), not saved by KiCad 10");
155
+
156
+ const files: Record<string, string> = {};
157
+ for (const [p, t] of Object.entries(project.files).sort(([a], [b]) => a.localeCompare(b))) {
158
+ write(`${folder}/${p}`, t, p.endsWith(".kicad_pcb") ? "KiCad board" : p.endsWith(".kicad_sch") ? "KiCad schematic" : undefined);
159
+ files[p] = digestOf(t);
160
+ }
161
+ for (const [p, src] of Object.entries(project.models).sort(([a], [b]) => a.localeCompare(b))) {
162
+ const bytes = readFileSync(src);
163
+ write(`${folder}/${p}`, bytes, "3D model");
164
+ files[p] = digestOf(bytes);
165
+ }
166
+ return { folder, files, notes: project.notes, kicad };
167
+ }
168
+
169
+ /** The KiCad side read from a project folder: the board's nets and parts, the schematic's parts and (with kicad-cli) nets. */
170
+ export function readKicadSide(root: string, folder: string, board: string, refdes: Record<string, string>): { pcb: Netlist; sch?: Netlist; schParts?: Record<string, import("../lib/kicad/netlists.ts").PartRecord>; problems: string[] } {
171
+ const problems: string[] = [];
172
+ const pcbText = read(root, `${folder}/${board}.kicad_pcb`);
173
+ if (!pcbText) throw new Error(`${folder}/${board}.kicad_pcb is missing`);
174
+ const pcb = readBoardNetlist(pcbText, refdes);
175
+ const schText = read(root, `${folder}/${board}.kicad_sch`);
176
+ let schParts: ReturnType<typeof readSchematicParts> | undefined;
177
+ let sch: Netlist | undefined;
178
+ if (!schText) problems.push(`${folder}/${board}.kicad_sch is missing`);
179
+ else {
180
+ schParts = readSchematicParts(schText, refdes);
181
+ if (kicadCli()) {
182
+ // on a copy: KiCad writes its local settings (.kicad_prl) beside a project it opens, and check writes nothing
183
+ const tmp = mkdtempSync(join(tmpdir(), "pb-kicad-net-"));
184
+ try {
185
+ for (const f of readdirSync(join(root, folder))) if (/\.(kicad_sch|kicad_pro|kicad_sym)$|^sym-lib-table$/.test(f)) cpSync(join(root, folder, f), join(tmp, f));
186
+ const out = join(tmp, "netlist.net");
187
+ const r = runKicad(["sch", "export", "netlist", "--format", "kicadsexpr", "--output", out, join(tmp, `${board}.kicad_sch`)]);
188
+ if (r.status === 0 && existsSync(out)) sch = readKicadNetlist(readFileSync(out, "utf8"), refdes);
189
+ else problems.push(`kicad-cli could not export the schematic's netlist: ${(r.stderr || r.stdout).trim().split("\n").pop()}`);
190
+ } finally {
191
+ rmSync(tmp, { recursive: true, force: true });
192
+ }
193
+ }
194
+ }
195
+ return { pcb, sch, schParts, problems };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Operations
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** circuit:kicad: a KiCad project of the current build, in tscircuit/kicad/ (rewritten each time). */
203
+ export const kicad: CapabilityOperationFn = async (ctx) => {
204
+ const netlist = netlistOf(ctx);
205
+ if (netlist.problems.length) return failed(`${ctx.module.id} cannot be read as a board: ${netlist.problems[0]}`);
206
+ const native = readNative(ctx.project.root, ctx.module.native);
207
+ if (!native) return failed(`no built board in ${ctx.module.native}/; run circuit:generate first`);
208
+ const w = writeProject(ctx, netlist, native, `${ctx.module.native}/kicad`);
209
+ const n = Object.keys(w.files).length;
210
+ return { status: "succeeded", summary: `KiCad project (${w.kicad ? `KiCad ${w.kicad} format` : "converter format"}) in ${w.folder}/: ${n} files. A preview: the layout of record stays tscircuit until circuit:lock${w.notes.length ? `; ${w.notes.join("; ")}` : ""}`, data: { folder: w.folder, files: Object.keys(w.files), notes: w.notes } };
211
+ };
212
+
213
+ /** circuit:lock: hand the board to KiCad (or, with refresh=true, re-record a locked board's handoff). */
214
+ export const lock: CapabilityOperationFn = async (ctx) => {
215
+ const root = ctx.project.root;
216
+ const dir = ctx.module.dir;
217
+ const board = ctx.module.id;
218
+ const netlist = netlistOf(ctx);
219
+ if (netlist.problems.length) return failed(`${board} cannot be read as a board: ${netlist.problems[0]}`);
220
+ const existing = readHandoff(root, dir);
221
+ const native = readNative(root, ctx.module.native);
222
+ const constraints = constraintsOf(ctx, netlist);
223
+ const now = new Date().toISOString();
224
+
225
+ if (existing?.state === "locked") {
226
+ if (ctx.inputs.refresh !== "true") return failed(`${board} is already locked (since ${existing.lockedAt}; ${handoffFile(dir)}). To record the model and KiCad as they are now, after updating KiCad by hand: --input refresh=true; to go back to tscircuit: circuit:unlock`);
227
+ const refdes = refdesFor(ctx, netlist, true);
228
+ const k = readKicadSide(root, kicadDir(dir), board, refdes.assigned);
229
+ const record: Handoff = { ...existing, refreshedAt: now, netlistDigest: digestOf(JSON.stringify(netlist)), boardShape: boardShapeDigest(constraints), model: modelSide(ctx, netlist, native, refdes), kicad: k.pcb };
230
+ ctx.writeOutput(handoffFile(dir), JSON.stringify(record, null, 2) + "\n", "KiCad handoff record (re-recorded)");
231
+ return { status: "succeeded", summary: `${board}: handoff re-recorded from the model and ${kicadDir(dir)}/ as they are now; the board stays locked` };
232
+ }
233
+ if (!kicadCli()) return failed("circuit:lock needs kicad-cli (KiCad 10): install KiCad 10, or set PROTOBOARD_KICAD_CLI");
234
+ if (!native) return failed(`no built board in ${ctx.module.native}/; run circuit:generate first`);
235
+ // lock what tscircuit has now: refuse a build that no longer follows the model or its source
236
+ const digest = digestOf(JSON.stringify(netlist));
237
+ if (native.build.netlistDigest !== digest) return failed(`the UHD board changed since the last circuit:generate; generate (and check) before locking`);
238
+ if (native.build.sourceDigest !== sourceDigest(root, ctx.module.native, native.build.sourceFiles)) return failed(`the tscircuit source changed since the last build; run circuit:export (it rebuilds) or circuit:generate before locking`);
239
+ if (existsSync(join(root, kicadDir(dir))) && readdirSync(join(root, kicadDir(dir))).length) return failed(`${kicadDir(dir)}/ already has files and no lock record; move them away (they may be hand work) before locking`);
240
+
241
+ // all or nothing: the project, the designators and the record are written to a staging folder first, read back
242
+ // there, and moved into place only when every step has succeeded; a lock that fails leaves the board unlocked,
243
+ // with no half-written kicad/ to move away before trying again
244
+ const stage = mkdtempSync(join(tmpdir(), "pb-kicad-lock-"));
245
+ const staged = new Map<string, { content: string | Buffer; description?: string }>();
246
+ const stageWrite: Write = (path, content, description) => {
247
+ mkdirSync(join(stage, path, ".."), { recursive: true });
248
+ writeFileSync(join(stage, path), content);
249
+ staged.set(path, { content, ...(description ? { description } : {}) });
250
+ };
251
+ let w: Written, refdes: RefdesRecord, model: Netlist, k: Netlist, side: ReturnType<typeof readKicadSide>, positions: Record<string, KicadPos>;
252
+ try {
253
+ w = writeProject(ctx, netlist, native, kicadDir(dir), stageWrite);
254
+ refdes = staged.has(refdesFile(ctx.module.native)) ? (parseRefdes(staged.get(refdesFile(ctx.module.native))!.content.toString()) ?? refdesFor(ctx, netlist, false)) : refdesFor(ctx, netlist, false);
255
+ model = modelSide(ctx, netlist, native, refdes);
256
+ // the KiCad side as written, read back: later differences from it are hand edits
257
+ k = readBoardNetlist(readFileSync(join(stage, w.folder, `${board}.kicad_pcb`), "utf8"), refdes.assigned);
258
+ // what KiCad's schematic and board already disagree on as exported (the converter's limits): not reported as new later
259
+ side = readKicadSide(stage, kicadDir(dir), board, refdes.assigned);
260
+ // where KiCad has each footprint now: export measures later moves from here (F26)
261
+ const byRef = new Map(Object.entries(refdes.assigned).map(([i, r]) => [r, i]));
262
+ positions = Object.fromEntries([...kicadPositions(join(stage, w.folder, `${board}.kicad_pcb`))].filter(([ref]) => byRef.has(ref)).map(([ref, p]): [string, KicadPos] => [byRef.get(ref)!, p]).sort(([a], [b]) => a.localeCompare(b)));
263
+ } catch (e) {
264
+ rmSync(stage, { recursive: true, force: true });
265
+ return failed(`${board} was not locked (nothing written to ${kicadDir(dir)}/): ${(e as Error).message.split("\n")[0]}`);
266
+ }
267
+ rmSync(stage, { recursive: true, force: true });
268
+ const { diffNetlists } = await import("../lib/kicad/netlists.ts");
269
+ const exportedWith = diffNetlists(model, k).map((c) => c.message);
270
+ const parityAtLock = [...(side.schParts ? parityProblems(side.schParts, side.pcb.parts) : []), ...(side.sch ? netParity(side.sch, side.pcb) : [])];
271
+ const record: Handoff = {
272
+ schema: HANDOFF_SCHEMA,
273
+ state: "locked",
274
+ board,
275
+ lockedAt: now,
276
+ netlistDigest: digest,
277
+ boardShape: boardShapeDigest(constraints),
278
+ model,
279
+ kicad: k,
280
+ ...(exportedWith.length ? { exportedWith } : {}),
281
+ ...(parityAtLock.length ? { parityAtLock } : {}),
282
+ positions,
283
+ tscircuit: { sourceDigest: native.build.sourceDigest, circuit: digestOf(readFileSync(join(root, nativeFiles(ctx.module.native).circuit))), core: native.build.tscircuit.core },
284
+ tools: { ...(w.kicad ? { kicad: w.kicad } : {}), converter: packageVersion("circuit-json-to-kicad"), provider: PROVIDER_VERSION },
285
+ files: w.files,
286
+ };
287
+ for (const [path, f] of staged) ctx.writeOutput(path, f.content, f.description);
288
+ ctx.writeOutput(handoffFile(dir), JSON.stringify(record, null, 2) + "\n", "KiCad handoff record: the board is locked");
289
+ return {
290
+ status: "succeeded",
291
+ summary: `${board} locked: KiCad ${w.kicad} project in ${kicadDir(dir)}/ (${Object.keys(record.model.parts).length} parts, ${Object.keys(record.kicad.nets).length} nets); KiCad is now the layout of record. circuit:generate refuses without --force; circuit:export makes the fabrication outputs with kicad-cli${w.notes.length ? `. ${w.notes.join("; ")}` : ""}${exportedWith.length ? `. The exported board differs from the model in ${exportedWith.length} place(s) (converter limits, recorded in handoff.json: ${exportedWith.slice(0, 2).join("; ")})` : ""}`,
292
+ data: { folder: w.folder, files: Object.keys(w.files), notes: w.notes, exportedWith },
293
+ };
294
+ };
295
+
296
+ /** circuit:unlock: kicad/ becomes kicad-archive/<stamp>/ (its record saying when), and tscircuit is the layout of record again. */
297
+ export const unlock: CapabilityOperationFn = async (ctx) => {
298
+ const root = ctx.project.root;
299
+ const dir = ctx.module.dir;
300
+ const h = readHandoff(root, dir);
301
+ if (h?.state !== "locked") return failed(`${ctx.module.id} is not locked (no ${handoffFile(dir)} with state "locked")`);
302
+ const now = new Date().toISOString();
303
+ const stamp = now.replace(/[:.]/g, "-");
304
+ const from = join(root, kicadDir(dir));
305
+ const to = `${dir}/kicad-archive/${stamp}`;
306
+ const walk = (d: string): string[] => readdirSync(d).flatMap((n) => (statSync(join(d, n)).isDirectory() ? walk(join(d, n)) : [join(d, n)]));
307
+ for (const f of walk(from).sort()) {
308
+ const rel = relative(from, f).split("\\").join("/");
309
+ if (rel === "handoff.json") continue;
310
+ ctx.writeOutput(`${to}/${rel}`, readFileSync(f), "archived KiCad file");
311
+ }
312
+ ctx.writeOutput(`${to}/handoff.json`, JSON.stringify({ ...h, state: "unlocked", unlockedAt: now }, null, 2) + "\n", "handoff record of the archived lock");
313
+ rmSync(from, { recursive: true, force: true });
314
+ ctx.log(`removed ${kicadDir(dir)}/ (archived in ${to}/)`);
315
+ return { status: "succeeded", summary: `${ctx.module.id} unlocked: ${kicadDir(dir)}/ archived in ${to}/ (hand work in it is kept there); tscircuit is the layout of record again. circuit:generate, then circuit:lock when the design is settled` };
316
+ };
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Hooks for generate, export and check (ops/circuit.ts)
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** generate on a locked board: refused unless --force. */
323
+ export function lockedGenerate(ctx: CapabilityContext): CapabilityResult | undefined {
324
+ const h = readHandoff(ctx.project.root, ctx.module.dir);
325
+ if (h?.state !== "locked") return undefined;
326
+ if (ctx.inputs.force === "true") {
327
+ ctx.log(`warning: ${ctx.module.id} is locked (KiCad is the layout of record since ${h.lockedAt}); generating anyway (--force). The KiCad files are not touched`);
328
+ return undefined;
329
+ }
330
+ return failed(`${ctx.module.id} is locked: KiCad (${kicadDir(ctx.module.dir)}/) is the layout of record since ${h.lockedAt}, and generate would replace tscircuit's routing. Change the layout in KiCad, or circuit:unlock first (a human decision), or pass --force to rebuild tscircuit's copy anyway`);
331
+ }
332
+
333
+ /** A footprint's place in the board frame, as KiCad's position file gives it (drill/place origin = the board's). */
334
+ export interface KicadPos {
335
+ x: number;
336
+ y: number;
337
+ rot: number;
338
+ side: "top" | "bottom";
339
+ }
340
+
341
+ /** KiCad's position file (`pcb export pos --format csv --units mm`), by reference. */
342
+ export function readPosCsv(text: string): Map<string, KicadPos> {
343
+ const rows = text.split(/\r?\n/).filter(Boolean);
344
+ const out = new Map<string, KicadPos>();
345
+ if (!rows.length) return out;
346
+ const head = splitCsv(rows[0]).map((h) => h.toLowerCase());
347
+ const col = (n: string) => head.indexOf(n);
348
+ for (const r of rows.slice(1)) {
349
+ const f = splitCsv(r);
350
+ out.set(f[col("ref")], { x: Number(f[col("posx")]), y: Number(f[col("posy")]), rot: (((Number(f[col("rot")]) % 360) + 360) % 360) + 0, side: /bottom/i.test(f[col("side")]) ? "bottom" : "top" });
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /** The footprints' positions on a KiCad board, by reference (kicad-cli). */
356
+ function kicadPositions(pcb: string): Map<string, KicadPos> {
357
+ const tmp = mkdtempSync(join(tmpdir(), "pb-kicad-pos-"));
358
+ try {
359
+ mustKicad(["pcb", "export", "pos", "--format", "csv", "--units", "mm", "--side", "both", "--use-drill-file-origin", "--output", join(tmp, "pos.csv"), pcb]);
360
+ return readPosCsv(readFileSync(join(tmp, "pos.csv"), "utf8"));
361
+ } finally {
362
+ rmSync(tmp, { recursive: true, force: true });
363
+ }
364
+ }
365
+
366
+ /**
367
+ * How a component moved in KiCad since the lock (PB-1027, F26): against the
368
+ * position KiCad had at lock when the record has it (the footprint origin,
369
+ * which can differ from tscircuit's body origin by the pads' centre), else
370
+ * against the tscircuit build's. Undefined when it did not move or turn.
371
+ */
372
+ export function movedSinceLock(now: KicadPos, atLock: KicadPos | undefined, built: { origin: number[]; rotation_deg: number }): { from: [number, number]; to: [number, number]; turn: number } | undefined {
373
+ const from: [number, number] = atLock ? [atLock.x, atLock.y] : [built.origin[0], built.origin[1]];
374
+ const turn = ((((now.rot - (atLock ? atLock.rot : built.rotation_deg)) % 360) + 540) % 360) - 180;
375
+ if (Math.hypot(now.x - from[0], now.y - from[1]) < 1e-3 && Math.abs(turn) < 0.1) return undefined;
376
+ return { from, to: [now.x, now.y], turn };
377
+ }
378
+
379
+ const PNP_HEADER = ["instance", "x_mm", "y_mm", "rotation_deg", "side", "part", "mpn", "package", "lcsc"];
380
+ const csvField = (v: unknown) => {
381
+ const s = v === undefined || v === null ? "" : String(v);
382
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
383
+ };
384
+
385
+ /** Split one CSV line (KiCad's position file quotes every field). */
386
+ const splitCsv = (line: string): string[] => {
387
+ const out: string[] = [];
388
+ let cur = "";
389
+ let q = false;
390
+ for (let i = 0; i < line.length; i++) {
391
+ const c = line[i];
392
+ if (q) {
393
+ if (c === '"' && line[i + 1] === '"') (cur += '"'), i++;
394
+ else if (c === '"') q = false;
395
+ else cur += c;
396
+ } else if (c === '"') q = true;
397
+ else if (c === ",") out.push(cur), (cur = "");
398
+ else cur += c;
399
+ }
400
+ out.push(cur);
401
+ return out;
402
+ };
403
+
404
+ /** export on a locked board: the fabrication outputs from KiCad (spec §5). */
405
+ export async function exportLocked(ctx: CapabilityContext): Promise<CapabilityResult> {
406
+ const root = ctx.project.root;
407
+ const board = ctx.module.id;
408
+ const out = ctx.module.out;
409
+ const folder = kicadDir(ctx.module.dir);
410
+ if (!kicadCli()) return failed(`${board} is locked and its fabrication outputs come from KiCad, but kicad-cli (KiCad 10) is not installed; install KiCad 10, or set PROTOBOARD_KICAD_CLI`);
411
+ if (!existsSync(join(root, folder, `${board}.kicad_pcb`))) return failed(`${board} is locked but ${folder}/${board}.kicad_pcb is missing`);
412
+ const netlist = netlistOf(ctx);
413
+ const native = readNative(root, ctx.module.native);
414
+ const refdes = refdesFor(ctx, netlist, false);
415
+ const byRef = new Map(Object.entries(refdes.assigned).map(([i, r]) => [r, i]));
416
+ const handoff = readHandoff(root, ctx.module.dir);
417
+ // the supplier numbers the unlocked export gives too (one offer reader: library offers and the project's evidence)
418
+ const suppliers = supplierNumbers(root, netlist.components, projectOffers(ctx.project));
419
+ const tmp = mkdtempSync(join(tmpdir(), "pb-kicad-fab-"));
420
+ const notes: string[] = [];
421
+ // kicad-cli works on a copy: it writes its local settings beside the project, and kicad/ is the user's
422
+ cpSync(join(root, folder), join(tmp, "project"), { recursive: true });
423
+ const pcb = join(tmp, "project", `${board}.kicad_pcb`);
424
+ const sch = join(tmp, "project", `${board}.kicad_sch`);
425
+ try {
426
+ // ---- DRC first: an error stops the export unless --force
427
+ // one runner and one count for export and check (lib/kicad/drc.ts; F24, F25)
428
+ const { report: drcReport, raw } = runDrc(join(root, folder), board);
429
+ const sum = summariseDrc(drcReport);
430
+ const { unconnected, parity, first } = sum;
431
+ const drcSummary = drcLine(sum);
432
+ if ((sum.errors || unconnected) && ctx.inputs.force !== "true") {
433
+ return failed(
434
+ `${board} is locked and KiCad's DRC stops the export: ${drcSummary}${first.length ? ` (first: ${first.join("; ")})` : ""}. ` +
435
+ `To fix: open ${folder}/${board}.kicad_pcb in KiCad, run Inspect > Design Rules Checker, correct the layout (the kicad-layout-review skill walks through it), save, and run \`protoboard run circuit:export --module ${ctx.module.id}\` again. ` +
436
+ `To export as it is (a prototype you accept with these findings): \`protoboard run circuit:export --module ${ctx.module.id} --force\`; out/drc.json then records them`,
437
+ { drc: { errors: sum.errors, unconnected, parity, first } },
438
+ );
439
+ }
440
+ ctx.writeOutput(`${out}/drc.json`, raw, `KiCad DRC report of the locked board (${DRC_COMMAND})`);
441
+
442
+ // ---- Gerber and drill
443
+ const copper = readFileSync(pcb, "utf8").match(/"In\d+\.Cu"/g) ? ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"] : ["F.Cu", "B.Cu"];
444
+ const layers = [...copper, "F.Paste", "B.Paste", "F.SilkS", "B.SilkS", "F.Mask", "B.Mask", "Edge.Cuts"].join(",");
445
+ mustKicad(["pcb", "export", "gerbers", "--no-protel-ext", "--layers", layers, "--output", join(tmp, "gerber") + "/", pcb]);
446
+ mustKicad(["pcb", "export", "drill", "--format", "excellon", "--excellon-units", "mm", "--excellon-separate-th", "--output", join(tmp, "gerber") + "/", pcb]);
447
+ for (const f of readdirSync(join(tmp, "gerber")).sort()) {
448
+ if (f.endsWith(".gbrjob")) continue;
449
+ // KiCad dates every file; drop the date so an unchanged board exports identical files
450
+ const t = readFileSync(join(tmp, "gerber", f), "utf8").replace(/^(G04 Created by KiCad.*date|%TF\.CreationDate,|; DRILL file .* date|; #@! TF\.CreationDate,).*$/gm, "");
451
+ // a drill file with no tool (no unplated holes on the board): KiCad writes its header anyway; there is nothing to drill
452
+ if (/\.drl$/i.test(f) && !/^T\d+C/m.test(t)) {
453
+ notes.push(`${f} left out: KiCad wrote it with no hole to drill`);
454
+ continue;
455
+ }
456
+ ctx.writeOutput(`${out}/gerber/${f}`, t, "from KiCad");
457
+ }
458
+
459
+ // ---- pick-and-place, keyed by UHD instance, in the board frame (KiCad's place origin is the board's)
460
+ mustKicad(["pcb", "export", "pos", "--format", "csv", "--units", "mm", "--side", "both", "--use-drill-file-origin", "--output", join(tmp, "pos.csv"), pcb]);
461
+ const kicadPos = new Map<string, KicadPos>();
462
+ const pnp = [PNP_HEADER.join(",")];
463
+ for (const [ref, pos] of readPosCsv(readFileSync(join(tmp, "pos.csv"), "utf8"))) {
464
+ const inst = byRef.get(ref) ?? ref;
465
+ const c = netlist.components.find((x) => x.instance === inst);
466
+ kicadPos.set(inst, pos);
467
+ pnp.push([inst, pos.x, pos.y, pos.rot, pos.side, c?.part, c?.mpn, c?.package?.name, supplierColumns(suppliers[inst]).lcsc].map(csvField).join(","));
468
+ }
469
+ ctx.writeOutput(`${out}/pnp.csv`, pnp.join("\n") + "\n", "pick-and-place from KiCad (footprint origins), by UHD instance");
470
+
471
+ // ---- bodies in the stack's board frame (z = 0 on the mid-plane, as tscircuit's), renders
472
+ // KiCad puts z = 0 on the board's bottom face: both files are moved down by half the board's thickness (KiCad's own)
473
+ const thickness = Number(/\(general[\s\S]*?\(thickness\s+([\d.]+)\)/.exec(readFileSync(pcb, "utf8"))?.[1] ?? constraintsOf(ctx, netlist)?.thickness_mm ?? 1.6);
474
+ mustKicad(["pcb", "export", "step", "--force", "--drill-origin", "--subst-models", "--output", join(tmp, "board.step"), pcb]);
475
+ const stepped = shiftStepAssembly(readFileSync(join(tmp, "board.step"), "utf8"), -thickness / 2);
476
+ if (!stepped.moved) notes.push("board.step: KiCad's assembly root was not found, so the file is in KiCad's frame (z = 0 on the board's bottom face)");
477
+ ctx.writeOutput(`${out}/board.step`, stepped.text.replace(/^(FILE_NAME\('[^']*',)'[^']*'/m, "$1'1970-01-01T00:00:00'"), "board with component bodies, from KiCad (board frame: z = 0 on the mid-plane)");
478
+ mustKicad(["pcb", "export", "glb", "--force", "--drill-origin", "--subst-models", "--output", join(tmp, "board.glb"), pcb]);
479
+ ctx.writeOutput(`${out}/board.glb`, reframeKicadGlb(readFileSync(join(tmp, "board.glb")), thickness, (ref) => byRef.get(ref)), "board with component bodies, from KiCad (board frame: z = 0 on the mid-plane)");
480
+ for (const [name, ls, mirror] of [["pcb-top.svg", "F.Cu,F.SilkS,F.Mask,Edge.Cuts", false], ["pcb-bottom.svg", "B.Cu,B.SilkS,B.Mask,Edge.Cuts", true]] as const) {
481
+ mustKicad(["pcb", "export", "svg", "--mode-single", "--exclude-drawing-sheet", "--page-size-mode", "2", "--layers", ls, ...(mirror ? ["--mirror"] : []), "--output", join(tmp, name), pcb]);
482
+ ctx.writeOutput(`${out}/${name}`, readFileSync(join(tmp, name)), "render, from KiCad");
483
+ }
484
+ if (existsSync(sch)) {
485
+ mustKicad(["sch", "export", "svg", "--exclude-drawing-sheet", "--output", join(tmp, "sch") + "/", sch]);
486
+ const svg = readdirSync(join(tmp, "sch")).find((f) => f.endsWith(".svg"));
487
+ if (svg) ctx.writeOutput(`${out}/schematic.svg`, readFileSync(join(tmp, "sch", svg)), "schematic, from KiCad");
488
+ } else if (native) for (const [name, svg] of Object.entries(renders(native.circuitJson))) if (name === "schematic.svg") ctx.writeOutput(`${out}/${name}`, svg, "render (tscircuit)");
489
+
490
+ // ---- netlist as laid out: the KiCad board's copper nets, by UHD instance and interface
491
+ const k = readBoardNetlist(readFileSync(pcb, "utf8"), refdes.assigned);
492
+ const ifaceOf = new Map<string, string>();
493
+ for (const m of Object.values(native?.build.map.components ?? {})) for (const [port, iface] of Object.entries(m.ports)) if (iface) ifaceOf.set(`${m.instance}.${port.replace(/^pin/, "")}`, iface);
494
+ const report: NetlistReport = {
495
+ schema: NETLIST_SCHEMA,
496
+ nets: Object.entries(k.nets).map(([id, pins]) => ({ id, pins: [...new Set(pins.map((p) => `${p.slice(0, p.lastIndexOf("."))}:${ifaceOf.get(p) ?? `pad_${p.slice(p.lastIndexOf(".") + 1)}`}`))].sort() })),
497
+ };
498
+ ctx.writeOutput(`${out}/netlist.json`, JSON.stringify(report, null, 2) + "\n", "nets as laid out in KiCad");
499
+ ctx.writeOutput(`${out}/bom.csv`, bomCsv(netlist, suppliers), "bill of materials by UHD instance");
500
+
501
+ // ---- manifest: the locked build's, components moved to where KiCad has them
502
+ if (native) {
503
+ const constraints = constraintsOf(ctx, netlist);
504
+ const { manifest, geometry } = boardManifest(native.circuitJson, native.build.map, netlist, constraints, native.build.sourceDigest, edgeFrames(ctx, netlist));
505
+ const features = manifest.features as Record<string, { centroid: number[]; frame: { origin: number[] } }>;
506
+ let moved = 0;
507
+ let turned = 0;
508
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4 + 0;
509
+ for (const g of geometry.components) {
510
+ const p = kicadPos.get(g.instance);
511
+ if (!p) continue;
512
+ // the component's feature and the features of the board exports it carries, carried from where tscircuit
513
+ // put the part to where KiCad has it: moved, and turned about its origin by the difference in rotation
514
+ const own = [features[g.instance], ...netlist.edge.filter((e) => e.instance === g.instance).map((e) => features[e.id])].filter(Boolean) as { centroid: number[]; normal?: number[]; frame: { origin: number[]; normal?: number[]; xAxis?: number[] } }[];
515
+ if (!own.length) continue;
516
+ // measured from where KiCad had it at lock (F26: a footprint origin is not the body origin, so a part that did
517
+ // not move must not read as moved); a record from before 0.2.0 has no positions: tscircuit's then
518
+ const move = movedSinceLock(p, handoff?.positions?.[g.instance], g);
519
+ if (!move) continue;
520
+ const [dx, dy] = [move.to[0] - move.from[0], move.to[1] - move.from[1]];
521
+ const turn = move.turn;
522
+ if (p.side !== g.side) notes.push(`${g.instance} moved to the ${p.side} side in KiCad: its manifest feature keeps tscircuit's side`);
523
+ const a = (turn * Math.PI) / 180;
524
+ const rot = (x: number, y: number) => [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
525
+ const point = (v: number[]) => {
526
+ const [x, y] = rot(v[0] - move.from[0], v[1] - move.from[1]);
527
+ [v[0], v[1]] = [r4(p.x + x), r4(p.y + y)];
528
+ };
529
+ const vector = (v?: number[]) => {
530
+ if (!v) return;
531
+ const [x, y] = rot(v[0], v[1]);
532
+ [v[0], v[1]] = [r4(x), r4(y)];
533
+ };
534
+ const seen = new Set<unknown>();
535
+ for (const f of own) {
536
+ for (const v of [f.centroid, f.frame.origin]) if (!seen.has(v)) (seen.add(v), point(v));
537
+ for (const v of [f.normal, f.frame.normal, f.frame.xAxis]) if (v && !seen.has(v)) (seen.add(v), vector(v));
538
+ }
539
+ if (Math.hypot(dx, dy) >= 1e-3) moved++;
540
+ if (Math.abs(turn) >= 0.1) turned++;
541
+ }
542
+ ctx.writeOutput(`${out}/board.manifest.json`, JSON.stringify({ ...manifest, tool: "kicad", toolVersion: kicadVersion(), frame: manifest.frame }, null, 2) + "\n", "geometry manifest (tscircuit build at lock, positions from KiCad)");
543
+ if (moved) notes.push(`manifest positions of ${moved} component(s) taken from KiCad (their footprint origin differs from the locked tscircuit build)`);
544
+ if (turned) notes.push(`${turned} component(s) turned in KiCad: their manifest features carry KiCad's orientation`);
545
+ }
546
+ return { status: "succeeded", summary: `${board} is locked: fabrication outputs from KiCad ${kicadVersion()} (${folder}/): Gerber + drill, pick-and-place, STEP, GLB, renders, netlist; BOM from the model. ${drcSummary}${sum.errors || unconnected ? " (exported with --force)" : ""}${notes.length ? `; ${notes.join("; ")}` : ""}`, data: { locked: true, drc: { errors: sum.errors, warnings: sum.warnings, unconnected, parity }, notes } };
547
+ } finally {
548
+ rmSync(tmp, { recursive: true, force: true });
549
+ }
550
+ }
551
+
552
+ /** The mating frame each board export's part states (as ops/circuit.ts gives the manifest). */
553
+ function edgeFrames(ctx: CapabilityContext, netlist: BoardNetlist): Record<string, PartEdgeFrame> {
554
+ const out: Record<string, PartEdgeFrame> = {};
555
+ for (const e of netlist.edge) {
556
+ const comp = netlist.components.find((c) => c.instance === e.instance);
557
+ const frame = ctx.project.lookup(comp?.part ?? "")?.interfaces.find((i) => i.id === e.interface)?.geometry?.frame;
558
+ if (!comp || !frame) continue;
559
+ const mount = comp.bodies.find((b) => b.mount)?.mount?.origin;
560
+ out[e.id] = { frame, ...(mount ? { mount: [mount[0], mount[1], mount[2]] as [number, number, number] } : {}) };
561
+ }
562
+ return out;
563
+ }
564
+
565
+ /**
566
+ * CIRCUIT_KICAD_DRC: KiCad's DRC of the locked board, run with kicad-cli when
567
+ * it is installed (cached by the files' digest), else read from the last
568
+ * export's out/drc.json, said to be older than the board when it is. Errors
569
+ * or unconnected items are an error; warnings alone a warning; a clean board
570
+ * says nothing.
571
+ */
572
+ export function kicadDrcFinding(root: string, folder: string, board: string, out: string, module: string): { severity: "error" | "warning" | "info"; message: string } | undefined {
573
+ const fix = `open ${folder}/${board}.kicad_pcb in KiCad, run Inspect > Design Rules Checker and correct the layout (the kicad-layout-review skill walks through it)`;
574
+ let report: DrcReport | undefined;
575
+ let from: string;
576
+ if (kicadCli()) {
577
+ try {
578
+ report = runDrc(join(root, folder), board, { cache: true }).report;
579
+ from = `run now (${DRC_COMMAND})`;
580
+ } catch (e) {
581
+ return { severity: "warning", message: `KiCad's DRC of ${board} did not run: ${(e as Error).message.split("\n")[0]}` };
582
+ }
583
+ } else if (existsSync(join(root, out, "drc.json"))) {
584
+ try {
585
+ report = JSON.parse(readFileSync(join(root, out, "drc.json"), "utf8")) as DrcReport;
586
+ } catch {
587
+ return { severity: "warning", message: `${out}/drc.json cannot be read, and kicad-cli is not installed to run KiCad's DRC of ${board}` };
588
+ }
589
+ const stale = statSync(join(root, folder, `${board}.kicad_pcb`)).mtimeMs > statSync(join(root, out, "drc.json")).mtimeMs;
590
+ from = `from ${out}/drc.json, the last circuit:export${stale ? "; the board changed since, so it may be out of date (install KiCad 10 for check to run it)" : ""}`;
591
+ } else return { severity: "info", message: `KiCad's DRC of ${board} was not run: kicad-cli is not installed and there is no ${out}/drc.json (circuit:export writes one)` };
592
+ const s = summariseDrc(report);
593
+ if (!s.errors && !s.unconnected && !s.warnings) return undefined;
594
+ const bad = s.errors > 0 || s.unconnected > 0;
595
+ return {
596
+ severity: bad ? "error" : "warning",
597
+ message: `${drcLine(s)}, ${from}${s.first.length ? `. First: ${s.first.join("; ")}` : ""}. ${bad ? `To fix: ${fix}; circuit:export stops on these unless --force (\`protoboard run circuit:export --module ${module}\`)` : "Warnings only; review them in KiCad"}`,
598
+ };
599
+ }
600
+
601
+ /** check on a locked board (spec §4): model, record and KiCad compared, and the land patterns' verification. Pure. */
602
+ export function checkLocked(ctx: CapabilityContext, netlist: BoardNetlist): Diagnostic[] | undefined {
603
+ const root = ctx.project.root;
604
+ const h = readHandoff(root, ctx.module.dir);
605
+ if (h?.state !== "locked") return undefined;
606
+ const board = ctx.module.id;
607
+ const folder = kicadDir(ctx.module.dir);
608
+ const out: Diagnostic[] = [];
609
+ const add = (severity: Diagnostic["severity"], code: string, message: string, file = handoffFile(ctx.module.dir)) => out.push({ code, severity, message, entity: { kind: "definition", id: ctx.module.id }, source: { file } });
610
+ add("info", "CIRCUIT_LOCKED", `${board} is locked: KiCad (${folder}/) is the layout of record since ${h.lockedAt}${h.refreshedAt ? ` (re-recorded ${h.refreshedAt})` : ""}; tscircuit's build is not checked`);
611
+ for (const l of boardLands(netlist.components, readOverrides(root, ctx.module.native).overrides)) {
612
+ if (!l.problems.length) continue;
613
+ add("warning", "CIRCUIT_FOOTPRINT_UNVERIFIED", `land pattern ${l.entry.id} for ${l.instances.slice(0, 6).join(", ")} is not verified: ${l.problems[0]}`, ctx.module.native);
614
+ }
615
+ // designators as recorded (check writes nothing: new instances get theirs in memory)
616
+ const refdes = assignRefdes(netlist.components, parseRefdes(read(root, refdesFile(ctx.module.native))), partPrefix(ctx, netlist)).record;
617
+ const model = modelSide(ctx, netlist, readNative(root, ctx.module.native), refdes);
618
+ let k: ReturnType<typeof readKicadSide>;
619
+ try {
620
+ k = readKicadSide(root, folder, board, refdes.assigned);
621
+ } catch (e) {
622
+ add("error", "CIRCUIT_KICAD_UNREADABLE", `${(e as Error).message}; the locked board's KiCad files cannot be read`, folder);
623
+ return out;
624
+ }
625
+ for (const p of k.problems) add("warning", "CIRCUIT_KICAD_UNREADABLE", p, folder);
626
+ const shapeChanged = h.boardShape !== boardShapeDigest(constraintsOf(ctx, netlist));
627
+ out.push(...comparisonDiagnostics(compare(h, model, k.pcb), { board, file: `${folder}/${board}.kicad_pcb`, shapeChanged, module: ctx.module.id }));
628
+ // the schematic against the board (KiCad's own parity, without running DRC)
629
+ const known = new Set(h.parityAtLock ?? []);
630
+ const parity = [...(k.schParts ? parityProblems(k.schParts, k.pcb.parts) : []), ...(k.sch ? netParity(k.sch, k.pcb) : [])].filter((p) => !known.has(p));
631
+ // KiCad's own DRC (PB-1027, F24): a forced export does not pass check unnoticed
632
+ const drc = kicadDrcFinding(root, folder, board, ctx.module.out, ctx.module.id);
633
+ if (drc) add(drc.severity, "CIRCUIT_KICAD_DRC", drc.message, `${folder}/${board}.kicad_pcb`);
634
+ if (parity.length) add("warning", "CIRCUIT_KICAD_PARITY", `the KiCad schematic and board disagree (${parity.length}): ${parity.slice(0, 4).join("; ")}${parity.length > 4 ? "; …" : ""}. Update PCB from Schematic in KiCad, or fix the schematic`, `${folder}/${board}.kicad_sch`);
635
+ return out;
636
+ }