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.
- package/NOTICE +1 -1
- package/README.md +47 -8
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +63 -72
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +186 -15
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +100 -586
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +181 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +83 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +19 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +135 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +5310 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +504 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +220 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +608 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +431 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +265 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +14 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +13 -8
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +73 -78
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +183 -13
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -628
- package/src/agent/transcript.ts +6 -0
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +154 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +183 -21
- package/src/commands/doctor.ts +85 -6
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +56 -8
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +173 -0
- package/src/kicad/draft/engine.ts +5274 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +549 -0
- package/src/kicad/emit.ts +300 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +695 -0
- package/src/kicad/score.ts +502 -0
- package/src/kicad/sexp.ts +346 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/mcp/server.ts +560 -0
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +14 -1
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- 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
|
+
}
|