copperhead 0.3.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/LICENSE +201 -0
- package/README.md +87 -0
- package/dist/agent/filetools.js +118 -0
- package/dist/agent/filetools.js.map +1 -0
- package/dist/agent/ledger.js +43 -0
- package/dist/agent/ledger.js.map +1 -0
- package/dist/agent/loop.js +279 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompts.js +47 -0
- package/dist/agent/prompts.js.map +1 -0
- package/dist/agent/providers/anthropic.js +78 -0
- package/dist/agent/providers/anthropic.js.map +1 -0
- package/dist/agent/providers/openai.js +74 -0
- package/dist/agent/providers/openai.js.map +1 -0
- package/dist/agent/tools.js +439 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/agent/transcript.js +60 -0
- package/dist/agent/transcript.js.map +1 -0
- package/dist/agent/types.js +2 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli.js +185 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.js +64 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/create.js +91 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/sync.js +144 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/kicad/cli.js +91 -0
- package/dist/kicad/cli.js.map +1 -0
- package/dist/kicad/report.js +48 -0
- package/dist/kicad/report.js.map +1 -0
- package/dist/kicad/sexp.js +291 -0
- package/dist/kicad/sexp.js.map +1 -0
- package/dist/memory/constraints.js +43 -0
- package/dist/memory/constraints.js.map +1 -0
- package/dist/memory/drift.js +81 -0
- package/dist/memory/drift.js.map +1 -0
- package/dist/memory/scaffold.js +215 -0
- package/dist/memory/scaffold.js.map +1 -0
- package/dist/openspec/cli.js +31 -0
- package/dist/openspec/cli.js.map +1 -0
- package/dist/util/env.js +68 -0
- package/dist/util/env.js.map +1 -0
- package/dist/util/git.js +53 -0
- package/dist/util/git.js.map +1 -0
- package/dist/util/paths.js +25 -0
- package/dist/util/paths.js.map +1 -0
- package/dist/util/redact.js +21 -0
- package/dist/util/redact.js.map +1 -0
- package/dist/util/retry.js +27 -0
- package/dist/util/retry.js.map +1 -0
- package/package.json +73 -0
- package/src/agent/filetools.ts +136 -0
- package/src/agent/ledger.ts +71 -0
- package/src/agent/loop.ts +320 -0
- package/src/agent/prompts.ts +58 -0
- package/src/agent/providers/anthropic.ts +81 -0
- package/src/agent/providers/openai.ts +80 -0
- package/src/agent/tools.ts +481 -0
- package/src/agent/transcript.ts +78 -0
- package/src/agent/types.ts +32 -0
- package/src/cli.ts +188 -0
- package/src/commands/check.ts +85 -0
- package/src/commands/create.ts +125 -0
- package/src/commands/sync.ts +182 -0
- package/src/config.ts +77 -0
- package/src/kicad/cli.ts +106 -0
- package/src/kicad/report.ts +81 -0
- package/src/kicad/sexp.ts +327 -0
- package/src/memory/constraints.ts +73 -0
- package/src/memory/drift.ts +97 -0
- package/src/memory/scaffold.ts +241 -0
- package/src/openspec/cli.ts +43 -0
- package/src/util/env.ts +65 -0
- package/src/util/git.ts +63 -0
- package/src/util/paths.ts +25 -0
- package/src/util/redact.ts +20 -0
- package/src/util/retry.ts +33 -0
package/src/kicad/cli.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execa, ExecaError } from 'execa';
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { normalizeReport, type CheckReport } from './report.js';
|
|
6
|
+
|
|
7
|
+
export class KicadCliMissingError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(
|
|
10
|
+
'kicad-cli not found on PATH. Install KiCad ≥ 8 (https://www.kicad.org/download/) and ensure kicad-cli is available.',
|
|
11
|
+
);
|
|
12
|
+
this.name = 'KicadCliMissingError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function kicadCliVersion(): Promise<string> {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execa('kicad-cli', ['version']);
|
|
19
|
+
return stdout.trim();
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runCheck(
|
|
27
|
+
kind: 'erc' | 'drc',
|
|
28
|
+
filePath: string,
|
|
29
|
+
extraArgs: string[] = [],
|
|
30
|
+
): Promise<CheckReport> {
|
|
31
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'copperhead-'));
|
|
32
|
+
const out = path.join(dir, `${kind}.json`);
|
|
33
|
+
const sub = kind === 'erc' ? ['sch', 'erc'] : ['pcb', 'drc'];
|
|
34
|
+
try {
|
|
35
|
+
await execa(
|
|
36
|
+
'kicad-cli',
|
|
37
|
+
[...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
|
|
38
|
+
{ reject: false },
|
|
39
|
+
).then((res) => {
|
|
40
|
+
if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
|
|
41
|
+
throw new KicadCliMissingError();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const raw = JSON.parse(await readFile(out, 'utf8'));
|
|
45
|
+
return normalizeReport(raw, kind);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function runErc(schPath: string): Promise<CheckReport> {
|
|
52
|
+
return runCheck('erc', schPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function runDrc(pcbPath: string): Promise<CheckReport> {
|
|
56
|
+
return runCheck('drc', pcbPath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FabExportResult {
|
|
60
|
+
produced: string[];
|
|
61
|
+
failed: { artifact: string; reason: string }[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Export the fabrication package (SPEC §2.5 outputs): gerbers + drill, DXF and
|
|
66
|
+
* STEP outline, SVG renders. Each artifact fails independently with a reason so
|
|
67
|
+
* a missing STEP exporter never sinks the rest of the package.
|
|
68
|
+
*/
|
|
69
|
+
export async function exportFab(pcbPath: string, schPath: string | null, outDir: string): Promise<FabExportResult> {
|
|
70
|
+
const result: FabExportResult = { produced: [], failed: [] };
|
|
71
|
+
const jobs: { artifact: string; args: string[] }[] = [
|
|
72
|
+
{ artifact: 'gerbers', args: ['pcb', 'export', 'gerbers', '--output', path.join(outDir, 'gerbers'), pcbPath] },
|
|
73
|
+
{ artifact: 'drill', args: ['pcb', 'export', 'drill', '--output', path.join(outDir, 'gerbers'), pcbPath] },
|
|
74
|
+
{ artifact: 'outline.dxf', args: ['pcb', 'export', 'dxf', '--output', path.join(outDir, 'outline.dxf'), '--layers', 'Edge.Cuts', pcbPath] },
|
|
75
|
+
{ artifact: 'board.step', args: ['pcb', 'export', 'step', '--output', path.join(outDir, 'board.step'), pcbPath] },
|
|
76
|
+
{ artifact: 'board.svg', args: ['pcb', 'export', 'svg', '--output', path.join(outDir, 'board.svg'), '--layers', 'F.Cu,B.Cu,Edge.Cuts', pcbPath] },
|
|
77
|
+
];
|
|
78
|
+
if (schPath) {
|
|
79
|
+
jobs.push({ artifact: 'schematic.svg', args: ['sch', 'export', 'svg', '--output', path.join(outDir, 'renders'), schPath] });
|
|
80
|
+
}
|
|
81
|
+
for (const job of jobs) {
|
|
82
|
+
try {
|
|
83
|
+
await execa('kicad-cli', job.args);
|
|
84
|
+
result.produced.push(job.artifact);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
|
|
87
|
+
result.failed.push({ artifact: job.artifact, reason: String((err as ExecaError).stderr ?? (err as Error).message).slice(0, 200) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Export an SVG render of a schematic or board; returns the output directory. */
|
|
94
|
+
export async function exportSvg(kind: 'sch' | 'pcb', filePath: string, outDir: string): Promise<string> {
|
|
95
|
+
const args =
|
|
96
|
+
kind === 'sch'
|
|
97
|
+
? ['sch', 'export', 'svg', '--output', outDir, filePath]
|
|
98
|
+
: ['pcb', 'export', 'svg', '--output', path.join(outDir, 'board.svg'), '--layers', 'F.Cu,B.Cu,Edge.Cuts', filePath];
|
|
99
|
+
try {
|
|
100
|
+
await execa('kicad-cli', args);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return outDir;
|
|
106
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface ViolationItem {
|
|
2
|
+
description: string;
|
|
3
|
+
x?: number;
|
|
4
|
+
y?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Violation {
|
|
8
|
+
severity: 'error' | 'warning' | string;
|
|
9
|
+
type: string;
|
|
10
|
+
description: string;
|
|
11
|
+
sheet?: string;
|
|
12
|
+
items: ViolationItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CheckReport {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
source: 'erc' | 'drc';
|
|
18
|
+
violations: Violation[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RawItem {
|
|
22
|
+
description?: string;
|
|
23
|
+
pos?: { x?: number; y?: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RawViolation {
|
|
27
|
+
severity?: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
items?: RawItem[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normViolation(v: RawViolation, sheet?: string): Violation {
|
|
34
|
+
return {
|
|
35
|
+
severity: v.severity ?? 'error',
|
|
36
|
+
type: v.type ?? 'unknown',
|
|
37
|
+
description: v.description ?? '',
|
|
38
|
+
...(sheet !== undefined ? { sheet } : {}),
|
|
39
|
+
items: (v.items ?? []).map((i) => ({
|
|
40
|
+
description: i.description ?? '',
|
|
41
|
+
...(i.pos?.x !== undefined ? { x: i.pos.x } : {}),
|
|
42
|
+
...(i.pos?.y !== undefined ? { y: i.pos.y } : {}),
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize kicad-cli ERC and DRC JSON reports into one shape. ERC nests
|
|
49
|
+
* violations per sheet; DRC has top-level `violations` plus `unconnected_items`
|
|
50
|
+
* and `schematic_parity`. Tolerant of missing fields across KiCad versions.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeReport(raw: unknown, source: 'erc' | 'drc'): CheckReport {
|
|
53
|
+
const r = raw as {
|
|
54
|
+
sheets?: { path?: string; violations?: RawViolation[] }[];
|
|
55
|
+
violations?: RawViolation[];
|
|
56
|
+
unconnected_items?: RawViolation[];
|
|
57
|
+
schematic_parity?: RawViolation[];
|
|
58
|
+
};
|
|
59
|
+
const violations: Violation[] = [];
|
|
60
|
+
for (const sheet of r.sheets ?? []) {
|
|
61
|
+
for (const v of sheet.violations ?? []) violations.push(normViolation(v, sheet.path));
|
|
62
|
+
}
|
|
63
|
+
for (const v of r.violations ?? []) violations.push(normViolation(v));
|
|
64
|
+
for (const v of r.unconnected_items ?? []) violations.push(normViolation(v));
|
|
65
|
+
for (const v of r.schematic_parity ?? []) violations.push(normViolation(v));
|
|
66
|
+
return { ok: violations.length === 0, source, violations };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatViolations(report: CheckReport): string {
|
|
70
|
+
if (report.ok) return `${report.source.toUpperCase()}: clean`;
|
|
71
|
+
const lines = [`${report.source.toUpperCase()}: ${report.violations.length} violation(s)`];
|
|
72
|
+
for (const v of report.violations) {
|
|
73
|
+
const where = v.sheet ? ` [sheet ${v.sheet}]` : '';
|
|
74
|
+
lines.push(` ${v.severity} ${v.type}${where}: ${v.description}`);
|
|
75
|
+
for (const i of v.items) {
|
|
76
|
+
const pos = i.x !== undefined ? ` @ (${i.x}, ${i.y})` : '';
|
|
77
|
+
lines.push(` - ${i.description}${pos}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal READ-ONLY s-expression tooling for .kicad_sch files. This module
|
|
6
|
+
* never serializes: edits to KiCad files happen as anchored text replaces on
|
|
7
|
+
* the original source (SPEC §1.3 / design D4).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SexpNode = string | SexpNode[];
|
|
11
|
+
|
|
12
|
+
export function parseSexp(text: string): SexpNode[] {
|
|
13
|
+
const tokens: string[] = [];
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < text.length) {
|
|
16
|
+
const c = text[i]!;
|
|
17
|
+
if (c === '(' || c === ')') {
|
|
18
|
+
tokens.push(c);
|
|
19
|
+
i++;
|
|
20
|
+
} else if (c === '"') {
|
|
21
|
+
let j = i + 1;
|
|
22
|
+
let s = '';
|
|
23
|
+
while (j < text.length && text[j] !== '"') {
|
|
24
|
+
if (text[j] === '\\' && j + 1 < text.length) {
|
|
25
|
+
s += text[j + 1];
|
|
26
|
+
j += 2;
|
|
27
|
+
} else {
|
|
28
|
+
s += text[j];
|
|
29
|
+
j++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
tokens.push(JSON.stringify(s));
|
|
33
|
+
i = j + 1;
|
|
34
|
+
} else if (/\s/.test(c)) {
|
|
35
|
+
i++;
|
|
36
|
+
} else {
|
|
37
|
+
let j = i;
|
|
38
|
+
while (j < text.length && !/[\s()"]/.test(text[j]!)) j++;
|
|
39
|
+
tokens.push(text.slice(i, j));
|
|
40
|
+
i = j;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let pos = 0;
|
|
44
|
+
function parseNode(): SexpNode {
|
|
45
|
+
const tok = tokens[pos++]!;
|
|
46
|
+
if (tok === '(') {
|
|
47
|
+
const list: SexpNode[] = [];
|
|
48
|
+
while (pos < tokens.length && tokens[pos] !== ')') list.push(parseNode());
|
|
49
|
+
pos++; // consume ')'
|
|
50
|
+
return list;
|
|
51
|
+
}
|
|
52
|
+
return tok.startsWith('"') ? (JSON.parse(tok) as string) : tok;
|
|
53
|
+
}
|
|
54
|
+
const roots: SexpNode[] = [];
|
|
55
|
+
while (pos < tokens.length) roots.push(parseNode());
|
|
56
|
+
return roots;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const isList = (n: SexpNode): n is SexpNode[] => Array.isArray(n);
|
|
60
|
+
const tag = (n: SexpNode): string | null => (isList(n) && typeof n[0] === 'string' ? n[0] : null);
|
|
61
|
+
|
|
62
|
+
export function children(node: SexpNode, name: string): SexpNode[][] {
|
|
63
|
+
if (!isList(node)) return [];
|
|
64
|
+
return node.filter((c): c is SexpNode[] => tag(c) === name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function child(node: SexpNode, name: string): SexpNode[] | undefined {
|
|
68
|
+
return children(node, name)[0];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function atomAt(node: SexpNode[] | undefined, idx: number): string | undefined {
|
|
72
|
+
const v = node?.[idx];
|
|
73
|
+
return typeof v === 'string' ? v : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function property(sym: SexpNode[], key: string): string | undefined {
|
|
77
|
+
for (const p of children(sym, 'property')) {
|
|
78
|
+
if (atomAt(p, 1) === key) return atomAt(p, 2);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SchematicSymbol {
|
|
84
|
+
ref: string;
|
|
85
|
+
value: string;
|
|
86
|
+
footprint: string;
|
|
87
|
+
libId: string;
|
|
88
|
+
sheet: string;
|
|
89
|
+
at: { x: number; y: number; rot: number };
|
|
90
|
+
uuid: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PinDef {
|
|
94
|
+
number: string;
|
|
95
|
+
name: string;
|
|
96
|
+
x: number;
|
|
97
|
+
y: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ParsedSheet {
|
|
101
|
+
filePath: string;
|
|
102
|
+
sheetName: string;
|
|
103
|
+
root: SexpNode[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadSheets(rootSch: string): Promise<ParsedSheet[]> {
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
const out: ParsedSheet[] = [];
|
|
109
|
+
async function load(file: string, sheetName: string): Promise<void> {
|
|
110
|
+
const abs = path.resolve(file);
|
|
111
|
+
if (seen.has(abs)) return;
|
|
112
|
+
seen.add(abs);
|
|
113
|
+
const text = await readFile(abs, 'utf8');
|
|
114
|
+
const root = parseSexp(text)[0];
|
|
115
|
+
if (root === undefined || !isList(root)) {
|
|
116
|
+
throw new Error(`not a KiCad s-expression file: ${file}`);
|
|
117
|
+
}
|
|
118
|
+
out.push({ filePath: abs, sheetName, root });
|
|
119
|
+
for (const sheet of children(root, 'sheet')) {
|
|
120
|
+
const sub = property(sheet, 'Sheetfile') ?? property(sheet, 'Sheet file');
|
|
121
|
+
const name = property(sheet, 'Sheetname') ?? property(sheet, 'Sheet name') ?? 'sheet';
|
|
122
|
+
if (sub) await load(path.resolve(path.dirname(abs), sub), name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await load(rootSch, '/');
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Pin definitions per lib symbol name, in symbol coordinates. */
|
|
130
|
+
function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
|
|
131
|
+
const map = new Map<string, PinDef[]>();
|
|
132
|
+
const libs = child(root, 'lib_symbols');
|
|
133
|
+
if (!libs) return map;
|
|
134
|
+
for (const sym of children(libs, 'symbol')) {
|
|
135
|
+
const name = atomAt(sym, 1);
|
|
136
|
+
if (!name) continue;
|
|
137
|
+
const pins: PinDef[] = [];
|
|
138
|
+
const walk = (n: SexpNode): void => {
|
|
139
|
+
if (!isList(n)) return;
|
|
140
|
+
if (tag(n) === 'pin') {
|
|
141
|
+
const at = child(n, 'at');
|
|
142
|
+
const num = atomAt(child(n, 'number'), 1);
|
|
143
|
+
const pinName = atomAt(child(n, 'name'), 1);
|
|
144
|
+
if (at && num !== undefined) {
|
|
145
|
+
pins.push({
|
|
146
|
+
number: num,
|
|
147
|
+
name: pinName ?? '~',
|
|
148
|
+
x: parseFloat(atomAt(at, 1) ?? '0'),
|
|
149
|
+
y: parseFloat(atomAt(at, 2) ?? '0'),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const c of n) walk(c);
|
|
154
|
+
};
|
|
155
|
+
walk(sym);
|
|
156
|
+
map.set(name, pins);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Symbol-space → schematic-space transform (schematic Y grows downward). */
|
|
162
|
+
export function pinAbsolute(
|
|
163
|
+
symAt: { x: number; y: number; rot: number },
|
|
164
|
+
mirror: 'x' | 'y' | null,
|
|
165
|
+
pin: { x: number; y: number },
|
|
166
|
+
): { x: number; y: number } {
|
|
167
|
+
let px = pin.x;
|
|
168
|
+
let py = pin.y;
|
|
169
|
+
if (mirror === 'y') px = -px;
|
|
170
|
+
if (mirror === 'x') py = -py;
|
|
171
|
+
const theta = (symAt.rot * Math.PI) / 180;
|
|
172
|
+
const rx = px * Math.cos(theta) - py * Math.sin(theta);
|
|
173
|
+
const ry = px * Math.sin(theta) + py * Math.cos(theta);
|
|
174
|
+
return { x: round(symAt.x + rx), y: round(symAt.y - ry) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const round = (n: number): number => Math.round(n * 10000) / 10000;
|
|
178
|
+
const key = (x: number, y: number): string => `${round(x)},${round(y)}`;
|
|
179
|
+
|
|
180
|
+
class UnionFind {
|
|
181
|
+
private parent = new Map<string, string>();
|
|
182
|
+
find(k: string): string {
|
|
183
|
+
let p = this.parent.get(k);
|
|
184
|
+
if (p === undefined) {
|
|
185
|
+
this.parent.set(k, k);
|
|
186
|
+
return k;
|
|
187
|
+
}
|
|
188
|
+
if (p !== k) {
|
|
189
|
+
p = this.find(p);
|
|
190
|
+
this.parent.set(k, p);
|
|
191
|
+
}
|
|
192
|
+
return p;
|
|
193
|
+
}
|
|
194
|
+
union(a: string, b: string): void {
|
|
195
|
+
this.parent.set(this.find(a), this.find(b));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function symbolsOf(sheet: ParsedSheet): { node: SexpNode[]; sym: SchematicSymbol; mirror: 'x' | 'y' | null }[] {
|
|
200
|
+
const out: { node: SexpNode[]; sym: SchematicSymbol; mirror: 'x' | 'y' | null }[] = [];
|
|
201
|
+
for (const s of children(sheet.root, 'symbol')) {
|
|
202
|
+
const libId = atomAt(child(s, 'lib_id'), 1);
|
|
203
|
+
if (!libId) continue; // lib_symbols entries have no lib_id child
|
|
204
|
+
const at = child(s, 'at');
|
|
205
|
+
const mirrorAtom = atomAt(child(s, 'mirror'), 1);
|
|
206
|
+
out.push({
|
|
207
|
+
node: s,
|
|
208
|
+
mirror: mirrorAtom === 'x' || mirrorAtom === 'y' ? mirrorAtom : null,
|
|
209
|
+
sym: {
|
|
210
|
+
ref: property(s, 'Reference') ?? '?',
|
|
211
|
+
value: property(s, 'Value') ?? '',
|
|
212
|
+
footprint: property(s, 'Footprint') ?? '',
|
|
213
|
+
libId,
|
|
214
|
+
sheet: sheet.sheetName,
|
|
215
|
+
at: {
|
|
216
|
+
x: parseFloat(atomAt(at, 1) ?? '0'),
|
|
217
|
+
y: parseFloat(atomAt(at, 2) ?? '0'),
|
|
218
|
+
rot: parseFloat(atomAt(at, 3) ?? '0'),
|
|
219
|
+
},
|
|
220
|
+
uuid: atomAt(child(s, 'uuid'), 1) ?? '',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const isPowerSymbol = (libId: string): boolean => libId.startsWith('power:');
|
|
228
|
+
|
|
229
|
+
/** One row per real component (power symbols excluded), across all sheets. */
|
|
230
|
+
export async function listSymbols(rootSch: string): Promise<SchematicSymbol[]> {
|
|
231
|
+
const sheets = await loadSheets(rootSch);
|
|
232
|
+
const out: SchematicSymbol[] = [];
|
|
233
|
+
for (const sheet of sheets) {
|
|
234
|
+
for (const { sym } of symbolsOf(sheet)) {
|
|
235
|
+
if (!isPowerSymbol(sym.libId)) out.push(sym);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out.sort((a, b) => a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** All net names visible via labels and power symbols, across all sheets. */
|
|
242
|
+
export async function listNets(rootSch: string): Promise<string[]> {
|
|
243
|
+
const sheets = await loadSheets(rootSch);
|
|
244
|
+
const names = new Set<string>();
|
|
245
|
+
for (const sheet of sheets) {
|
|
246
|
+
for (const kind of ['label', 'global_label', 'hierarchical_label']) {
|
|
247
|
+
for (const l of children(sheet.root, kind)) {
|
|
248
|
+
const name = atomAt(l, 1);
|
|
249
|
+
if (name) names.add(name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const { sym } of symbolsOf(sheet)) {
|
|
253
|
+
if (isPowerSymbol(sym.libId)) names.add(sym.value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return [...names].sort();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface PinNet {
|
|
260
|
+
ref: string;
|
|
261
|
+
pinNumber: string;
|
|
262
|
+
pinName: string;
|
|
263
|
+
net: string | null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Geometric connectivity per sheet: pins, labels, and wire endpoints that share
|
|
268
|
+
* coordinates (or are joined by wires) form a group; a group's net name comes
|
|
269
|
+
* from its labels or power symbols. Good enough for docs scaffolding and drift
|
|
270
|
+
* checks; not a full netlister.
|
|
271
|
+
*/
|
|
272
|
+
export async function pinNets(rootSch: string): Promise<PinNet[]> {
|
|
273
|
+
const sheets = await loadSheets(rootSch);
|
|
274
|
+
const out: PinNet[] = [];
|
|
275
|
+
for (const sheet of sheets) {
|
|
276
|
+
const pinDefs = libPinDefs(sheet.root);
|
|
277
|
+
const uf = new UnionFind();
|
|
278
|
+
const netNameAt = new Map<string, string>();
|
|
279
|
+
|
|
280
|
+
for (const w of children(sheet.root, 'wire')) {
|
|
281
|
+
const pts = children(child(w, 'pts') ?? [], 'xy').map((xy) => ({
|
|
282
|
+
x: parseFloat(atomAt(xy, 1) ?? '0'),
|
|
283
|
+
y: parseFloat(atomAt(xy, 2) ?? '0'),
|
|
284
|
+
}));
|
|
285
|
+
for (let i = 1; i < pts.length; i++) {
|
|
286
|
+
uf.union(key(pts[0]!.x, pts[0]!.y), key(pts[i]!.x, pts[i]!.y));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const kind of ['label', 'global_label', 'hierarchical_label']) {
|
|
290
|
+
for (const l of children(sheet.root, kind)) {
|
|
291
|
+
const name = atomAt(l, 1);
|
|
292
|
+
const at = child(l, 'at');
|
|
293
|
+
if (!name || !at) continue;
|
|
294
|
+
const k = key(parseFloat(atomAt(at, 1) ?? '0'), parseFloat(atomAt(at, 2) ?? '0'));
|
|
295
|
+
uf.find(k);
|
|
296
|
+
netNameAt.set(k, name);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const symPins: { sym: SchematicSymbol; pin: PinDef; k: string }[] = [];
|
|
301
|
+
for (const { sym, mirror } of symbolsOf(sheet)) {
|
|
302
|
+
const defs = pinDefs.get(sym.libId) ?? [];
|
|
303
|
+
for (const pin of defs) {
|
|
304
|
+
const abs = pinAbsolute(sym.at, mirror, pin);
|
|
305
|
+
const k = key(abs.x, abs.y);
|
|
306
|
+
uf.find(k);
|
|
307
|
+
if (isPowerSymbol(sym.libId)) {
|
|
308
|
+
netNameAt.set(k, sym.value);
|
|
309
|
+
} else {
|
|
310
|
+
symPins.push({ sym, pin, k });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const groupNet = new Map<string, string>();
|
|
316
|
+
for (const [k, name] of netNameAt) groupNet.set(uf.find(k), name);
|
|
317
|
+
for (const { sym, pin, k } of symPins) {
|
|
318
|
+
out.push({
|
|
319
|
+
ref: sym.ref,
|
|
320
|
+
pinNumber: pin.number,
|
|
321
|
+
pinName: pin.name,
|
|
322
|
+
net: groupNet.get(uf.find(k)) ?? null,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Machine-readable constraint registry (SPEC §2.6). Built simultaneously with
|
|
7
|
+
* the docs: every stated/assumed/discovered constraint lands in both in the
|
|
8
|
+
* same tool turn. `affects` drives propagation.
|
|
9
|
+
*/
|
|
10
|
+
export interface Constraint {
|
|
11
|
+
min?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
forbidden?: string[];
|
|
14
|
+
value?: string | number;
|
|
15
|
+
source: string;
|
|
16
|
+
affects: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ConstraintRegistry = Record<string, Constraint>;
|
|
20
|
+
|
|
21
|
+
export function constraintsPath(repoRoot: string): string {
|
|
22
|
+
return path.join(repoRoot, '.copperhead', 'constraints.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadConstraints(repoRoot: string): Promise<ConstraintRegistry> {
|
|
26
|
+
const p = constraintsPath(repoRoot);
|
|
27
|
+
if (!existsSync(p)) return {};
|
|
28
|
+
return JSON.parse(await readFile(p, 'utf8')) as ConstraintRegistry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function saveConstraint(
|
|
32
|
+
repoRoot: string,
|
|
33
|
+
key: string,
|
|
34
|
+
constraint: Constraint,
|
|
35
|
+
): Promise<ConstraintRegistry> {
|
|
36
|
+
const registry = await loadConstraints(repoRoot);
|
|
37
|
+
registry[key] = constraint;
|
|
38
|
+
const p = constraintsPath(repoRoot);
|
|
39
|
+
await mkdir(path.dirname(p), { recursive: true });
|
|
40
|
+
await writeFile(p, JSON.stringify(registry, null, 2) + '\n', 'utf8');
|
|
41
|
+
return registry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ConstraintViolation {
|
|
45
|
+
key: string;
|
|
46
|
+
description: string;
|
|
47
|
+
source: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mechanical validation where possible (SPEC §2.6): forbidden pins against the
|
|
52
|
+
* pinout, numeric budget keys surfaced for the doc-level checks. Geometry
|
|
53
|
+
* checks are out of scope for Phase 1.
|
|
54
|
+
*/
|
|
55
|
+
export function checkForbiddenPins(
|
|
56
|
+
registry: ConstraintRegistry,
|
|
57
|
+
pinNets: { ref: string; pinName: string; net: string | null }[],
|
|
58
|
+
): ConstraintViolation[] {
|
|
59
|
+
const violations: ConstraintViolation[] = [];
|
|
60
|
+
for (const [key, c] of Object.entries(registry)) {
|
|
61
|
+
if (!c.forbidden?.length) continue;
|
|
62
|
+
for (const pn of pinNets) {
|
|
63
|
+
if (pn.net && c.forbidden.includes(pn.pinName)) {
|
|
64
|
+
violations.push({
|
|
65
|
+
key,
|
|
66
|
+
description: `${pn.ref} pin ${pn.pinName} is connected to net ${pn.net} but is forbidden by ${key}`,
|
|
67
|
+
source: c.source,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return violations;
|
|
73
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { listSymbols, pinNets, type SchematicSymbol } from '../kicad/sexp.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Doc-vs-schematic drift check (AC-2.3). BOM.md and PINOUT.md use fixed table
|
|
8
|
+
* columns (the parseable contract, design D9); free-prose docs are not checked.
|
|
9
|
+
*/
|
|
10
|
+
export interface DriftMismatch {
|
|
11
|
+
doc: string;
|
|
12
|
+
claim: string;
|
|
13
|
+
actual: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TableRow {
|
|
17
|
+
cells: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseMarkdownTables(md: string): TableRow[] {
|
|
21
|
+
const rows: TableRow[] = [];
|
|
22
|
+
for (const line of md.split('\n')) {
|
|
23
|
+
const t = line.trim();
|
|
24
|
+
if (!t.startsWith('|')) continue;
|
|
25
|
+
const cells = t
|
|
26
|
+
.split('|')
|
|
27
|
+
.slice(1, -1)
|
|
28
|
+
.map((c) => c.trim());
|
|
29
|
+
if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
|
|
30
|
+
rows.push({ cells });
|
|
31
|
+
}
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isHeader = (row: TableRow): boolean =>
|
|
36
|
+
row.cells.some((c) => /^(refdes|pin)$/i.test(c));
|
|
37
|
+
|
|
38
|
+
export async function checkDrift(repoRoot: string, docsDir: string, schematic: string): Promise<DriftMismatch[]> {
|
|
39
|
+
const mismatches: DriftMismatch[] = [];
|
|
40
|
+
const schPath = path.join(repoRoot, schematic);
|
|
41
|
+
const symbols = await listSymbols(schPath);
|
|
42
|
+
const byRef = new Map<string, SchematicSymbol>(symbols.map((s) => [s.ref, s]));
|
|
43
|
+
|
|
44
|
+
const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
|
|
45
|
+
if (existsSync(bomPath)) {
|
|
46
|
+
const rows = parseMarkdownTables(await readFile(bomPath, 'utf8')).filter((r) => !isHeader(r));
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const [ref, value, footprint] = row.cells;
|
|
50
|
+
if (!ref) continue;
|
|
51
|
+
seen.add(ref);
|
|
52
|
+
const sym = byRef.get(ref);
|
|
53
|
+
if (!sym) {
|
|
54
|
+
mismatches.push({ doc: 'BOM.md', claim: `${ref} exists`, actual: `${ref} not in schematic` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (value !== undefined && value !== sym.value) {
|
|
58
|
+
mismatches.push({ doc: 'BOM.md', claim: `${ref} value ${value}`, actual: `${ref} value ${sym.value}` });
|
|
59
|
+
}
|
|
60
|
+
if (footprint !== undefined && footprint !== '' && footprint !== sym.footprint) {
|
|
61
|
+
mismatches.push({
|
|
62
|
+
doc: 'BOM.md',
|
|
63
|
+
claim: `${ref} footprint ${footprint}`,
|
|
64
|
+
actual: `${ref} footprint ${sym.footprint}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const sym of symbols) {
|
|
69
|
+
if (!seen.has(sym.ref)) {
|
|
70
|
+
mismatches.push({ doc: 'BOM.md', claim: `${sym.ref} absent`, actual: `${sym.ref} (${sym.value}) in schematic` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pinoutPath = path.join(repoRoot, docsDir, 'PINOUT.md');
|
|
76
|
+
if (existsSync(pinoutPath)) {
|
|
77
|
+
const nets = await pinNets(schPath);
|
|
78
|
+
const netOf = new Map(nets.map((p) => [`${p.ref}:${p.pinNumber}`, p.net]));
|
|
79
|
+
const rows = parseMarkdownTables(await readFile(pinoutPath, 'utf8')).filter((r) => !isHeader(r));
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
const [ref, pinNumber, , net] = row.cells;
|
|
82
|
+
if (!ref || !pinNumber) continue;
|
|
83
|
+
const k = `${ref}:${pinNumber}`;
|
|
84
|
+
if (!netOf.has(k)) {
|
|
85
|
+
mismatches.push({ doc: 'PINOUT.md', claim: `${k} exists`, actual: `${k} not in schematic` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const actual = netOf.get(k) ?? 'NC';
|
|
89
|
+
const claimed = net === undefined || net === '' ? 'NC' : net;
|
|
90
|
+
if (claimed !== actual) {
|
|
91
|
+
mismatches.push({ doc: 'PINOUT.md', claim: `${k} net ${claimed}`, actual: `${k} net ${actual}` });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return mismatches;
|
|
97
|
+
}
|