copperhead 0.6.0 → 0.8.0

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 (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Parser and assertion compiler for the opt-in SPICE verification gate.
3
+ *
4
+ * This module is deliberately pure: it reads strings and produces data or
5
+ * ngspice deck fragments. Process execution belongs to the wrapper in task 1.1.
6
+ */
7
+
8
+ export type SpiceAnalysis = 'op' | 'dc' | 'ac' | 'tran';
9
+
10
+ export type SimulationScope =
11
+ | { kind: 'sheet'; sheet: string }
12
+ | { kind: 'nets'; nets: string[] };
13
+
14
+ export interface SpiceNumber {
15
+ raw: string;
16
+ value: number;
17
+ unit: string;
18
+ }
19
+
20
+ export type SpiceMeasurable =
21
+ | { kind: 'voltage'; target: string }
22
+ | { kind: 'current'; target: string }
23
+ | { kind: 'corner'; target: string };
24
+
25
+ export type SpiceComparator =
26
+ | { kind: 'between'; lower: SpiceNumber; upper: SpiceNumber }
27
+ | { kind: 'less-than'; bound: SpiceNumber }
28
+ | { kind: 'greater-than'; bound: SpiceNumber };
29
+
30
+ export interface SimulationAssertion {
31
+ raw: string;
32
+ line: number;
33
+ measurable: SpiceMeasurable;
34
+ comparator: SpiceComparator;
35
+ }
36
+
37
+ export interface SimulationSource {
38
+ port: string;
39
+ value: SpiceNumber;
40
+ line: number;
41
+ }
42
+
43
+ export interface SimulationBlock {
44
+ line: number;
45
+ scope: SimulationScope;
46
+ analysis: SpiceAnalysis;
47
+ sources: SimulationSource[];
48
+ assertions: SimulationAssertion[];
49
+ }
50
+
51
+ export interface CompiledSpiceAssertion {
52
+ valueName: string;
53
+ checks: Array<{
54
+ name: string;
55
+ pass: 'nonnegative' | 'positive';
56
+ }>;
57
+ lines: string[];
58
+ deck: string;
59
+ }
60
+
61
+ export class SimulationParseError extends Error {
62
+ readonly line: number;
63
+
64
+ constructor(line: number, message: string) {
65
+ super(`line ${line}: ${message}`);
66
+ this.name = 'SimulationParseError';
67
+ this.line = line;
68
+ }
69
+ }
70
+
71
+ const ANALYSES = new Set<SpiceAnalysis>(['op', 'dc', 'ac', 'tran']);
72
+ const SAFE_TARGET = /^[A-Za-z0-9_./:+-]+$/;
73
+ const SAFE_MEASURE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
74
+
75
+ const SI_MULTIPLIERS: Readonly<Record<string, number>> = {
76
+ '': 1,
77
+ t: 1e12,
78
+ g: 1e9,
79
+ meg: 1e6,
80
+ k: 1e3,
81
+ m: 1e-3,
82
+ u: 1e-6,
83
+ n: 1e-9,
84
+ p: 1e-12,
85
+ f: 1e-15,
86
+ };
87
+
88
+ /** Parse a SPICE-style number while preserving the original safe literal. */
89
+ export function parseSpiceNumber(raw: string, line = 1): SpiceNumber {
90
+ const token = raw.trim();
91
+ const match =
92
+ /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(meg|[tgkmunpf])?([a-z]*)$/i.exec(
93
+ token,
94
+ );
95
+ if (!match) {
96
+ throw new SimulationParseError(line, `invalid SPICE number "${raw}"`);
97
+ }
98
+
99
+ const base = Number(match[1]);
100
+ const suffix = (match[2] ?? '').toLowerCase();
101
+ // The regex limits suffixes to keys in SI_MULTIPLIERS.
102
+ const multiplier = SI_MULTIPLIERS[suffix]!;
103
+
104
+ const value = base * multiplier;
105
+ if (!Number.isFinite(value)) {
106
+ throw new SimulationParseError(line, `SPICE number is out of range "${raw}"`);
107
+ }
108
+
109
+ return {
110
+ raw: token,
111
+ value,
112
+ unit: match[3] ?? '',
113
+ };
114
+ }
115
+
116
+ /** Parse one assertion from the closed grammar in the SPICE delta spec. */
117
+ export function parseSpiceAssertion(raw: string, line = 1): SimulationAssertion {
118
+ const text = raw.trim();
119
+ const measurableMatch = /^(V|I|corner)\(([^()\s]+)\)\s+(.+)$/i.exec(text);
120
+ if (!measurableMatch) {
121
+ throw new SimulationParseError(line, `invalid SPICE assertion "${raw}"`);
122
+ }
123
+
124
+ const target = measurableMatch[2]!;
125
+ if (!SAFE_TARGET.test(target)) {
126
+ throw new SimulationParseError(line, `invalid measurable target "${target}"`);
127
+ }
128
+
129
+ const measurableToken = measurableMatch[1]!.toLowerCase();
130
+ const measurable: SpiceMeasurable =
131
+ measurableToken === 'v'
132
+ ? { kind: 'voltage', target }
133
+ : measurableToken === 'i'
134
+ ? { kind: 'current', target }
135
+ : { kind: 'corner', target };
136
+
137
+ const comparatorText = measurableMatch[3]!.trim();
138
+ const betweenMatch = /^between\s+(\S+)\s+and\s+(\S+)$/i.exec(comparatorText);
139
+ let comparator: SpiceComparator;
140
+
141
+ if (betweenMatch) {
142
+ const lower = parseSpiceNumber(betweenMatch[1]!, line);
143
+ const upper = parseSpiceNumber(betweenMatch[2]!, line);
144
+ if (lower.unit.toLowerCase() !== upper.unit.toLowerCase()) {
145
+ throw new SimulationParseError(line, 'between bounds use different units');
146
+ }
147
+ if (lower.value >= upper.value) {
148
+ throw new SimulationParseError(line, 'between lower bound must be less than upper bound');
149
+ }
150
+ comparator = { kind: 'between', lower, upper };
151
+ } else {
152
+ const singleMatch = /^([<>])\s*(\S+)$/.exec(comparatorText);
153
+ if (!singleMatch) {
154
+ throw new SimulationParseError(line, `invalid comparator "${comparatorText}"`);
155
+ }
156
+ const bound = parseSpiceNumber(singleMatch[2]!, line);
157
+ comparator =
158
+ singleMatch[1] === '<'
159
+ ? { kind: 'less-than', bound }
160
+ : { kind: 'greater-than', bound };
161
+ }
162
+
163
+ return { raw: text, line, measurable, comparator };
164
+ }
165
+
166
+ /**
167
+ * Parse every `## Simulation` section from SUBSYSTEMS.md.
168
+ *
169
+ * Fields are line-oriented so errors can always identify the source line.
170
+ */
171
+ export function parseSimulationBlocks(markdown: string): SimulationBlock[] {
172
+ const lines = markdown.split(/\r?\n/);
173
+ const blocks: SimulationBlock[] = [];
174
+ let openFence: string | null = null;
175
+ let inDocumentComment = false;
176
+
177
+ for (let index = 0; index < lines.length; index++) {
178
+ const line = lines[index]!.trim();
179
+ const fence = markdownFence(line);
180
+ if (openFence) {
181
+ if (fence) openFence = toggleFence(openFence, fence);
182
+ continue;
183
+ }
184
+ if (inDocumentComment) {
185
+ if (line.includes('-->')) inDocumentComment = false;
186
+ continue;
187
+ }
188
+ if (fence) {
189
+ openFence = fence;
190
+ continue;
191
+ }
192
+ if (line.startsWith('<!--')) {
193
+ if (!line.includes('-->')) inDocumentComment = true;
194
+ continue;
195
+ }
196
+ if (!/^##\s+Simulation\s*$/i.test(line)) continue;
197
+
198
+ const headingLine = index + 1;
199
+ const body: Array<{ text: string; line: number }> = [];
200
+ let cursor = index + 1;
201
+ let inComment = false;
202
+ let bodyFence: string | null = null;
203
+
204
+ for (; cursor < lines.length; cursor++) {
205
+ const text = lines[cursor]!;
206
+ const trimmed = text.trim();
207
+ const bodyFenceMarker = markdownFence(trimmed);
208
+ if (bodyFence) {
209
+ if (bodyFenceMarker) bodyFence = toggleFence(bodyFence, bodyFenceMarker);
210
+ continue;
211
+ }
212
+ if (inComment) {
213
+ if (trimmed.includes('-->')) inComment = false;
214
+ continue;
215
+ }
216
+ if (bodyFenceMarker) {
217
+ bodyFence = bodyFenceMarker;
218
+ continue;
219
+ }
220
+ if (/^#{1,6}\s+/.test(trimmed)) break;
221
+
222
+ if (trimmed.startsWith('<!--')) {
223
+ if (!trimmed.includes('-->')) inComment = true;
224
+ continue;
225
+ }
226
+ if (trimmed.startsWith('.') || /^[A-Za-z]+\s*:/.test(trimmed)) {
227
+ body.push({ text: trimmed, line: cursor + 1 });
228
+ }
229
+ }
230
+
231
+ blocks.push(parseSimulationBlock(body, headingLine));
232
+ index = cursor - 1;
233
+ }
234
+
235
+ return blocks;
236
+ }
237
+
238
+ function markdownFence(line: string): string | null {
239
+ return /^(`{3,}|~{3,})/.exec(line)?.[1] ?? null;
240
+ }
241
+
242
+ function toggleFence(openFence: string | null, marker: string): string | null {
243
+ if (!openFence) return marker;
244
+ if (marker[0] === openFence[0] && marker.length >= openFence.length) return null;
245
+ return openFence;
246
+ }
247
+
248
+ function parseSimulationBlock(
249
+ body: Array<{ text: string; line: number }>,
250
+ headingLine: number,
251
+ ): SimulationBlock {
252
+ let scope: SimulationScope | undefined;
253
+ let analysis: SpiceAnalysis | undefined;
254
+ const sources: SimulationSource[] = [];
255
+ const assertions: SimulationAssertion[] = [];
256
+
257
+ for (const entry of body) {
258
+ if (entry.text.startsWith('.')) {
259
+ throw new SimulationParseError(
260
+ entry.line,
261
+ `raw ngspice statement "${entry.text.split(/\s/, 1)[0]}" is not allowed`,
262
+ );
263
+ }
264
+
265
+ const fieldMatch = /^([A-Za-z]+)\s*:\s*(.+)$/.exec(entry.text);
266
+ if (!fieldMatch) {
267
+ throw new SimulationParseError(entry.line, `expected "field: value", got "${entry.text}"`);
268
+ }
269
+
270
+ const field = fieldMatch[1]!.toLowerCase();
271
+ const value = fieldMatch[2]!.trim();
272
+
273
+ switch (field) {
274
+ case 'scope':
275
+ if (scope) throw new SimulationParseError(entry.line, 'duplicate scope');
276
+ scope = parseScope(value, entry.line);
277
+ break;
278
+ case 'analysis': {
279
+ if (analysis) throw new SimulationParseError(entry.line, 'duplicate analysis');
280
+ const candidate = value.toLowerCase();
281
+ if (!ANALYSES.has(candidate as SpiceAnalysis)) {
282
+ throw new SimulationParseError(entry.line, `unsupported analysis "${value}"`);
283
+ }
284
+ analysis = candidate as SpiceAnalysis;
285
+ break;
286
+ }
287
+ case 'source':
288
+ case 'sources':
289
+ sources.push(parseSource(value, entry.line));
290
+ break;
291
+ case 'assert':
292
+ assertions.push(parseSpiceAssertion(value, entry.line));
293
+ break;
294
+ default:
295
+ throw new SimulationParseError(entry.line, `unknown Simulation field "${field}"`);
296
+ }
297
+ }
298
+
299
+ if (!scope) throw new SimulationParseError(headingLine, 'Simulation block is missing scope');
300
+ if (!analysis) throw new SimulationParseError(headingLine, 'Simulation block is missing analysis');
301
+ if (assertions.length === 0) {
302
+ throw new SimulationParseError(headingLine, 'Simulation block needs at least one assertion');
303
+ }
304
+
305
+ return { line: headingLine, scope, analysis, sources, assertions };
306
+ }
307
+
308
+ function parseScope(raw: string, line: number): SimulationScope {
309
+ const match = /^(sheet|nets)(?:\s+(.*))?$/i.exec(raw);
310
+ if (!match) {
311
+ throw new SimulationParseError(line, 'scope must be "sheet <path>" or "nets <a>, <b>"');
312
+ }
313
+
314
+ const scopeValue = (match[2] ?? '').trim();
315
+ if (match[1]!.toLowerCase() === 'sheet') {
316
+ if (scopeValue.length === 0) {
317
+ throw new SimulationParseError(line, 'sheet scope is empty');
318
+ }
319
+ return { kind: 'sheet', sheet: scopeValue };
320
+ }
321
+
322
+ const netList = scopeValue.replace(/^\[(.*)\]$/, '$1');
323
+ const nets = netList
324
+ .split(',')
325
+ .map((net) => net.trim())
326
+ .filter((net) => net.length > 0);
327
+ if (nets.length === 0 || nets.some((net) => !SAFE_TARGET.test(net))) {
328
+ throw new SimulationParseError(line, 'net scope must contain comma-separated net names');
329
+ }
330
+ return { kind: 'nets', nets: [...new Set(nets)] };
331
+ }
332
+
333
+ function parseSource(raw: string, line: number): SimulationSource {
334
+ const match = /^([A-Za-z_][A-Za-z0-9_./:+-]*)\s*=\s*(\S+)$/.exec(raw);
335
+ if (!match) {
336
+ throw new SimulationParseError(line, 'source must be "<port>=<number>"');
337
+ }
338
+ return {
339
+ port: match[1]!,
340
+ value: parseSpiceNumber(match[2]!, line),
341
+ line,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Compile a parsed assertion to one value measure and one or two margin
347
+ * measures. Inclusive bounds pass at zero; strict bounds must be positive.
348
+ */
349
+ export function compileSpiceAssertion(
350
+ assertion: SimulationAssertion,
351
+ analysis: SpiceAnalysis,
352
+ name = `assertion_${assertion.line}`,
353
+ ): CompiledSpiceAssertion {
354
+ if (!SAFE_MEASURE_NAME.test(name)) {
355
+ throw new SimulationParseError(assertion.line, `invalid measure name "${name}"`);
356
+ }
357
+
358
+ let valueLine: string;
359
+ if (assertion.measurable.kind === 'corner') {
360
+ if (analysis !== 'ac') {
361
+ throw new SimulationParseError(assertion.line, 'corner() requires an ac analysis');
362
+ }
363
+ valueLine = `.meas ac ${name}_value WHEN vdb(${assertion.measurable.target})=-3 FALL=1`;
364
+ } else {
365
+ const expression =
366
+ assertion.measurable.kind === 'voltage'
367
+ ? `v(${assertion.measurable.target})`
368
+ : `i(${assertion.measurable.target})`;
369
+ const operation = analysis === 'op' ? 'FIND' : 'AVG';
370
+ valueLine = `.meas ${analysis} ${name}_value ${operation} ${expression}`;
371
+ }
372
+
373
+ const valueName = `${name}_value`;
374
+ const marginExpressions =
375
+ assertion.comparator.kind === 'between'
376
+ ? [
377
+ [`${name}_lower_margin`, `${valueName} - ${assertion.comparator.lower.raw}`],
378
+ [`${name}_upper_margin`, `${assertion.comparator.upper.raw} - ${valueName}`],
379
+ ]
380
+ : assertion.comparator.kind === 'less-than'
381
+ ? [[`${name}_margin`, `${assertion.comparator.bound.raw} - ${valueName}`]]
382
+ : [[`${name}_margin`, `${valueName} - ${assertion.comparator.bound.raw}`]];
383
+
384
+ const checks = marginExpressions.map(([checkName]) => ({
385
+ name: checkName!,
386
+ pass: assertion.comparator.kind === 'between' ? ('nonnegative' as const) : ('positive' as const),
387
+ }));
388
+ const checkLines = marginExpressions.map(
389
+ ([checkName, expression]) => `.meas ${analysis} ${checkName} PARAM='${expression}'`,
390
+ );
391
+ const compiledLines = [valueLine, ...checkLines];
392
+
393
+ return {
394
+ valueName,
395
+ checks,
396
+ lines: compiledLines,
397
+ deck: compiledLines.join('\n'),
398
+ };
399
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Cross-check the schematic's `lib_symbols` against the KiCad symbol libraries
3
+ * installed on the machine (I9).
4
+ *
5
+ * The create pipeline currently has the model hand-author every `lib_symbols`
6
+ * entry — pins, names, electrical types, geometry — under a `lib_id` that
7
+ * *claims* to be a canonical KiCad part (`Device:R`, `Connector:USB_C_...`).
8
+ * ERC only checks the net graph as drawn, so an entry whose pins silently
9
+ * diverge from the real library part (wrong pin count, a missing shield/CC pin,
10
+ * swapped numbers) passes every gate while being wrong. This module reads the
11
+ * real `(symbol …)` out of the installed `.kicad_sym` and reports divergences so
12
+ * the model — or a reviewer — can reconcile them.
13
+ *
14
+ * It is deliberately a *checker*, not an auto-replacer: KiCad renames symbols
15
+ * across versions (e.g. `USB_C_Receptacle_USB2.0` became `…_14P`/`…_16P` in
16
+ * KiCad 10), so blindly splicing by lib_id would fail on exactly the parts that
17
+ * matter most. When the exact name is absent, we surface close candidates
18
+ * instead of guessing.
19
+ */
20
+
21
+ import { readFile, readdir, access } from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ import { parseSexp, children, child, isList, type SexpNode } from './sexp.js';
24
+
25
+ const tag = (n: SexpNode): string | null => (isList(n) && typeof n[0] === 'string' ? n[0] : null);
26
+ const atomAt = (node: SexpNode[] | undefined, idx: number): string | undefined => {
27
+ const v = node?.[idx];
28
+ return typeof v === 'string' ? v : undefined;
29
+ };
30
+
31
+ // KiCad has two spellings for an unnamed pin: the legacy `~` sentinel and, in
32
+ // newer library format, an empty string. They are semantically identical, so
33
+ // normalize before comparing or the check floods with phantom `~` vs "" diffs.
34
+ const normPinName = (n: string): string => (n === '~' ? '' : n);
35
+
36
+ export interface LibPin {
37
+ number: string;
38
+ name: string;
39
+ /** electrical type: passive | power_in | bidirectional | input | … */
40
+ type: string;
41
+ }
42
+
43
+ /**
44
+ * Candidate directories holding KiCad's stock `.kicad_sym` libraries, most
45
+ * specific first. Env overrides win (KiCad exports these), then the standard
46
+ * install locations for Linux/macOS/Windows. Only existing dirs are returned.
47
+ */
48
+ export async function symbolSearchDirs(env = process.env): Promise<string[]> {
49
+ const fromEnv = [
50
+ env.KICAD_SYMBOL_DIR,
51
+ env.KICAD10_SYMBOL_DIR,
52
+ env.KICAD9_SYMBOL_DIR,
53
+ env.KICAD8_SYMBOL_DIR,
54
+ ].filter((v): v is string => !!v);
55
+ const defaults = [
56
+ '/usr/share/kicad/symbols',
57
+ '/usr/local/share/kicad/symbols',
58
+ '/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols',
59
+ 'C:/Program Files/KiCad/share/kicad/symbols',
60
+ ];
61
+ const out: string[] = [];
62
+ for (const dir of [...fromEnv, ...defaults]) {
63
+ try {
64
+ await access(dir);
65
+ if (!out.includes(dir)) out.push(dir);
66
+ } catch {
67
+ // not present on this machine; skip
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** Path to `<lib>.kicad_sym` in the first search dir that has it, or null. */
74
+ export async function findLibraryFile(lib: string, dirs: string[]): Promise<string | null> {
75
+ for (const dir of dirs) {
76
+ const p = path.join(dir, `${lib}.kicad_sym`);
77
+ try {
78
+ await access(p);
79
+ return p;
80
+ } catch {
81
+ // try next dir
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** Collect pins (number, name, electrical type) from a `(symbol …)` node,
88
+ * including its nested unit sub-symbols. Same walk `libPinDefs` uses, plus the
89
+ * electrical-type atom that pin-position parsing does not need. */
90
+ export function pinsOfSymbolNode(sym: SexpNode[]): LibPin[] {
91
+ const pins: LibPin[] = [];
92
+ const walk = (n: SexpNode): void => {
93
+ if (!isList(n)) return;
94
+ if (tag(n) === 'pin') {
95
+ const num = atomAt(child(n, 'number'), 1);
96
+ if (num !== undefined) {
97
+ pins.push({
98
+ number: num,
99
+ name: atomAt(child(n, 'name'), 1) ?? '~',
100
+ type: typeof n[1] === 'string' ? n[1] : '?',
101
+ });
102
+ }
103
+ }
104
+ for (const c of n) walk(c);
105
+ };
106
+ walk(sym);
107
+ return pins;
108
+ }
109
+
110
+ /** The top-level `(symbol "name" …)` entries of a parsed `.kicad_sym` root. */
111
+ function librarySymbols(root: SexpNode[]): Map<string, SexpNode[]> {
112
+ const map = new Map<string, SexpNode[]>();
113
+ for (const sym of children(root, 'symbol')) {
114
+ const name = atomAt(sym, 1);
115
+ if (name) map.set(name, sym);
116
+ }
117
+ return map;
118
+ }
119
+
120
+ /**
121
+ * Resolve a `lib_id` (e.g. `Device:R`) to the real library part's pins.
122
+ * `extends` derived symbols inherit their base's pins, so we follow one such
123
+ * link (loop-guarded). Returns the pins, or — when the exact symbol is absent —
124
+ * the closest-named candidates so a caller can suggest the real name.
125
+ */
126
+ export async function resolveLibrarySymbol(
127
+ libId: string,
128
+ dirs: string[],
129
+ ): Promise<
130
+ | { status: 'ok'; pins: LibPin[] }
131
+ | { status: 'no-symbol'; candidates: string[] }
132
+ | { status: 'no-library' }
133
+ > {
134
+ const [lib, name] = libId.includes(':') ? [libId.slice(0, libId.indexOf(':')), libId.slice(libId.indexOf(':') + 1)] : ['', libId];
135
+ const file = await findLibraryFile(lib, dirs);
136
+ if (!file) return { status: 'no-library' };
137
+ const root = parseSexp(await readFile(file, 'utf8'))[0];
138
+ if (root === undefined || !isList(root)) return { status: 'no-library' };
139
+ const symbols = librarySymbols(root);
140
+
141
+ let current = name;
142
+ const seen = new Set<string>();
143
+ while (current && !seen.has(current)) {
144
+ seen.add(current);
145
+ const sym = symbols.get(current);
146
+ if (!sym) break;
147
+ const pins = pinsOfSymbolNode(sym);
148
+ if (pins.length) return { status: 'ok', pins };
149
+ // no pins of its own → follow an `extends` base if present
150
+ const base = atomAt(child(sym, 'extends'), 1);
151
+ if (!base) return { status: 'ok', pins }; // genuinely pinless (e.g. a graphic)
152
+ current = base;
153
+ }
154
+
155
+ // exact name not found: offer near matches (case-insensitive substring both ways)
156
+ const q = name.toLowerCase();
157
+ const candidates = [...symbols.keys()]
158
+ .filter((k) => {
159
+ const lk = k.toLowerCase();
160
+ return lk.includes(q) || q.includes(lk);
161
+ })
162
+ .slice(0, 8);
163
+ return { status: 'no-symbol', candidates };
164
+ }
165
+
166
+ export interface SymbolFinding {
167
+ libId: string;
168
+ kind: 'no-library' | 'no-symbol' | 'pin-count' | 'pin-mismatch';
169
+ detail: string;
170
+ }
171
+
172
+ /** A schematic lib_symbols entry: its lib_id and the pins as authored. */
173
+ function schematicLibSymbols(root: SexpNode[]): { libId: string; pins: LibPin[] }[] {
174
+ const libs = child(root, 'lib_symbols');
175
+ if (!libs) return [];
176
+ return children(libs, 'symbol').map((sym) => ({
177
+ libId: atomAt(sym, 1) ?? '',
178
+ pins: pinsOfSymbolNode(sym),
179
+ }));
180
+ }
181
+
182
+ /**
183
+ * Compare every lib_symbols entry in a schematic against the installed library.
184
+ * Returns one finding per divergence; an empty array means every resolvable
185
+ * symbol matched. A part whose library is not installed is reported once (so
186
+ * the model knows the check could not run for it) but never treated as a
187
+ * mismatch — absence of the library is not evidence of wrong pins.
188
+ */
189
+ export async function verifySchematicSymbols(
190
+ schPath: string,
191
+ env = process.env,
192
+ ): Promise<{ findings: SymbolFinding[]; checked: number; skipped: number }> {
193
+ const dirs = await symbolSearchDirs(env);
194
+ const root = parseSexp(await readFile(schPath, 'utf8'))[0];
195
+ const findings: SymbolFinding[] = [];
196
+ if (root === undefined || !isList(root)) return { findings, checked: 0, skipped: 0 };
197
+
198
+ let checked = 0;
199
+ let skipped = 0;
200
+ for (const entry of schematicLibSymbols(root)) {
201
+ if (!entry.libId) continue;
202
+ const resolved = await resolveLibrarySymbol(entry.libId, dirs);
203
+ if (resolved.status === 'no-library') {
204
+ skipped++;
205
+ findings.push({
206
+ libId: entry.libId,
207
+ kind: 'no-library',
208
+ detail: `library for "${entry.libId}" is not installed on this machine; cannot verify its pins`,
209
+ });
210
+ continue;
211
+ }
212
+ if (resolved.status === 'no-symbol') {
213
+ findings.push({
214
+ libId: entry.libId,
215
+ kind: 'no-symbol',
216
+ detail: resolved.candidates.length
217
+ ? `"${entry.libId}" does not exist in the installed library — closest real symbols: ${resolved.candidates.join(', ')}. Use one of these lib_ids (KiCad renames symbols across versions).`
218
+ : `"${entry.libId}" does not exist in the installed library and no close match was found; confirm the lib_id.`,
219
+ });
220
+ continue;
221
+ }
222
+ checked++;
223
+ const real = resolved.pins;
224
+ const authored = entry.pins;
225
+ const realByNum = new Map(real.map((p) => [p.number, p]));
226
+ const authByNum = new Map(authored.map((p) => [p.number, p]));
227
+ if (real.length !== authored.length) {
228
+ findings.push({
229
+ libId: entry.libId,
230
+ kind: 'pin-count',
231
+ detail: `pin count differs: schematic has ${authored.length} pin(s) [${[...authByNum.keys()].join(',')}], the real ${entry.libId} has ${real.length} [${[...realByNum.keys()].join(',')}]`,
232
+ });
233
+ }
234
+ // per-pin name/type divergence on shared pin numbers
235
+ for (const [num, rp] of realByNum) {
236
+ const ap = authByNum.get(num);
237
+ if (!ap) continue; // count mismatch already reported the gap
238
+ if (normPinName(ap.name) !== normPinName(rp.name) || ap.type !== rp.type) {
239
+ findings.push({
240
+ libId: entry.libId,
241
+ kind: 'pin-mismatch',
242
+ detail: `pin ${num}: schematic has (name "${ap.name}", ${ap.type}), real part has (name "${rp.name}", ${rp.type})`,
243
+ });
244
+ }
245
+ }
246
+ }
247
+ return { findings, checked, skipped };
248
+ }