copperhead 0.9.0 → 0.10.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 +6 -6
- 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 +29 -58
- 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/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- 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/cli.js +75 -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 +177 -25
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +50 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +1 -0
- 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/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -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 +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -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 +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +239 -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/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- 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 +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +28 -61
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +71 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +179 -20
- package/src/commands/doctor.ts +51 -3
- package/src/config.ts +24 -0
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +315 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +6 -0
- 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;
|
package/src/memory/bom-table.ts
CHANGED
|
@@ -35,18 +35,92 @@ export interface TableRow {
|
|
|
35
35
|
* whatever cells they split into — this function never throws.
|
|
36
36
|
*/
|
|
37
37
|
export function parseMarkdownTables(md: string): TableRow[] {
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
return (
|
|
39
|
+
scanTableGroups(md)
|
|
40
|
+
// A group opened by an un-piped line with no delimiter row is not a table:
|
|
41
|
+
// GFM requires the delimiter row for one to exist at all. Without this,
|
|
42
|
+
// ordinary prose such as `Second-source options: Yageo | Vishay | KOA`
|
|
43
|
+
// under a BOM table becomes a part in `export bom`, which `check` cannot
|
|
44
|
+
// catch because `parseCanonicalTables` drops the header-less group.
|
|
45
|
+
.filter((g) => g[0]!.piped || g.some((l) => l.separator))
|
|
46
|
+
.flat()
|
|
47
|
+
.filter((l) => !l.separator)
|
|
48
|
+
.map((l) => ({ cells: l.cells }))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ScannedLine {
|
|
53
|
+
cells: string[];
|
|
54
|
+
separator: boolean;
|
|
55
|
+
/** Whether the source line carried a leading pipe. */
|
|
56
|
+
piped: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Group a document's table lines, one group per table. Both readers below go
|
|
61
|
+
* through this so they cannot disagree about which documents are readable:
|
|
62
|
+
* `check` accepting a BOM.md that `export bom` reads as empty is how a clean
|
|
63
|
+
* check turns into a header-only ordering file.
|
|
64
|
+
*
|
|
65
|
+
* Lines inside a fenced code block are skipped. Docs legitimately show the
|
|
66
|
+
* table format as an example, and an example row is not a part or a pin.
|
|
67
|
+
*/
|
|
68
|
+
function scanTableGroups(md: string): ScannedLine[][] {
|
|
69
|
+
const groups: ScannedLine[][] = [];
|
|
70
|
+
let current: ScannedLine[] | null = null;
|
|
71
|
+
let width = 0; // column count of the open group, set by its first line
|
|
72
|
+
// The open fence's delimiter, or null outside one. A fence closes only on the
|
|
73
|
+
// same character, at least as long (CommonMark). Toggling on any fence-like
|
|
74
|
+
// line would let a shorter or different delimiter *inside* a block end it
|
|
75
|
+
// early, and the example rows after it would be read as parts or pins.
|
|
76
|
+
let fence: { char: string; length: number } | null = null;
|
|
77
|
+
// Split on either line ending: a CRLF document would otherwise leave a
|
|
78
|
+
// trailing \r on every line, and \r is a line terminator that `.` does not
|
|
79
|
+
// match, so the fence patterns below would never fire on a CRLF file.
|
|
80
|
+
for (const line of md.split(/\r?\n/)) {
|
|
40
81
|
const t = line.trim();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
82
|
+
// Matched against the raw line: CommonMark allows a fence up to three
|
|
83
|
+
// spaces of indentation, and four or more makes it content rather than a
|
|
84
|
+
// fence. Trimming first would let an indented delimiter inside a block
|
|
85
|
+
// read as a closer.
|
|
86
|
+
const delim = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
87
|
+
if (delim) {
|
|
88
|
+
const marker = delim[1]!;
|
|
89
|
+
const char = marker[0]!;
|
|
90
|
+
if (!fence) {
|
|
91
|
+
fence = { char, length: marker.length };
|
|
92
|
+
current = null;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// A closing fence carries no info string; anything else stays content.
|
|
96
|
+
if (char === fence.char && marker.length >= fence.length && !delim[2]!.trim()) {
|
|
97
|
+
fence = null;
|
|
98
|
+
current = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (fence || !t.includes('|')) {
|
|
103
|
+
current = null; // a blank, prose, or fenced line terminates the table
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const cells = splitRow(t);
|
|
107
|
+
// Outer pipes make a line unambiguously a table row. Without them, a
|
|
108
|
+
// pipe-bearing prose line (`Legend: A | B`) is indistinguishable from a
|
|
109
|
+
// row by shape alone, so the column count decides: matching the open
|
|
110
|
+
// table's width keeps it as a row, a mismatch ends the table there rather
|
|
111
|
+
// than reading the prose as a part/pin. The line still opens a new group,
|
|
112
|
+
// in case it is itself the header of an un-piped table.
|
|
113
|
+
if (current && !t.startsWith('|') && cells.length !== width) current = null;
|
|
114
|
+
if (!current) {
|
|
115
|
+
current = [];
|
|
116
|
+
width = cells.length;
|
|
117
|
+
groups.push(current);
|
|
118
|
+
}
|
|
119
|
+
// The separator row stays in the group so the header can be located
|
|
120
|
+
// relative to it; it is dropped by the readers above and below.
|
|
121
|
+
current.push({ cells, separator: isSeparatorRow(cells), piped: t.startsWith('|') });
|
|
48
122
|
}
|
|
49
|
-
return
|
|
123
|
+
return groups;
|
|
50
124
|
}
|
|
51
125
|
|
|
52
126
|
/** True for a table's header row. BOM.md and PINOUT.md both lead with a
|
|
@@ -86,35 +160,8 @@ export interface TableRow {
|
|
|
86
160
|
* it loops on finish forever. Resolving by header name fixes that.
|
|
87
161
|
*/
|
|
88
162
|
export function parseCanonicalTables(md: string): Array<{ header: TableRow; rows: TableRow[] }> {
|
|
89
|
-
type Line = { cells: string[]; separator: boolean };
|
|
90
|
-
const groups: Line[][] = [];
|
|
91
|
-
let current: Line[] | null = null;
|
|
92
|
-
let width = 0; // column count of the open group, set by its first line
|
|
93
|
-
for (const line of md.split('\n')) {
|
|
94
|
-
const t = line.trim();
|
|
95
|
-
if (!t.includes('|')) {
|
|
96
|
-
current = null; // a blank or prose line terminates the current table
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const cells = splitRow(t);
|
|
100
|
-
// Outer pipes make a line unambiguously a table row. Without them, a
|
|
101
|
-
// pipe-bearing prose line (`Legend: A | B`) is indistinguishable from a
|
|
102
|
-
// row by shape alone, so the column count decides: matching the open
|
|
103
|
-
// table's width keeps it as a row, a mismatch ends the table there rather
|
|
104
|
-
// than reading the prose as a part/pin. The line still opens a new group,
|
|
105
|
-
// in case it is itself the header of an un-piped table.
|
|
106
|
-
if (current && !t.startsWith('|') && cells.length !== width) current = null;
|
|
107
|
-
if (!current) {
|
|
108
|
-
current = [];
|
|
109
|
-
width = cells.length;
|
|
110
|
-
groups.push(current);
|
|
111
|
-
}
|
|
112
|
-
// The separator row stays in the group so the header can be located
|
|
113
|
-
// relative to it; it is dropped from the rows returned below.
|
|
114
|
-
current.push({ cells, separator: isSeparatorRow(cells) });
|
|
115
|
-
}
|
|
116
163
|
const tables: Array<{ header: TableRow; rows: TableRow[] }> = [];
|
|
117
|
-
for (const g of
|
|
164
|
+
for (const g of scanTableGroups(md)) {
|
|
118
165
|
// The header is the row directly above the separator. Falling back to the
|
|
119
166
|
// first row keeps a table that omits the separator working, and anchoring
|
|
120
167
|
// on the separator means a stray pipe-bearing prose line immediately above
|
package/src/memory/scaffold.ts
CHANGED
|
@@ -69,6 +69,11 @@ What the device is, top-level constraints and budgets.
|
|
|
69
69
|
Every part: refdes, MPN, value, package, and WHY it was chosen. MPNs the agent
|
|
70
70
|
introduces without a datasheet check are flagged UNVERIFIED.
|
|
71
71
|
|
|
72
|
+
Value holds the component value only ("4.7uF", "1M", "500mAh Li-Po") — the
|
|
73
|
+
schematic stage draws it as the symbol's Value field, so a description there
|
|
74
|
+
collides with neighbouring symbols and fails the legibility gate. Prose belongs
|
|
75
|
+
in Rationale. One row per refdes; a grouped row ("C5-C8") matches no symbol.
|
|
76
|
+
|
|
72
77
|
${bomTable(symbols)}
|
|
73
78
|
`,
|
|
74
79
|
'PINOUT.md': `# Pinout
|
|
@@ -119,6 +124,7 @@ Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
|
|
|
119
124
|
- \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
|
|
120
125
|
- \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt
|
|
121
126
|
- \`generatedHashes\`: content hashes of generated docs, used to detect hand edits on re-init
|
|
127
|
+
- \`legibility\` (optional): schematic legibility checker tuning. \`legibility.thresholds\` accepts \`gridPitch\` (default 1.27mm), \`minPitch\` (2.54mm edge-to-edge between symbol bodies), \`utilization\` (0.5 of the usable frame), \`maxWireLength\` (50.8mm), and \`familyCap\` (10 findings per family per sheet). \`legibility.severity.<kind>\` overrides any check family to \`error\`, \`advisory\`, or \`off\` (disables it). Absent keys use the defaults; \`check\` reports findings without failing, the \`create\` schematic stage gates on error-severity findings. \`legibility.score.weights.<metric>\` and \`legibility.score.floor\` tune the quantitative scorer.
|
|
122
128
|
|
|
123
129
|
## constraints.json
|
|
124
130
|
|
package/src/util/redact.ts
CHANGED
|
@@ -15,6 +15,12 @@ const PATTERNS: RegExp[] = [
|
|
|
15
15
|
/npm_[A-Za-z0-9-]{36,}/g,
|
|
16
16
|
/gh[pousr]_[A-Za-z0-9]{36,}/g,
|
|
17
17
|
/github_pat_[A-Za-z0-9_]{22,}/g,
|
|
18
|
+
// Gemini and Groq keys: same idea as sk- above, just a different prefix.
|
|
19
|
+
// Real keys are AIza + 35 chars, but AIzaSy (the common fifth/sixth pair)
|
|
20
|
+
// isn't the only prefix Google issues - match on AIza alone so other
|
|
21
|
+
// Google keys aren't left unredacted.
|
|
22
|
+
/AIza[A-Za-z0-9_-]{20,}/g,
|
|
23
|
+
/gsk_[A-Za-z0-9]{20,}/g,
|
|
18
24
|
];
|
|
19
25
|
|
|
20
26
|
export function redactSecrets(text: string): string {
|