copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,549 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseSexp, children, child, isList, type SexpNode, type Bounds } from '../sexp.js';
5
+ import { symbolSearchDirs, findLibraryFile, findSymbolAcrossLibraries, closestSymbolNames } from '../symlib.js';
6
+ import { EMIT_VERSION, renameSymbolBlock } from '../emit.js';
7
+
8
+ /**
9
+ * Symbol resolution for the drafting engine, with hermetic vendoring (design
10
+ * D4): on first use a symbol's `(symbol …)` source block is copied verbatim
11
+ * from the installed `.kicad_sym` into a committed project cache, and every
12
+ * later draft reads the vendored copy. A KiCad library upgrade therefore
13
+ * cannot change drafted output until the cache is deliberately refreshed;
14
+ * `verify_symbols` keeps comparing against the installed libraries, so genuine
15
+ * library drift stays visible rather than silently frozen.
16
+ */
17
+
18
+ export const SYM_CACHE_DIR = 'sym-lib-cache';
19
+
20
+ /**
21
+ * How many `(extends …)` hops to follow before treating a library as malformed.
22
+ * Real libraries chain at most one or two deep (a part variant extending a
23
+ * generic); anything deeper is a cycle, and following it would blow the stack
24
+ * mid-draft with no attributable message.
25
+ */
26
+ const MAX_EXTENDS_DEPTH = 8;
27
+
28
+ const atomAt = (node: SexpNode[] | undefined, idx: number): string | undefined => {
29
+ const v = node?.[idx];
30
+ return typeof v === 'string' ? v : undefined;
31
+ };
32
+ const tag = (n: SexpNode): string | null => (isList(n) && typeof n[0] === 'string' ? n[0] : null);
33
+ const num = (n: SexpNode[] | undefined, idx: number, fb = 0): number => {
34
+ const v = atomAt(n, idx);
35
+ return v === undefined ? fb : parseFloat(v);
36
+ };
37
+
38
+ export interface DraftPin {
39
+ number: string;
40
+ name: string;
41
+ /** electrical type: passive | power_in | power_out | input | output | … */
42
+ etype: string;
43
+ /** Connection point, symbol space. */
44
+ x: number;
45
+ y: number;
46
+ /** Pin direction angle (deg): the pin line runs from (x,y) toward the body. */
47
+ angle: number;
48
+ }
49
+
50
+ /** One placeable unit of a multi-unit symbol: its own pins and drawn body
51
+ * (unit graphics merged with the symbol's common unit-0 graphics). */
52
+ export interface SymbolUnitView {
53
+ unit: number;
54
+ pins: DraftPin[];
55
+ body: Bounds | null;
56
+ }
57
+
58
+ export interface ResolvedSymbol {
59
+ libId: string;
60
+ /** Verbatim `(symbol "Name" …)` block from the vendored source. */
61
+ sourceText: string;
62
+ pins: DraftPin[];
63
+ /** Union of body graphic items, symbol space; null for graphics-free symbols. */
64
+ body: Bounds | null;
65
+ isPower: boolean;
66
+ /** True when the library symbol defines more than one unit (an opamp, a
67
+ * gate pack). Units share symbol-space pin coordinates, so a single placed
68
+ * instance would overlay unrelated pins on one point; the engine places
69
+ * each unit as its own instance instead, using `units` below. */
70
+ multiUnit: boolean;
71
+ /** Per-unit views (pins and body), present iff `multiUnit`. Only the
72
+ * default body style (`_<unit>_0`/`_<unit>_1` children) contributes — a
73
+ * de Morgan alternate (style ≥ 2) repeats the same pins at other
74
+ * coordinates and would double them. Common (`_0_*`) pins are included in
75
+ * EVERY unit's view, because KiCad draws them on every placed unit. */
76
+ units?: SymbolUnitView[];
77
+ /** Pin numbers defined in the COMMON unit (`_0_*` children) — the classic
78
+ * home of a gate pack's power pins (an LM358's V+/V-). Each is drawn on
79
+ * every placed unit, so the engine wires it at every instance; the shared
80
+ * net makes the appearances electrically one point. */
81
+ commonUnitPins?: string[];
82
+ }
83
+
84
+ /** Extract the top-level `(symbol "name" …)` block from library text, verbatim. */
85
+ export function extractSymbolBlock(libText: string, name: string): string | null {
86
+ const needle = `(symbol "${name}"`;
87
+ let idx = -1;
88
+ // match only top-level entries: preceded by newline + whitespace, depth 1
89
+ for (let from = 0; ; ) {
90
+ idx = libText.indexOf(needle, from);
91
+ if (idx === -1) return null;
92
+ const lineStart = libText.lastIndexOf('\n', idx) + 1;
93
+ if (libText.slice(lineStart, idx).trim() === '') break;
94
+ from = idx + needle.length;
95
+ }
96
+ let depth = 0;
97
+ let inString = false;
98
+ for (let i = idx; i < libText.length; i++) {
99
+ const c = libText[i]!;
100
+ if (inString) {
101
+ if (c === '\\') i++;
102
+ else if (c === '"') inString = false;
103
+ } else if (c === '"') inString = true;
104
+ else if (c === '(') depth++;
105
+ else if (c === ')') {
106
+ depth--;
107
+ if (depth === 0) return libText.slice(idx, i + 1);
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** Highest unit number among a block's `Name_<unit>_<style>` children. */
114
+ function maxUnitOf(sym: SexpNode[]): number {
115
+ let max = 0;
116
+ for (const c of children(sym, 'symbol')) {
117
+ const name = atomAt(c, 1);
118
+ const m = name ? /_(\d+)_(\d+)$/.exec(name) : null;
119
+ if (m) max = Math.max(max, Number(m[1]));
120
+ }
121
+ return max;
122
+ }
123
+
124
+ const mergeBounds = (a: Bounds | null, b: Bounds | null): Bounds | null => {
125
+ if (!a) return b ? { ...b } : null;
126
+ if (!b) return { ...a };
127
+ return {
128
+ minX: Math.min(a.minX, b.minX),
129
+ minY: Math.min(a.minY, b.minY),
130
+ maxX: Math.max(a.maxX, b.maxX),
131
+ maxY: Math.max(a.maxY, b.maxY),
132
+ };
133
+ };
134
+
135
+ /**
136
+ * Per-unit views of a multi-unit block. Each unit's pins and body come from
137
+ * its own `_<unit>_<style>` children; the common `_0_*` graphics are merged
138
+ * into every unit's body because KiCad draws them on every unit. Only body
139
+ * styles 0 and 1 contribute — style ≥ 2 is the de Morgan alternate, which
140
+ * repeats the same pins at other coordinates and would double them.
141
+ */
142
+ function unitViewsOf(sym: SexpNode[]): { units: SymbolUnitView[]; commonUnitPins: string[] } {
143
+ const byUnit = new Map<number, SexpNode[][]>();
144
+ for (const c of children(sym, 'symbol')) {
145
+ const name = atomAt(c, 1);
146
+ const m = name ? /_(\d+)_(\d+)$/.exec(name) : null;
147
+ if (!m || Number(m[2]) >= 2) continue;
148
+ const u = Number(m[1]);
149
+ byUnit.set(u, [...(byUnit.get(u) ?? []), c]);
150
+ }
151
+ const common = byUnit.get(0) ?? [];
152
+ const commonPins = common.flatMap((c) => pinsOf(c));
153
+ const commonUnitPins = commonPins.map((p) => p.number);
154
+ const commonBody = common.reduce<Bounds | null>((b, c) => mergeBounds(b, bodyOf(c)), null);
155
+ const units: SymbolUnitView[] = [...byUnit.keys()]
156
+ .filter((u) => u >= 1)
157
+ .sort((a, b) => a - b)
158
+ .map((u) => ({
159
+ unit: u,
160
+ pins: [...byUnit.get(u)!.flatMap((n) => pinsOf(n)), ...commonPins],
161
+ body: byUnit.get(u)!.reduce<Bounds | null>((b, n) => mergeBounds(b, bodyOf(n)), commonBody),
162
+ }));
163
+ return { units, commonUnitPins };
164
+ }
165
+
166
+ function parseSymbolNode(block: string): SexpNode[] {
167
+ const node = parseSexp(block)[0];
168
+ if (node === undefined || !isList(node)) throw new Error('unparseable symbol block');
169
+ return node;
170
+ }
171
+
172
+ function pinsOf(sym: SexpNode[]): DraftPin[] {
173
+ const pins: DraftPin[] = [];
174
+ const walk = (n: SexpNode): void => {
175
+ if (!isList(n)) return;
176
+ if (tag(n) === 'pin') {
177
+ const at = child(n, 'at');
178
+ const numAtom = atomAt(child(n, 'number'), 1);
179
+ if (at && numAtom !== undefined) {
180
+ pins.push({
181
+ number: numAtom,
182
+ name: atomAt(child(n, 'name'), 1) ?? '~',
183
+ etype: typeof n[1] === 'string' ? n[1] : 'passive',
184
+ x: num(at, 1),
185
+ y: num(at, 2),
186
+ angle: num(at, 3),
187
+ });
188
+ }
189
+ }
190
+ for (const c of n) walk(c);
191
+ };
192
+ walk(sym);
193
+ return pins;
194
+ }
195
+
196
+ function bodyOf(sym: SexpNode[]): Bounds | null {
197
+ let b: Bounds | null = null;
198
+ const extend = (x: number, y: number): void => {
199
+ if (!b) b = { minX: x, minY: y, maxX: x, maxY: y };
200
+ else {
201
+ b.minX = Math.min(b.minX, x);
202
+ b.minY = Math.min(b.minY, y);
203
+ b.maxX = Math.max(b.maxX, x);
204
+ b.maxY = Math.max(b.maxY, y);
205
+ }
206
+ };
207
+ const walk = (n: SexpNode): void => {
208
+ if (!isList(n)) return;
209
+ const t = tag(n);
210
+ if (t === 'rectangle') {
211
+ const s = child(n, 'start');
212
+ const e = child(n, 'end');
213
+ extend(num(s, 1), num(s, 2));
214
+ extend(num(e, 1), num(e, 2));
215
+ } else if (t === 'circle') {
216
+ const c = child(n, 'center');
217
+ const r = num(child(n, 'radius'), 1);
218
+ extend(num(c, 1) - r, num(c, 2) - r);
219
+ extend(num(c, 1) + r, num(c, 2) + r);
220
+ } else if (t === 'arc') {
221
+ for (const part of ['start', 'mid', 'end']) {
222
+ const p = child(n, part);
223
+ if (p) extend(num(p, 1), num(p, 2));
224
+ }
225
+ } else if (t === 'polyline') {
226
+ for (const xy of children(child(n, 'pts') ?? [], 'xy')) extend(num(xy, 1), num(xy, 2));
227
+ }
228
+ if (t !== 'pin' && t !== 'text') for (const c of n) walk(c);
229
+ };
230
+ walk(sym);
231
+ return b;
232
+ }
233
+
234
+ /** One vendored file per library nickname, so a project `sym-lib-table` can
235
+ * point KiCad (and ERC's lib_symbol_issues check) at the vendored sources. */
236
+ const vendorFileName = (lib: string): string => `${lib.replace(/[^A-Za-z0-9_.+-]/g, '_')}.kicad_sym`;
237
+
238
+ const emptyVendorLib = (): string =>
239
+ `(kicad_symbol_lib\n\t(version ${EMIT_VERSION})\n\t(generator "copperhead-vendor")\n)\n`;
240
+
241
+ /** Insert a symbol block before the wrapper's closing paren. */
242
+ function appendToVendorLib(libText: string, block: string): string {
243
+ const end = libText.lastIndexOf(')');
244
+ return libText.slice(0, end) + block + '\n' + libText.slice(end);
245
+ }
246
+
247
+ export class SymbolResolutionError extends Error {
248
+ constructor(
249
+ public readonly libId: string,
250
+ public readonly reason: 'no-library' | 'no-symbol' | 'derived-unsupported' | 'found-elsewhere',
251
+ public readonly candidates: string[] = [],
252
+ ) {
253
+ super(
254
+ reason === 'no-library'
255
+ ? `library for "${libId}" is not installed and not vendored`
256
+ : reason === 'derived-unsupported'
257
+ ? `"${libId}" extends a symbol that cannot be followed (missing base name, or a cycle deeper than ${MAX_EXTENDS_DEPTH} hops); use the base symbol directly`
258
+ : reason === 'found-elsewhere'
259
+ ? candidates.length && candidates.every((c) => c.startsWith(`${libId.includes(':') ? libId.slice(0, libId.indexOf(':')) : ''}:`))
260
+ ? `"${libId}" does not exist in that library, closest real name: ${candidates.join(' or ')} — the library was right, use the real symbol name`
261
+ : `"${libId}" is wrong about the library, not the part: use ${candidates.join(' or ')} instead`
262
+ : `"${libId}" does not exist in the library${candidates.length ? ` — closest: ${candidates.join(', ')}` : ''}`,
263
+ );
264
+ }
265
+ }
266
+
267
+ /**
268
+ * A part's library file is guessed wrong more often than the part itself is
269
+ * misspelled (a chip's stock library nickname is not derivable from its part
270
+ * number). The caller consults this only after the guessed file offered no
271
+ * candidates of its own — same-library near-misses are stronger evidence and
272
+ * come first. Across the other libraries, exact name hits are returned alone
273
+ * when any exist; fuzzy matches are the fallback, reported under the symbol
274
+ * name that actually matched. Returns corrected `lib_id`s or an empty array.
275
+ */
276
+ async function crossLibrarySuggestions(name: string, lib: string, dirs: string[]): Promise<string[]> {
277
+ const matches = await findSymbolAcrossLibraries(name, dirs, lib);
278
+ const exact = matches.filter((m) => m.exact).map((m) => `${m.lib}:${m.name}`);
279
+ if (exact.length) return exact;
280
+ return matches.map((m) => `${m.lib}:${m.name}`);
281
+ }
282
+
283
+ export class SymbolSource {
284
+ private cache = new Map<string, ResolvedSymbol>();
285
+
286
+ /**
287
+ * @param repoRoot project root; the vendored cache lives at `<root>/sym-lib-cache/`
288
+ * @param searchDirs override for the installed-library search path (tests)
289
+ * @param vendor when false, resolution never writes: symbols come from the
290
+ * vendored cache or the installed libraries verbatim, but nothing is copied
291
+ * into `sym-lib-cache/`. For read-shaped callers (`draftSchematicToText`, staleness
292
+ * probes) that must not mutate the working tree.
293
+ */
294
+ constructor(
295
+ private readonly repoRoot: string,
296
+ private readonly searchDirs?: string[],
297
+ private readonly vendor: boolean = true,
298
+ ) {}
299
+
300
+ cacheDir(): string {
301
+ return path.join(this.repoRoot, SYM_CACHE_DIR);
302
+ }
303
+
304
+ /** Library nicknames vendored (or generated) so far, for the sym-lib-table. */
305
+ private readonly libs = new Set<string>();
306
+
307
+ vendoredLibs(): string[] {
308
+ return [...this.libs].sort();
309
+ }
310
+
311
+ /**
312
+ * Write an engine-generated symbol block into the vendored cache (power lib).
313
+ *
314
+ * A generated block is the engine's, not a vendor's: when the engine's
315
+ * drawing of a rail or ground changes, the cached copy must follow, or every
316
+ * embedded power symbol on the sheet raises a lib_symbol_mismatch warning
317
+ * against the stale library (the triangle-ground redraw hit 111 of them on
318
+ * esp32-amp). A block already cached byte-for-byte is left alone.
319
+ */
320
+ async vendorGenerated(libId: string, block: string): Promise<void> {
321
+ const lib = libId.slice(0, libId.indexOf(':'));
322
+ const name = libId.slice(libId.indexOf(':') + 1);
323
+ const file = path.join(this.cacheDir(), vendorFileName(lib));
324
+ await mkdir(this.cacheDir(), { recursive: true });
325
+ let text = existsSync(file) ? await readFile(file, 'utf8') : emptyVendorLib();
326
+ const cached = extractSymbolBlock(text, name);
327
+ if (cached !== block) {
328
+ if (cached) text = text.replace(cached + '\n', '').replace(cached, '');
329
+ await writeFile(file, appendToVendorLib(text, block), 'utf8');
330
+ }
331
+ this.libs.add(lib);
332
+ }
333
+
334
+ /**
335
+ * A derived symbol (`(extends "Base")`) carries no geometry of its own: KiCad
336
+ * stores the pins and body once on the base and lets the derived entry
337
+ * override only properties (Value, Datasheet, description). Resolving one
338
+ * means inheriting the base's geometry under the derived name.
339
+ *
340
+ * Returns null for a symbol that does not extend anything, so the caller
341
+ * falls through to the ordinary path.
342
+ *
343
+ * `sourceText` is the BASE block verbatim. The emitter renames a vendored
344
+ * block to its lib_id on the way into `lib_symbols`
345
+ * (`emit.ts:renameSymbolBlock`), so the base geometry lands under the
346
+ * derived name with no rewriting here. The rename covers the unit children
347
+ * too (`Base_0_1` becomes `Derived_0_1`): KiCad's schematic loader requires
348
+ * the child prefix to match the parent name, and a derived block wrapping
349
+ * the base's children fails to load outright — found when the first
350
+ * reference board with real derived symbols (AMS1117-3.3, ATmega328P-A)
351
+ * refused to open.
352
+ *
353
+ * The derived entry's own property overrides are dropped. Nothing downstream
354
+ * reads them — the engine sets Reference, Value and Footprint from the IR —
355
+ * and keeping them would mean merging two property lists to no effect.
356
+ */
357
+ private async inherit(
358
+ libId: string,
359
+ lib: string,
360
+ node: SexpNode[],
361
+ depth: number,
362
+ ): Promise<ResolvedSymbol | null> {
363
+ const ext = child(node, 'extends');
364
+ if (!ext) return null;
365
+ const baseName = atomAt(ext, 1);
366
+ if (!baseName) throw new SymbolResolutionError(libId, 'derived-unsupported');
367
+ // A chain is legal (a derived symbol may extend another), a cycle is not —
368
+ // and a malformed library that extends itself would otherwise recurse until
369
+ // the stack gives out, mid-draft, with no attributable message.
370
+ if (depth >= MAX_EXTENDS_DEPTH) throw new SymbolResolutionError(libId, 'derived-unsupported');
371
+ const base = await this.resolve(`${lib}:${baseName}`, depth + 1);
372
+ return {
373
+ libId,
374
+ sourceText: base.sourceText,
375
+ pins: base.pins,
376
+ body: base.body,
377
+ isPower: base.isPower,
378
+ multiUnit: base.multiUnit,
379
+ units: base.units,
380
+ commonUnitPins: base.commonUnitPins,
381
+ };
382
+ }
383
+
384
+ /** Resolve a lib_id: vendored copy first, else installed library (then vendor it). */
385
+ async resolve(libId: string, depth = 0): Promise<ResolvedSymbol> {
386
+ const hit = this.cache.get(libId);
387
+ if (hit) return hit;
388
+
389
+ const lib = libId.includes(':') ? libId.slice(0, libId.indexOf(':')) : '';
390
+ const name = libId.includes(':') ? libId.slice(libId.indexOf(':') + 1) : libId;
391
+ const vendored = path.join(this.cacheDir(), vendorFileName(lib));
392
+ let block: string | null = null;
393
+ let fromInstalled = false;
394
+ if (existsSync(vendored)) {
395
+ block = extractSymbolBlock(await readFile(vendored, 'utf8'), name);
396
+ if (block) this.libs.add(lib);
397
+ }
398
+ if (!block) {
399
+ const dirs = this.searchDirs ?? (await symbolSearchDirs());
400
+ const file = await findLibraryFile(lib, dirs);
401
+ if (!file) {
402
+ const elsewhere = await crossLibrarySuggestions(name, lib, dirs);
403
+ if (elsewhere.length) throw new SymbolResolutionError(libId, 'found-elsewhere', elsewhere);
404
+ throw new SymbolResolutionError(libId, 'no-library');
405
+ }
406
+ const libText = await readFile(file, 'utf8');
407
+ block = extractSymbolBlock(libText, name);
408
+ if (!block) {
409
+ // The guessed library exists and simply lacks this name, so its own
410
+ // near-matches are the most useful answer and come first: the caller
411
+ // named the right file and mistyped the part. The scrape matches every
412
+ // `(symbol …)` line including indented sub-unit children;
413
+ // closestSymbolNames filters those out and ranks what remains, so all
414
+ // 8 slots carry placeable symbols. Only when that file offers nothing
415
+ // is a different library worth suggesting — and only an exact hit
416
+ // there, since a cross-library *fuzzy* guess is weaker evidence than
417
+ // "you were close, in the right file".
418
+ const names = [...libText.matchAll(/^\s*\(symbol\s+"([^"]+)"/gm)].map((m) => m[1]!);
419
+ const candidates = closestSymbolNames(names, name);
420
+ if (candidates.length) throw new SymbolResolutionError(libId, 'no-symbol', candidates);
421
+ const elsewhere = await crossLibrarySuggestions(name, lib, dirs);
422
+ if (elsewhere.length) throw new SymbolResolutionError(libId, 'found-elsewhere', elsewhere);
423
+ throw new SymbolResolutionError(libId, 'no-symbol', candidates);
424
+ }
425
+ fromInstalled = true;
426
+ this.libs.add(lib);
427
+ }
428
+
429
+ const node = parseSymbolNode(block);
430
+ const inherited = await this.inherit(libId, lib, node, depth);
431
+ if (fromInstalled && this.vendor) {
432
+ // Derived symbols vendor FLATTENED under their own name, never as the
433
+ // library's `extends` stub. A vendored stub makes the project
434
+ // sym-lib-table resolve the derived name to base-geometry-plus-derived-
435
+ // properties while the sheet embeds base-geometry-plus-base-properties,
436
+ // and ERC reports lib_symbol_mismatch on every derived part. The
437
+ // flattened copy is byte-for-byte what the emitter embeds (modulo the
438
+ // lib nickname), exactly like a non-derived vendored block.
439
+ const vendorBlock = inherited ? renameSymbolBlock(inherited.sourceText, name) : block;
440
+ await mkdir(this.cacheDir(), { recursive: true });
441
+ const existing = existsSync(vendored) ? await readFile(vendored, 'utf8') : emptyVendorLib();
442
+ if (!extractSymbolBlock(existing, name)) {
443
+ await writeFile(vendored, appendToVendorLib(existing, vendorBlock), 'utf8');
444
+ }
445
+ }
446
+ const multiUnit = maxUnitOf(node) >= 2;
447
+ const resolved: ResolvedSymbol = inherited ?? {
448
+ libId,
449
+ sourceText: block,
450
+ pins: pinsOf(node),
451
+ body: bodyOf(node),
452
+ isPower: child(node, 'power') !== undefined || libId.startsWith('power:'),
453
+ multiUnit,
454
+ ...(multiUnit ? unitViewsOf(node) : {}),
455
+ };
456
+ this.cache.set(libId, resolved);
457
+ return resolved;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * The symbol-name token a power-class net's generated symbol lives under
463
+ * (`copperhead_power:<token>`), which is also what its vendored file is named
464
+ * from. Two nets that sanitize to one token would share one `lib_id` and one
465
+ * embedded pin name, quietly merging their rails in KiCad's netlist —
466
+ * `validateIntent` refuses that pair using this same function, so the collision
467
+ * dies as a numbered finding instead.
468
+ */
469
+ export const powerNetToken = (net: string): string => net.replace(/[^A-Za-z0-9_+.-]/g, '_');
470
+
471
+ /** Escape a string for embedding inside a quoted s-expression atom. Defense in
472
+ * depth: `validateIntent` already refuses net names containing `"` or `\`, but
473
+ * this template must not be able to corrupt a schematic even if called raw. */
474
+ const sexpQuote = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
475
+
476
+ /**
477
+ * Engine-authored power-port and PWR_FLAG symbols (design D6a). Authored here,
478
+ * not copied from a library: the engine must be able to satisfy ERC's
479
+ * undriven-rail check on any machine without depending on the installed power
480
+ * library's naming. Pin at the origin; the body draws above (rail) or below
481
+ * (ground) it.
482
+ */
483
+ export function powerSymbolSource(rawNet: string, kind: 'rail' | 'ground'): { libId: string; sourceText: string } {
484
+ const net = sexpQuote(rawNet);
485
+ const name = powerNetToken(rawNet);
486
+ const libId = `copperhead_power:${name}`;
487
+ // Symbol space is Y-up and the schematic transform flips it: negative-Y
488
+ // graphics here render BELOW the connection point on the sheet. A ground
489
+ // hangs below its pin (angle 270 = line toward -Y body) as a stem and an
490
+ // open triangle, the reference-ground glyph; a rail rises above (angle 90)
491
+ // as a stem and a filled arrowhead, the way KiCad's own +3V3 draws. Both
492
+ // reach 2.54 from the pin, inside the 2-unit box the engine reserves.
493
+ const body =
494
+ kind === 'ground'
495
+ ? `\t(symbol "${name}_0_1"
496
+ \t\t(polyline (pts (xy 0 0) (xy 0 -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
497
+ \t\t(polyline (pts (xy -1.27 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
498
+ \t)
499
+ \t(symbol "${name}_1_1"
500
+ \t\t(pin power_in line (at 0 0 270) (length 0) hide
501
+ \t\t\t(name "${net}" (effects (font (size 1.27 1.27))))
502
+ \t\t\t(number "1" (effects (font (size 1.27 1.27))))
503
+ \t\t)
504
+ \t)`
505
+ : `\t(symbol "${name}_0_1"
506
+ \t\t(polyline (pts (xy 0 0) (xy 0 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
507
+ \t\t(polyline (pts (xy -0.762 1.27) (xy 0 2.54) (xy 0.762 1.27) (xy -0.762 1.27)) (stroke (width 0.254) (type default)) (fill (type outline)))
508
+ \t)
509
+ \t(symbol "${name}_1_1"
510
+ \t\t(pin power_in line (at 0 0 90) (length 0) hide
511
+ \t\t\t(name "${net}" (effects (font (size 1.27 1.27))))
512
+ \t\t\t(number "1" (effects (font (size 1.27 1.27))))
513
+ \t\t)
514
+ \t)`;
515
+ const sourceText = `(symbol "${name}"
516
+ \t(power)
517
+ \t(pin_names (offset 0))
518
+ \t(exclude_from_sim yes)
519
+ \t(in_bom no)
520
+ \t(on_board no)
521
+ \t(property "Reference" "#PWR" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
522
+ \t(property "Value" "${net}" (at 0 ${kind === 'ground' ? '-3.556' : '3.556'} 0) (effects (font (size 1.27 1.27)) hide))
523
+ ${body}
524
+ )`;
525
+ return { libId, sourceText };
526
+ }
527
+
528
+ export function pwrFlagSource(): { libId: string; sourceText: string } {
529
+ const libId = 'copperhead_power:PWR_FLAG';
530
+ const sourceText = `(symbol "PWR_FLAG"
531
+ \t(power)
532
+ \t(pin_names (offset 0))
533
+ \t(exclude_from_sim yes)
534
+ \t(in_bom no)
535
+ \t(on_board no)
536
+ \t(property "Reference" "#FLG" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
537
+ \t(property "Value" "PWR_FLAG" (at 0 -3.556 0) (effects (font (size 1.27 1.27)) hide))
538
+ \t(symbol "PWR_FLAG_0_1"
539
+ \t\t(polyline (pts (xy 0 1.27) (xy -1.016 1.905) (xy 0 2.54) (xy 1.016 1.905) (xy 0 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
540
+ \t)
541
+ \t(symbol "PWR_FLAG_1_1"
542
+ \t\t(pin power_out line (at 0 0 90) (length 0) hide
543
+ \t\t\t(name "pwr" (effects (font (size 1.27 1.27))))
544
+ \t\t\t(number "1" (effects (font (size 1.27 1.27))))
545
+ \t\t)
546
+ \t)
547
+ )`;
548
+ return { libId, sourceText };
549
+ }