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
@@ -18,10 +18,37 @@
18
18
  * instead of guessing.
19
19
  */
20
20
 
21
- import { readFile, readdir, access } from 'node:fs/promises';
21
+ import { readFile, readdir, access, stat } from 'node:fs/promises';
22
22
  import path from 'node:path';
23
23
  import { parseSexp, children, child, isList, type SexpNode } from './sexp.js';
24
24
 
25
+ /** Numeric-aware, case-insensitive pin-number ordering ("2" < "10", "a1" ~ "A1")
26
+ * shared by every pin-table renderer so orderings never diverge. */
27
+ const pinNumberCollator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
28
+ export const comparePinNumbers = (a: string, b: string): number => pinNumberCollator.compare(a, b);
29
+
30
+ /**
31
+ * Symbol-name index of a `.kicad_sym`, cached by mtime. The stock set is ~220
32
+ * files that never change within a run, but discovery-heavy callers (the
33
+ * dossier's per-part search, the recovery probe loop) would otherwise re-read
34
+ * and re-scrape all of them per query. Returns null when the file is
35
+ * unreadable, so callers skip it the same way a failed readFile did.
36
+ */
37
+ const symbolNameCache = new Map<string, { mtimeMs: number; names: string[] }>();
38
+ async function libSymbolNames(file: string): Promise<string[] | null> {
39
+ try {
40
+ const { mtimeMs } = await stat(file);
41
+ const hit = symbolNameCache.get(file);
42
+ if (hit && hit.mtimeMs === mtimeMs) return hit.names;
43
+ const text = await readFile(file, 'utf8');
44
+ const names = [...text.matchAll(/^\s*\(symbol\s+"([^"]+)"/gm)].map((m) => m[1]!);
45
+ symbolNameCache.set(file, { mtimeMs, names });
46
+ return names;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
25
52
  const tag = (n: SexpNode): string | null => (isList(n) && typeof n[0] === 'string' ? n[0] : null);
26
53
  const atomAt = (node: SexpNode[] | undefined, idx: number): string | undefined => {
27
54
  const v = node?.[idx];
@@ -44,8 +71,13 @@ export interface LibPin {
44
71
  * Candidate directories holding KiCad's stock `.kicad_sym` libraries, most
45
72
  * specific first. Env overrides win (KiCad exports these), then the standard
46
73
  * install locations for Linux/macOS/Windows. Only existing dirs are returned.
74
+ *
75
+ * @param winRoot test seam for the Windows `C:\Program Files\KiCad` root;
76
+ * defaults to the real path. Without this, the Windows version-directory
77
+ * discovery below can only be exercised on an actual Windows machine with
78
+ * KiCad installed at the default location.
47
79
  */
48
- export async function symbolSearchDirs(env = process.env): Promise<string[]> {
80
+ export async function symbolSearchDirs(env = process.env, winRoot = 'C:/Program Files/KiCad'): Promise<string[]> {
49
81
  const fromEnv = [
50
82
  env.KICAD_SYMBOL_DIR,
51
83
  env.KICAD10_SYMBOL_DIR,
@@ -56,10 +88,15 @@ export async function symbolSearchDirs(env = process.env): Promise<string[]> {
56
88
  '/usr/share/kicad/symbols',
57
89
  '/usr/local/share/kicad/symbols',
58
90
  '/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols',
59
- 'C:/Program Files/KiCad/share/kicad/symbols',
91
+ // Windows installs under a version-numbered directory
92
+ // (C:\Program Files\KiCad\10.0\...), unlike Linux/macOS, so there is no
93
+ // single fixed path here. Discovered below instead.
60
94
  ];
61
95
  const out: string[] = [];
62
- for (const dir of [...fromEnv, ...defaults]) {
96
+ // An env override is exclusive: a caller pinning KICAD_SYMBOL_DIR at an
97
+ // isolated directory (tests, a pinned library set) must get only that
98
+ // directory back, not the machine's stock install appended after it.
99
+ for (const dir of fromEnv.length ? fromEnv : defaults) {
63
100
  try {
64
101
  await access(dir);
65
102
  if (!out.includes(dir)) out.push(dir);
@@ -67,11 +104,52 @@ export async function symbolSearchDirs(env = process.env): Promise<string[]> {
67
104
  // not present on this machine; skip
68
105
  }
69
106
  }
107
+ // Windows: KiCad's own installer picks the version directory
108
+ // (`10.0`, `9.0`, `8.0`, ...), so no fixed path is ever correct. List
109
+ // `C:\Program Files\KiCad` and check each version folder instead. Sorted
110
+ // descending so a machine with more than one version prefers the newest.
111
+ // Skipped entirely when an env override is set: "env overrides win" means
112
+ // exactly that, not "env overrides win, plus whatever else this discovers"
113
+ // — a caller pointing KICAD_SYMBOL_DIR at an isolated directory (tests, a
114
+ // pinned library set) must get only that directory back.
115
+ const kicadRoot = winRoot;
116
+ if (fromEnv.length === 0) {
117
+ try {
118
+ const entries = await readdir(kicadRoot, { withFileTypes: true });
119
+ const versions = entries
120
+ .filter((e) => e.isDirectory())
121
+ .map((e) => e.name)
122
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
123
+ for (const version of versions) {
124
+ const dir = `${kicadRoot}/${version}/share/kicad/symbols`;
125
+ try {
126
+ await access(dir);
127
+ if (!out.includes(dir)) out.push(dir);
128
+ } catch {
129
+ // this version folder has no symbols dir; skip
130
+ }
131
+ }
132
+ // Some installs put share/ directly under the KiCad root with no
133
+ // version directory; probe that layout after the versioned ones.
134
+ const flat = `${kicadRoot}/share/kicad/symbols`;
135
+ try {
136
+ await access(flat);
137
+ if (!out.includes(flat)) out.push(flat);
138
+ } catch {
139
+ // no non-versioned layout either; skip
140
+ }
141
+ } catch {
142
+ // C:\Program Files\KiCad doesn't exist on this machine (not Windows, or KiCad not installed here); skip
143
+ }
144
+ }
70
145
  return out;
71
146
  }
72
147
 
73
- /** Path to `<lib>.kicad_sym` in the first search dir that has it, or null. */
148
+ /** Path to `<lib>.kicad_sym` in the first search dir that has it, or null.
149
+ * The nickname is model-supplied text: a separator or `..` in it would escape
150
+ * the search directories, so such a nickname resolves to nothing. */
74
151
  export async function findLibraryFile(lib: string, dirs: string[]): Promise<string | null> {
152
+ if (lib.includes('/') || lib.includes('\\') || lib.includes('..')) return null;
75
153
  for (const dir of dirs) {
76
154
  const p = path.join(dir, `${lib}.kicad_sym`);
77
155
  try {
@@ -84,6 +162,237 @@ export async function findLibraryFile(lib: string, dirs: string[]): Promise<stri
84
162
  return null;
85
163
  }
86
164
 
165
+ /**
166
+ * Rank a library's symbol names against a queried name that failed to resolve.
167
+ * Comparison is case-insensitive with separators (`_`, `-`, `.`) stripped, so
168
+ * `Rotary_Encoder` finds `RotaryEncoder_Switch` and `microSD_Card` finds
169
+ * `Micro_SD_Card` — KiCad's own naming is inconsistent on exactly this axis.
170
+ * Sub-unit children (`Name_<unit>_<style>`) are internal structure, not
171
+ * placeable symbols, and never candidates. The name-inside-query direction
172
+ * requires at least 3 significant characters: a stock library is full of
173
+ * single-letter generics (`R`, `C`, `D`) that would otherwise "match" nearly
174
+ * any part number and crowd out the real near-miss.
175
+ */
176
+ const canonSymName = (s: string): string => s.toLowerCase().replace(/[_\-.]/g, '');
177
+
178
+ export interface RankedSymbolName {
179
+ name: string;
180
+ /** 0 = exact (separator-insensitive), 1 = prefix, 2 = substring,
181
+ * 3 = name-inside-query, 4 = one-edit family variant. */
182
+ rank: number;
183
+ }
184
+
185
+ /** Names must be this long before a single edit is distinctive enough to match. */
186
+ const MIN_EDIT_MATCH_LEN = 5;
187
+
188
+ /**
189
+ * The shared one-edit rule for "same part family, variant spelling": within one
190
+ * edit (SHT40 vs SHT4x, STM32F103C8T6 vs STM32F103C8Tx), except a
191
+ * digit-for-digit substitution, which names a *different real part*
192
+ * (TPS22860 vs TPS22810) rather than a family wildcard. Used by both resolvers
193
+ * so search and cross-library discovery can never disagree about what counts
194
+ * as a near-miss.
195
+ */
196
+ function oneEditFamilyVariant(a: string, b: string): boolean {
197
+ if (a.length < MIN_EDIT_MATCH_LEN || b.length < MIN_EDIT_MATCH_LEN) return false;
198
+ if (editDistanceWithin(a, b, 1) > 1) return false;
199
+ if (a.length === b.length) {
200
+ for (let i = 0; i < a.length; i++) {
201
+ if (a[i] !== b[i]) {
202
+ return !(/[0-9]/.test(a[i]!) && /[0-9]/.test(b[i]!));
203
+ }
204
+ }
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /** `closestSymbolNames` with the match rank retained, so a caller merging
210
+ * candidates from several libraries can order them by how well they match
211
+ * rather than by which library happened to be scanned first. */
212
+ export function rankSymbolNames(
213
+ names: Iterable<string>,
214
+ query: string,
215
+ cap = 8,
216
+ ): RankedSymbolName[] {
217
+ const canon = canonSymName;
218
+ const q = canon(query);
219
+ if (q.length < 2) return [];
220
+ const ranked: RankedSymbolName[] = [];
221
+ const seen = new Set<string>();
222
+ for (const name of names) {
223
+ if (seen.has(name) || /_\d+_\d+$/.test(name)) continue;
224
+ seen.add(name);
225
+ const c = canon(name);
226
+ let rank: number;
227
+ if (c === q) rank = 0;
228
+ else if (c.startsWith(q)) rank = 1;
229
+ else if (c.includes(q)) rank = 2;
230
+ else if (c.length >= 3 && q.includes(c)) rank = 3;
231
+ // Without this tier, search declared family-variant stock symbols absent
232
+ // (STM32F103C8T6 vs the installed STM32F103C8Tx) while
233
+ // findSymbolAcrossLibraries found them, and the "machine-verified" dossier
234
+ // rendered a search miss as a false absence claim.
235
+ else if (oneEditFamilyVariant(q, c)) rank = 4;
236
+ else continue;
237
+ ranked.push({ name, rank });
238
+ }
239
+ ranked.sort((a, b) => a.rank - b.rank || a.name.length - b.name.length || (a.name < b.name ? -1 : 1));
240
+ return ranked.slice(0, cap);
241
+ }
242
+
243
+ export function closestSymbolNames(names: Iterable<string>, query: string, cap = 8): string[] {
244
+ return rankSymbolNames(names, query, cap).map((r) => r.name);
245
+ }
246
+
247
+ /** Every installed `<lib>.kicad_sym`, keyed by library nickname; the first
248
+ * search dir claims a nickname, matching `findLibraryFile`'s precedence. */
249
+ export async function listInstalledLibraries(dirs: string[]): Promise<Map<string, string>> {
250
+ const map = new Map<string, string>();
251
+ for (const dir of dirs) {
252
+ let entries: string[];
253
+ try {
254
+ entries = await readdir(dir);
255
+ } catch {
256
+ continue;
257
+ }
258
+ for (const e of entries) {
259
+ if (!e.endsWith('.kicad_sym')) continue;
260
+ const lib = e.slice(0, -'.kicad_sym'.length);
261
+ if (!map.has(lib)) map.set(lib, path.join(dir, e));
262
+ }
263
+ }
264
+ return map;
265
+ }
266
+
267
+ export interface CrossLibraryMatch {
268
+ lib: string;
269
+ /** The symbol name actually found (may differ from the query on a fuzzy hit,
270
+ * e.g. `SHT4x` for a query of `SHT40`) — the suggested lib_id must be built
271
+ * from this, never from the caller's original query, or a fuzzy suggestion
272
+ * points at a lib_id that does not exist. */
273
+ name: string;
274
+ /** true when `name` matched exactly; false for a fuzzy hit. */
275
+ exact: boolean;
276
+ }
277
+
278
+ /** Levenshtein edit distance, capped: returns `cap + 1` once the true distance
279
+ * would exceed `cap`, so a caller doing a cheap "is this close enough" check
280
+ * never pays for the full O(n·m) table on two names that are obviously
281
+ * unrelated. */
282
+ function editDistanceWithin(a: string, b: string, cap: number): number {
283
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
284
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
285
+ for (let i = 1; i <= a.length; i++) {
286
+ const cur = [i];
287
+ let rowMin = i;
288
+ for (let j = 1; j <= b.length; j++) {
289
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
290
+ const v = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + cost);
291
+ cur.push(v);
292
+ if (v < rowMin) rowMin = v;
293
+ }
294
+ if (rowMin > cap) return cap + 1; // whole row exceeded cap: no cell can recover
295
+ prev = cur;
296
+ }
297
+ return prev[b.length]!;
298
+ }
299
+
300
+ /**
301
+ * A part is unresolvable at its guessed `lib_id`, but the guess itself is often
302
+ * right about the *part*, just wrong about which stock file it lives in (a
303
+ * chip's library nickname is not derivable from its part number). Rather than
304
+ * fail outright, check every other discovered `.kicad_sym` for a symbol with
305
+ * this exact name; when none matches exactly, fall back to the same
306
+ * closest-candidate substring heuristic `resolveLibrarySymbol` already uses
307
+ * within one file, just applied across all of them.
308
+ *
309
+ * Returns matches sorted exact-first; the caller decides what "found" means
310
+ * (one exact hit resolves unambiguously, several means the agent must pick).
311
+ */
312
+ export async function findSymbolAcrossLibraries(
313
+ name: string,
314
+ dirs: string[],
315
+ excludeLib?: string,
316
+ ): Promise<CrossLibraryMatch[]> {
317
+ const q = name.toLowerCase();
318
+ const exact: CrossLibraryMatch[] = [];
319
+ const fuzzy: CrossLibraryMatch[] = [];
320
+ for (const [lib, file] of await listInstalledLibraries(dirs)) {
321
+ // `excludeLib` is only known not to hold this *exact* name; it can still
322
+ // hold the near-miss the caller actually wants (SHT40 guessed in
323
+ // Sensor_Humidity, where the real symbol is SHT4x). Skip its exact check,
324
+ // never its fuzzy one.
325
+ const skipExact = lib === excludeLib;
326
+ const names = await libSymbolNames(file);
327
+ if (!names) continue;
328
+ if (!skipExact && names.includes(name)) {
329
+ exact.push({ lib, name, exact: true });
330
+ continue;
331
+ }
332
+ // The "does the query contain the library name" direction is only
333
+ // meaningful for a reasonably specific name (>= 3 chars): a stock library
334
+ // is full of single-letter generics ("R", "C", "L", "D", "U", "Q"), and
335
+ // "does <any part number> contain the letter R" is true for nearly
336
+ // everything, which would fuzzy-match almost any query against them.
337
+ const MIN_SHORT_NAME_LEN = 3;
338
+ // Substring alone misses the most common real near-miss: a datasheet part
339
+ // number against a library's family name, differing mid-string rather than
340
+ // at either end (SHT40 vs SHT4x). Neither contains the other; the shared
341
+ // one-edit family-variant rule catches it while refusing digit-for-digit
342
+ // swaps (TPS22860 vs TPS22810 are different real parts).
343
+ // The matched candidate string, not just whether one exists: a fuzzy hit
344
+ // must report the real symbol name (`n`) so the caller can build a lib_id
345
+ // that actually resolves. Suggesting `${lib}:${query}` back would name a
346
+ // part that was never found in that library — the exact regression this
347
+ // fuzzy path exists to avoid repeating.
348
+ const fuzzyHit = names.find((n) => {
349
+ const lower = n.toLowerCase();
350
+ if (lower.includes(q)) return true;
351
+ if (lower.length >= MIN_SHORT_NAME_LEN && q.includes(lower)) return true;
352
+ return oneEditFamilyVariant(q, lower);
353
+ });
354
+ if (fuzzyHit) {
355
+ fuzzy.push({ lib, name: fuzzyHit, exact: false });
356
+ }
357
+ }
358
+ return [...exact, ...fuzzy];
359
+ }
360
+
361
+ /**
362
+ * Search every installed library for symbols matching a part name, returning
363
+ * ranked `Lib:Name` lib_ids with exact (separator-insensitive) matches first.
364
+ * This is the discovery primitive a bare lib_id probe cannot provide: a wrong
365
+ * library-nickname guess is otherwise indistinguishable from a missing part,
366
+ * and KiCad's nicknames rarely follow from the part number (TPS61165DBV lives
367
+ * in Driver_LED, AudioJack3 in Connector_Audio, INA226 in Sensor_Energy).
368
+ */
369
+ export async function searchInstalledSymbols(
370
+ query: string,
371
+ dirs: string[],
372
+ cap = 24,
373
+ ): Promise<string[]> {
374
+ if (canonSymName(query).length < 2) return [];
375
+ // Rank globally, not per library: a library scanned early contributes only
376
+ // weak matches, and capping in scan order would drop a later library's
377
+ // stronger candidate — the exact case this tool exists to surface, since a
378
+ // caller reaching for it has already guessed the nickname wrong.
379
+ const hits: { libId: string; rank: number; name: string }[] = [];
380
+ for (const [lib, file] of await listInstalledLibraries(dirs)) {
381
+ const names = await libSymbolNames(file);
382
+ if (!names) continue;
383
+ for (const { name, rank } of rankSymbolNames(names, query, 4)) {
384
+ hits.push({ libId: `${lib}:${name}`, rank, name });
385
+ }
386
+ }
387
+ hits.sort(
388
+ (a, b) =>
389
+ a.rank - b.rank ||
390
+ a.name.length - b.name.length ||
391
+ (a.libId < b.libId ? -1 : a.libId > b.libId ? 1 : 0),
392
+ );
393
+ return hits.slice(0, cap).map((h) => h.libId);
394
+ }
395
+
87
396
  /** Collect pins (number, name, electrical type) from a `(symbol …)` node,
88
397
  * including its nested unit sub-symbols. Same walk `libPinDefs` uses, plus the
89
398
  * electrical-type atom that pin-position parsing does not need. */
@@ -107,6 +416,19 @@ export function pinsOfSymbolNode(sym: SexpNode[]): LibPin[] {
107
416
  return pins;
108
417
  }
109
418
 
419
+ /** Highest unit index among a symbol's `Name_<unit>_<style>` children: 1 for a
420
+ * single-unit part. Unit 0 holds common graphics, so it never raises the count.
421
+ * The drafting engine refuses symbols with units >= 2 (they share symbol-space
422
+ * pin coordinates), which makes this the drawability half of availability. */
423
+ function maxUnitIndex(sym: SexpNode[]): number {
424
+ let max = 1;
425
+ for (const c of children(sym, 'symbol')) {
426
+ const m = atomAt(c, 1)?.match(/_(\d+)_\d+$/);
427
+ if (m) max = Math.max(max, Number(m[1]));
428
+ }
429
+ return max;
430
+ }
431
+
110
432
  /** The top-level `(symbol "name" …)` entries of a parsed `.kicad_sym` root. */
111
433
  function librarySymbols(root: SexpNode[]): Map<string, SexpNode[]> {
112
434
  const map = new Map<string, SexpNode[]>();
@@ -127,13 +449,18 @@ export async function resolveLibrarySymbol(
127
449
  libId: string,
128
450
  dirs: string[],
129
451
  ): Promise<
130
- | { status: 'ok'; pins: LibPin[] }
452
+ | { status: 'ok'; pins: LibPin[]; units: number }
131
453
  | { status: 'no-symbol'; candidates: string[] }
132
454
  | { status: 'no-library' }
455
+ | { status: 'found-elsewhere'; libIds: string[] }
133
456
  > {
134
457
  const [lib, name] = libId.includes(':') ? [libId.slice(0, libId.indexOf(':')), libId.slice(libId.indexOf(':') + 1)] : ['', libId];
135
458
  const file = await findLibraryFile(lib, dirs);
136
- if (!file) return { status: 'no-library' };
459
+ if (!file) {
460
+ const elsewhere = await findSymbolAcrossLibraries(name, dirs, lib);
461
+ if (elsewhere.length) return { status: 'found-elsewhere', libIds: elsewhere.map((m) => `${m.lib}:${m.name}`) };
462
+ return { status: 'no-library' };
463
+ }
137
464
  const root = parseSexp(await readFile(file, 'utf8'))[0];
138
465
  if (root === undefined || !isList(root)) return { status: 'no-library' };
139
466
  const symbols = librarySymbols(root);
@@ -145,27 +472,29 @@ export async function resolveLibrarySymbol(
145
472
  const sym = symbols.get(current);
146
473
  if (!sym) break;
147
474
  const pins = pinsOfSymbolNode(sym);
148
- if (pins.length) return { status: 'ok', pins };
475
+ if (pins.length) return { status: 'ok', pins, units: maxUnitIndex(sym) };
149
476
  // no pins of its own → follow an `extends` base if present
150
477
  const base = atomAt(child(sym, 'extends'), 1);
151
- if (!base) return { status: 'ok', pins }; // genuinely pinless (e.g. a graphic)
478
+ if (!base) return { status: 'ok', pins, units: maxUnitIndex(sym) }; // genuinely pinless (e.g. a graphic)
152
479
  current = base;
153
480
  }
154
481
 
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);
482
+ // The guessed library exists and simply lacks this name, so its own ranked
483
+ // near-matches are the most useful answer and come first: the caller named
484
+ // the right file and mistyped the part. Only when that file offers nothing
485
+ // is a different library worth suggesting (mirrors SymbolSource.resolve).
486
+ const candidates = closestSymbolNames(symbols.keys(), name);
487
+ if (candidates.length) return { status: 'no-symbol', candidates };
488
+
489
+ const elsewhere = await findSymbolAcrossLibraries(name, dirs, lib);
490
+ if (elsewhere.length) return { status: 'found-elsewhere', libIds: elsewhere.map((m) => `${m.lib}:${m.name}`) };
491
+
163
492
  return { status: 'no-symbol', candidates };
164
493
  }
165
494
 
166
495
  export interface SymbolFinding {
167
496
  libId: string;
168
- kind: 'no-library' | 'no-symbol' | 'pin-count' | 'pin-mismatch';
497
+ kind: 'no-library' | 'no-symbol' | 'wrong-library' | 'pin-count' | 'pin-mismatch';
169
498
  detail: string;
170
499
  }
171
500
 
@@ -219,6 +548,23 @@ export async function verifySchematicSymbols(
219
548
  });
220
549
  continue;
221
550
  }
551
+ if (resolved.status === 'found-elsewhere') {
552
+ // A distinct kind from 'no-library': the part IS verifiable, just filed
553
+ // under another name — callers counting real issues must include it,
554
+ // while 'no-library' stays "could not check". When every suggestion
555
+ // shares the queried nickname the library was right and only the symbol
556
+ // name is a variant; saying "wrong library" there contradicts the fix.
557
+ const nick = entry.libId.includes(':') ? entry.libId.slice(0, entry.libId.indexOf(':')) : '';
558
+ const sameLib = nick !== '' && resolved.libIds.every((id) => id.startsWith(`${nick}:`));
559
+ findings.push({
560
+ libId: entry.libId,
561
+ kind: 'wrong-library',
562
+ detail: sameLib
563
+ ? `"${entry.libId}" does not exist in that library — closest real name: ${resolved.libIds.join(' or ')}. The library was right; use the real symbol name.`
564
+ : `"${entry.libId}" names the wrong library, not the wrong part: use ${resolved.libIds.join(' or ')} instead.`,
565
+ });
566
+ continue;
567
+ }
222
568
  checked++;
223
569
  const real = resolved.pins;
224
570
  const authored = entry.pins;