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
package/src/kicad/symlib.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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;
|