copperhead 0.8.0 → 0.9.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 (41) hide show
  1. package/README.md +9 -1
  2. package/dist/agent/loop.js +38 -4
  3. package/dist/agent/loop.js.map +1 -1
  4. package/dist/agent/providers/openai.js +28 -6
  5. package/dist/agent/providers/openai.js.map +1 -1
  6. package/dist/agent/response-cache.js +18 -2
  7. package/dist/agent/response-cache.js.map +1 -1
  8. package/dist/cli.js +2 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/create.js +113 -9
  11. package/dist/commands/create.js.map +1 -1
  12. package/dist/commands/doctor.js +164 -11
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/config.js +60 -4
  15. package/dist/config.js.map +1 -1
  16. package/dist/kicad/cli.js +7 -26
  17. package/dist/kicad/cli.js.map +1 -1
  18. package/dist/kicad/sexp.js +23 -4
  19. package/dist/kicad/sexp.js.map +1 -1
  20. package/dist/memory/bom-table.js +48 -10
  21. package/dist/memory/bom-table.js.map +1 -1
  22. package/dist/openspec/cli.js +2 -1
  23. package/dist/openspec/cli.js.map +1 -1
  24. package/dist/util/preflight.js +17 -0
  25. package/dist/util/preflight.js.map +1 -1
  26. package/dist/util/redact.js +6 -2
  27. package/dist/util/redact.js.map +1 -1
  28. package/package.json +2 -5
  29. package/src/agent/loop.ts +60 -4
  30. package/src/agent/providers/openai.ts +38 -4
  31. package/src/agent/response-cache.ts +17 -1
  32. package/src/cli.ts +2 -2
  33. package/src/commands/create.ts +107 -10
  34. package/src/commands/doctor.ts +171 -12
  35. package/src/config.ts +83 -2
  36. package/src/kicad/cli.ts +6 -19
  37. package/src/kicad/sexp.ts +24 -4
  38. package/src/memory/bom-table.ts +51 -10
  39. package/src/openspec/cli.ts +3 -2
  40. package/src/util/preflight.ts +18 -0
  41. package/src/util/redact.ts +6 -2
package/src/kicad/sexp.ts CHANGED
@@ -224,15 +224,33 @@ function symbolsOf(sheet: ParsedSheet): { node: SexpNode[]; sym: SchematicSymbol
224
224
  return out;
225
225
  }
226
226
 
227
- const isPowerSymbol = (libId: string): boolean => libId.startsWith('power:');
227
+ function collectPowerSymbols(sheets: ParsedSheet[]): Set<string> {
228
+ const set = new Set<string>();
229
+ for (const sheet of sheets) {
230
+ const libs = child(sheet.root, 'lib_symbols');
231
+ if (!libs) continue;
232
+ for (const sym of children(libs, 'symbol')) {
233
+ const name = atomAt(sym, 1);
234
+ const p = child(sym, 'power');
235
+ if (name && p !== undefined && atomAt(p, 1) !== 'no') {
236
+ set.add(name);
237
+ }
238
+ }
239
+ }
240
+ return set;
241
+ }
242
+
243
+ const isPowerSymbol = (libId: string, powerSyms: Set<string>): boolean =>
244
+ libId.startsWith('power:') || powerSyms.has(libId);
228
245
 
229
246
  /** One row per real component (power symbols excluded), across all sheets. */
230
247
  export async function listSymbols(rootSch: string): Promise<SchematicSymbol[]> {
231
248
  const sheets = await loadSheets(rootSch);
249
+ const powerSyms = collectPowerSymbols(sheets);
232
250
  const out: SchematicSymbol[] = [];
233
251
  for (const sheet of sheets) {
234
252
  for (const { sym } of symbolsOf(sheet)) {
235
- if (!isPowerSymbol(sym.libId)) out.push(sym);
253
+ if (!isPowerSymbol(sym.libId, powerSyms)) out.push(sym);
236
254
  }
237
255
  }
238
256
  return out.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
@@ -241,6 +259,7 @@ export async function listSymbols(rootSch: string): Promise<SchematicSymbol[]> {
241
259
  /** All net names visible via labels and power symbols, across all sheets. */
242
260
  export async function listNets(rootSch: string): Promise<string[]> {
243
261
  const sheets = await loadSheets(rootSch);
262
+ const powerSyms = collectPowerSymbols(sheets);
244
263
  const names = new Set<string>();
245
264
  for (const sheet of sheets) {
246
265
  for (const kind of ['label', 'global_label', 'hierarchical_label']) {
@@ -250,7 +269,7 @@ export async function listNets(rootSch: string): Promise<string[]> {
250
269
  }
251
270
  }
252
271
  for (const { sym } of symbolsOf(sheet)) {
253
- if (isPowerSymbol(sym.libId)) names.add(sym.value);
272
+ if (isPowerSymbol(sym.libId, powerSyms)) names.add(sym.value);
254
273
  }
255
274
  }
256
275
  return [...names].sort();
@@ -271,6 +290,7 @@ export interface PinNet {
271
290
  */
272
291
  export async function pinNets(rootSch: string): Promise<PinNet[]> {
273
292
  const sheets = await loadSheets(rootSch);
293
+ const powerSyms = collectPowerSymbols(sheets);
274
294
  const out: PinNet[] = [];
275
295
  for (const sheet of sheets) {
276
296
  const pinDefs = libPinDefs(sheet.root);
@@ -304,7 +324,7 @@ export async function pinNets(rootSch: string): Promise<PinNet[]> {
304
324
  const abs = pinAbsolute(sym.at, mirror, pin);
305
325
  const k = key(abs.x, abs.y);
306
326
  uf.find(k);
307
- if (isPowerSymbol(sym.libId)) {
327
+ if (isPowerSymbol(sym.libId, powerSyms)) {
308
328
  netNameAt.set(k, sym.value);
309
329
  } else {
310
330
  symPins.push({ sym, pin, k });
@@ -9,6 +9,25 @@ export interface TableRow {
9
9
  cells: string[];
10
10
  }
11
11
 
12
+ /**
13
+ * Split one row into trimmed cells. The outer pipes are optional, because they
14
+ * are optional in GitHub-flavored markdown: `Refdes | Pin | Net` renders as a
15
+ * table exactly like `| Refdes | Pin | Net |` does, and a hand- or LLM-authored
16
+ * doc may legitimately be written either way.
17
+ */
18
+ function splitRow(line: string): string[] {
19
+ return line
20
+ .replace(/^\|/, '')
21
+ .replace(/\|$/, '')
22
+ .split('|')
23
+ .map((c) => c.trim());
24
+ }
25
+
26
+ /** `|---|:--:|` and friends: the row separating a header from its data. */
27
+ function isSeparatorRow(cells: string[]): boolean {
28
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
29
+ }
30
+
12
31
  /**
13
32
  * Parses every markdown pipe-table row out of a document, across however
14
33
  * many tables the file contains, skipping separator rows (e.g. `|---|---|`).
@@ -67,28 +86,50 @@ export interface TableRow {
67
86
  * it loops on finish forever. Resolving by header name fixes that.
68
87
  */
69
88
  export function parseCanonicalTables(md: string): Array<{ header: TableRow; rows: TableRow[] }> {
70
- const groups: TableRow[][] = [];
71
- let current: TableRow[] | null = null;
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
72
93
  for (const line of md.split('\n')) {
73
94
  const t = line.trim();
74
- if (!t.startsWith('|')) {
95
+ if (!t.includes('|')) {
75
96
  current = null; // a blank or prose line terminates the current table
76
97
  continue;
77
98
  }
78
- const cells = t
79
- .split('|')
80
- .slice(1, -1)
81
- .map((c) => c.trim());
82
- if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row: stays within the table
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;
83
107
  if (!current) {
84
108
  current = [];
109
+ width = cells.length;
85
110
  groups.push(current);
86
111
  }
87
- current.push({ cells });
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) });
88
115
  }
89
116
  const tables: Array<{ header: TableRow; rows: TableRow[] }> = [];
90
117
  for (const g of groups) {
91
- if (g.length && isHeader(g[0]!)) tables.push({ header: g[0]!, rows: g.slice(1) });
118
+ // The header is the row directly above the separator. Falling back to the
119
+ // first row keeps a table that omits the separator working, and anchoring
120
+ // on the separator means a stray pipe-bearing prose line immediately above
121
+ // a table no longer hides it.
122
+ const sep = g.findIndex((l) => l.separator);
123
+ const headerIdx = sep > 0 ? sep - 1 : 0;
124
+ const header = g[headerIdx];
125
+ if (!header || header.separator || !isHeader({ cells: header.cells })) continue;
126
+ tables.push({
127
+ header: { cells: header.cells },
128
+ rows: g
129
+ .slice(headerIdx + 1)
130
+ .filter((l) => !l.separator)
131
+ .map((l) => ({ cells: l.cells })),
132
+ });
92
133
  }
93
134
  return tables;
94
135
  }
@@ -1,6 +1,7 @@
1
1
  import { execa } from 'execa';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { isNotFoundError } from '../util/preflight.js';
4
5
 
5
6
  /**
6
7
  * OpenSpec is driven as a subprocess, same pattern as kicad-cli (SPEC §2.6).
@@ -17,8 +18,8 @@ async function openspec(repo: string, args: string[]): Promise<OpenSpecResult> {
17
18
  const { stdout, stderr } = await execa('openspec', args, { cwd: repo });
18
19
  return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n') };
19
20
  } catch (err) {
20
- const e = err as { stdout?: string; stderr?: string; code?: string; message: string };
21
- if (e.code === 'ENOENT') {
21
+ const e = err as { stdout?: string; stderr?: string; code?: string; message: string; exitCode?: number };
22
+ if (isNotFoundError(e)) {
22
23
  return { ok: false, output: 'openspec CLI not found on PATH (npm i -g @fission-ai/openspec)' };
23
24
  }
24
25
  return { ok: false, output: [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message };
@@ -23,6 +23,24 @@ export function formatPreflightFailure(reason: string, why: string, remedy: stri
23
23
  return [reason, '', `why it failed: ${why}`, 'to fix:', ...steps].join('\n');
24
24
  }
25
25
 
26
+ export function isNotFoundError(err: any): boolean {
27
+ if (!err) return false;
28
+ if (err.code === 'ENOENT') return true;
29
+ if (process.platform === 'win32') {
30
+ const msg = String(err.stderr || err.message || '');
31
+ if (err.exitCode === 9009) {
32
+ return (
33
+ msg.includes('is not recognized') ||
34
+ msg.includes('cannot find the path specified')
35
+ );
36
+ }
37
+ if (err.exitCode === 1) {
38
+ return msg.includes('is not recognized');
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
26
44
  /** Default minimum free space to start a run: 2 GiB. A create run emits gerbers,
27
45
  * STEP, SVG renders and KiCad local history; 2 GiB is comfortably above a
28
46
  * single board's output while still catching a nearly-full disk. */
@@ -5,10 +5,14 @@
5
5
  */
6
6
  const PATTERNS: RegExp[] = [
7
7
  /sk-[A-Za-z0-9_-]+/g,
8
- /Bearer\s+[A-Za-z0-9._-]{16,}/g,
8
+ // Auth schemes are case-insensitive (RFC 7235 §2.1), and the token charset has
9
+ // to include base64's +, / and = or the match stops at the first one and the
10
+ // tail of the secret gets written out verbatim.
11
+ /Bearer\s+[A-Za-z0-9._+/=-]{16,}/gi,
9
12
  // Registry and forge tokens: a transcript that quotes a publish command or a
10
13
  // failing CI log can carry these just as easily as a model API key.
11
- /npm_[A-Za-z0-9]{36,}/g,
14
+ // npm also issues UUID-shaped tokens, so the charset allows dashes.
15
+ /npm_[A-Za-z0-9-]{36,}/g,
12
16
  /gh[pousr]_[A-Za-z0-9]{36,}/g,
13
17
  /github_pat_[A-Za-z0-9_]{22,}/g,
14
18
  ];