Initial Schemeta MVP compiler and API

This commit is contained in:
Rbanh 2026-02-16 18:48:51 -05:00
commit 61814349ed
10 changed files with 1003 additions and 0 deletions

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# SCHEMETA (MVP)
AI-Native Schematic Compiler & Visual Teaching Platform.
This MVP implements:
- Schemeta JSON Model validation
- ERC checks (power/output conflicts, floating pins, ground-net checks)
- Deterministic component placement + Manhattan routing
- SVG rendering with interactive data attributes
- HTTP API: `POST /compile`, `POST /analyze`
## Run
```bash
npm install
npm run dev
```
Server defaults to `http://localhost:8787`.
## API
### `POST /analyze`
Input: SJM JSON
Output: validation/ERC errors + warnings + topology summary
### `POST /compile`
Input: SJM JSON
Output: all `analyze` fields + rendered `svg`
## Example
```bash
curl -sS -X POST http://localhost:8787/compile \
-H 'content-type: application/json' \
--data-binary @examples/esp32-audio.json
```
## Notes
- Deterministic rendering is guaranteed by stable sorting (`ref`, `net.name`) and fixed layout constants.
- Wires are derived from net truth; nets remain source-of-truth.
- Current layout is constraint-aware only at basic group ordering level in this MVP.

91
examples/esp32-audio.json Normal file
View File

@ -0,0 +1,91 @@
{
"meta": {
"title": "ESP32 Audio Path"
},
"symbols": {
"esp32_s3_supermini": {
"symbol_id": "esp32_s3_supermini",
"category": "microcontroller",
"body": { "width": 160, "height": 240 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 30, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 60, "type": "ground" },
{ "name": "GPIO5", "number": "10", "side": "right", "offset": 40, "type": "output" },
{ "name": "GPIO6", "number": "11", "side": "right", "offset": 70, "type": "output" },
{ "name": "GPIO7", "number": "12", "side": "right", "offset": 100, "type": "output" }
],
"graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 240 }]
}
},
"dac_i2s": {
"symbol_id": "dac_i2s",
"category": "audio",
"body": { "width": 140, "height": 180 },
"pins": [
{ "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
{ "name": "BCLK", "number": "3", "side": "left", "offset": 80, "type": "input" },
{ "name": "LRCLK", "number": "4", "side": "left", "offset": 110, "type": "input" },
{ "name": "DIN", "number": "5", "side": "left", "offset": 140, "type": "input" },
{ "name": "AOUT", "number": "6", "side": "right", "offset": 90, "type": "analog" }
],
"graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 180 }]
}
},
"amp": {
"symbol_id": "amp",
"category": "output",
"body": { "width": 120, "height": 120 },
"pins": [
{ "name": "5V", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
{ "name": "IN", "number": "3", "side": "left", "offset": 80, "type": "input" },
{ "name": "SPK", "number": "4", "side": "right", "offset": 70, "type": "output" }
],
"graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
}
},
"psu": {
"symbol_id": "psu",
"category": "power",
"body": { "width": 120, "height": 120 },
"pins": [
{ "name": "5V_OUT", "number": "1", "side": "right", "offset": 30, "type": "power_out" },
{ "name": "3V3_OUT", "number": "2", "side": "right", "offset": 60, "type": "power_out" },
{ "name": "GND", "number": "3", "side": "right", "offset": 90, "type": "ground" }
],
"graphics": {
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
}
}
},
"instances": [
{ "ref": "U1", "symbol": "esp32_s3_supermini", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U2", "symbol": "dac_i2s", "properties": { "value": "DAC" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U3", "symbol": "amp", "properties": { "value": "Amp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
{ "ref": "U4", "symbol": "psu", "properties": { "value": "Power" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
],
"nets": [
{ "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }] },
{ "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] },
{ "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }] },
{ "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] },
{ "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] },
{ "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] },
{ "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] }
],
"constraints": {
"groups": [
{ "name": "power_stage", "members": ["U4"], "layout": "cluster" },
{ "name": "compute", "members": ["U1", "U2"], "layout": "cluster" }
],
"alignment": [{ "left_of": "U1", "right_of": "U2" }],
"near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }]
},
"annotations": [
{ "text": "I2S audio chain" }
]
}

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "schemeta",
"version": "0.1.0",
"private": true,
"description": "AI-native schematic compiler and visual teaching platform MVP",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test"
}
}

200
src/analyze.js Normal file
View File

@ -0,0 +1,200 @@
function pinTypeFor(model, ref, pin) {
const instance = model.instances.find((x) => x.ref === ref);
if (!instance) {
return "passive";
}
const symbol = model.symbols[instance.symbol];
const p = symbol.pins.find((x) => x.name === pin);
return p?.type ?? "passive";
}
function buildSignalPaths(model) {
const directedEdges = [];
for (const net of model.nets) {
if (net.class === "power" || net.class === "ground") {
continue;
}
const sources = net.nodes
.filter((n) => {
const t = pinTypeFor(model, n.ref, n.pin);
return t === "output" || t === "power_out";
})
.map((n) => n.ref);
const sinks = net.nodes
.filter((n) => {
const t = pinTypeFor(model, n.ref, n.pin);
return t === "input" || t === "power_in" || t === "analog";
})
.map((n) => n.ref);
for (const s of sources) {
for (const d of sinks) {
if (s !== d) {
directedEdges.push([s, d]);
}
}
}
}
const uniqEdges = new Map();
for (const edge of directedEdges) {
uniqEdges.set(`${edge[0]}->${edge[1]}`, edge);
}
const adj = new Map();
for (const [a, b] of uniqEdges.values()) {
const list = adj.get(a) ?? [];
list.push(b);
adj.set(a, list);
}
const starts = [...adj.keys()].sort();
const result = [];
for (const start of starts) {
const queue = [{ node: start, path: [start] }];
const seen = new Set();
while (queue.length > 0) {
const current = queue.shift();
const key = current.path.join(">>");
if (seen.has(key)) {
continue;
}
seen.add(key);
const next = adj.get(current.node) ?? [];
if (next.length === 0 && current.path.length > 1) {
result.push(current.path);
}
for (const n of next) {
if (!current.path.includes(n)) {
queue.push({ node: n, path: [...current.path, n] });
}
}
}
}
const dedup = new Map();
for (const p of result) {
dedup.set(p.join(" -> "), p);
}
return [...dedup.values()].sort((a, b) => a.join("/").localeCompare(b.join("/")));
}
function extractTopology(model) {
const powerDomains = model.nets
.filter((n) => n.class === "power" || n.class === "ground")
.map((n) => n.name)
.sort();
const clockSources = new Set();
for (const net of model.nets) {
if (net.class !== "clock") {
continue;
}
for (const node of net.nodes) {
const type = pinTypeFor(model, node.ref, node.pin);
if (type === "output" || type === "power_out") {
clockSources.add(node.ref);
}
}
}
return {
power_domains: powerDomains,
clock_sources: [...clockSources].sort(),
signal_paths: buildSignalPaths(model)
};
}
function ercChecks(model) {
const issues = [];
const hasGroundNet = model.nets.some((n) => n.class === "ground");
if (!hasGroundNet) {
issues.push({
code: "ground_net_missing",
message: "No ground net defined.",
severity: "error",
path: "nets"
});
}
const connectedPins = new Set();
for (const net of model.nets) {
const types = net.nodes.map((n) => ({
node: n,
type: pinTypeFor(model, n.ref, n.pin)
}));
for (const t of types) {
connectedPins.add(`${t.node.ref}.${t.node.pin}`);
}
const powerOut = types.filter((t) => t.type === "power_out");
if (powerOut.length > 1) {
issues.push({
code: "multi_power_out",
message: `Net '${net.name}' has multiple power_out pins.`,
severity: "error",
path: `nets.${net.name}`
});
}
const outputs = types.filter((t) => t.type === "output");
if (outputs.length > 1) {
issues.push({
code: "output_conflict",
message: `Net '${net.name}' directly connects multiple output pins.`,
severity: "error",
path: `nets.${net.name}`
});
}
}
for (const instance of model.instances) {
const symbol = model.symbols[instance.symbol];
for (const pin of symbol.pins) {
const key = `${instance.ref}.${pin.name}`;
if ((pin.type === "power_in" || pin.type === "ground") && !connectedPins.has(key)) {
issues.push({
code: "required_power_unconnected",
message: `Required pin '${key}' is unconnected.`,
severity: "warning",
path: `instances.${instance.ref}`
});
}
if (pin.type === "input" && !connectedPins.has(key)) {
issues.push({
code: "floating_input",
message: `Input pin '${key}' is floating.`,
severity: "warning",
path: `instances.${instance.ref}`
});
}
}
}
return issues;
}
export function analyzeModel(model, validationIssues = []) {
const ercIssues = ercChecks(model);
const all = [...validationIssues, ...ercIssues];
const errors = all.filter((x) => x.severity === "error");
const warnings = all.filter((x) => x.severity === "warning");
return {
ok: errors.length === 0,
errors,
warnings,
topology: extractTopology(model)
};
}

52
src/compile.js Normal file
View File

@ -0,0 +1,52 @@
import { analyzeModel } from "./analyze.js";
import { renderSvg } from "./render.js";
import { validateModel } from "./validate.js";
function emptyTopology() {
return {
power_domains: [],
clock_sources: [],
signal_paths: []
};
}
export function compile(payload) {
const validated = validateModel(payload);
if (!validated.model) {
const errors = validated.issues.filter((x) => x.severity === "error");
const warnings = validated.issues.filter((x) => x.severity === "warning");
return {
ok: false,
errors,
warnings,
topology: emptyTopology(),
svg: ""
};
}
const analysis = analyzeModel(validated.model, validated.issues);
const svg = renderSvg(validated.model);
return {
...analysis,
svg
};
}
export function analyze(payload) {
const validated = validateModel(payload);
if (!validated.model) {
const errors = validated.issues.filter((x) => x.severity === "error");
const warnings = validated.issues.filter((x) => x.severity === "warning");
return {
ok: false,
errors,
warnings,
topology: emptyTopology()
};
}
return analyzeModel(validated.model, validated.issues);
}

228
src/layout.js Normal file
View File

@ -0,0 +1,228 @@
const GRID = 20;
const COMPONENT_GAP_X = 220;
const COMPONENT_GAP_Y = 180;
const MARGIN_X = 120;
const MARGIN_Y = 140;
function toGrid(value) {
return Math.round(value / GRID) * GRID;
}
function pinPoint(inst, pin, width, height) {
const x0 = inst.placement.x;
const y0 = inst.placement.y;
switch (pin.side) {
case "left":
return { x: x0, y: y0 + pin.offset };
case "right":
return { x: x0 + width, y: y0 + pin.offset };
case "top":
return { x: x0 + pin.offset, y: y0 };
case "bottom":
return { x: x0 + pin.offset, y: y0 + height };
default:
return { x: x0, y: y0 };
}
}
function componentFlowScore(model, ref) {
let score = 0;
for (const net of model.nets) {
for (const node of net.nodes) {
if (node.ref !== ref) {
continue;
}
if (net.class === "power") {
score -= 2;
}
if (net.class === "clock") {
score += 1;
}
if (net.class === "signal" || net.class === "analog") {
score += 2;
}
if (net.class === "ground") {
score -= 1;
}
}
}
return score;
}
function intersectionPenalty(segments, boxes) {
for (const seg of segments) {
const minX = Math.min(seg.a.x, seg.b.x);
const maxX = Math.max(seg.a.x, seg.b.x);
const minY = Math.min(seg.a.y, seg.b.y);
const maxY = Math.max(seg.a.y, seg.b.y);
for (const b of boxes) {
const overlap = !(maxX < b.x || minX > b.x + b.w || maxY < b.y || minY > b.y + b.h);
if (overlap) {
return true;
}
}
}
return false;
}
function simplifySegments(segments) {
const out = [];
for (const seg of segments) {
if (seg.a.x === seg.b.x && seg.a.y === seg.b.y) {
continue;
}
const prev = out[out.length - 1];
if (!prev) {
out.push(seg);
continue;
}
const prevVertical = prev.a.x === prev.b.x;
const curVertical = seg.a.x === seg.b.x;
if (prevVertical === curVertical && prev.b.x === seg.a.x && prev.b.y === seg.a.y) {
prev.b = { ...seg.b };
continue;
}
out.push(seg);
}
return out;
}
function routeManhattan(a, b, obstacles) {
const straight1 = [
{ a, b: { x: toGrid(b.x), y: toGrid(a.y) } },
{ a: { x: toGrid(b.x), y: toGrid(a.y) }, b }
];
if (!intersectionPenalty(straight1, obstacles)) {
return simplifySegments(straight1);
}
const detourY = toGrid((a.y + b.y) / 2 + 80);
const detourX = toGrid((a.x + b.x) / 2);
const detour = [
{ a, b: { x: a.x, y: detourY } },
{ a: { x: a.x, y: detourY }, b: { x: detourX, y: detourY } },
{ a: { x: detourX, y: detourY }, b: { x: detourX, y: b.y } },
{ a: { x: detourX, y: b.y }, b }
];
return simplifySegments(detour);
}
function pointForNode(model, placed, ref, pin) {
const inst = placed.find((x) => x.ref === ref);
if (!inst) {
return null;
}
const sym = model.symbols[inst.symbol];
const p = sym.pins.find((x) => x.name === pin);
if (!p) {
return null;
}
return pinPoint(inst, p, sym.body.width, sym.body.height);
}
export function layoutAndRoute(model) {
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
const groupMap = new Map();
const groups = model.constraints?.groups ?? [];
groups.forEach((g, i) => g.members.forEach((m) => groupMap.set(m, i)));
const flowSorted = instances
.map((inst) => ({ inst, score: componentFlowScore(model, inst.ref), group: groupMap.get(inst.ref) ?? 9999 }))
.sort((a, b) => a.group - b.group || a.score - b.score || a.inst.ref.localeCompare(b.inst.ref));
const placed = [];
let row = 0;
let col = 0;
for (const item of flowSorted) {
const symbol = model.symbols[item.inst.symbol];
const locked = item.inst.placement.locked ?? false;
let x = item.inst.placement.x;
let y = item.inst.placement.y;
if (x == null || y == null || !locked) {
x = toGrid(MARGIN_X + col * COMPONENT_GAP_X);
y = toGrid(MARGIN_Y + row * COMPONENT_GAP_Y);
}
placed.push({
...item.inst,
placement: {
x,
y,
rotation: item.inst.placement.rotation ?? 0,
locked
}
});
col += 1;
if (col >= 4) {
col = 0;
row += 1;
}
if (symbol.category.toLowerCase().includes("power")) {
placed[placed.length - 1].placement.y = toGrid(MARGIN_Y * 0.5);
}
}
const boxes = placed.map((p) => {
const sym = model.symbols[p.symbol];
return {
x: p.placement.x,
y: p.placement.y,
w: sym.body.width,
h: sym.body.height
};
});
const routed = model.nets.map((net) => {
const routes = [];
if (net.nodes.length < 2) {
return { net, routes };
}
const first = net.nodes[0];
for (let i = 1; i < net.nodes.length; i += 1) {
const target = net.nodes[i];
const a = pointForNode(model, placed, first.ref, first.pin);
const b = pointForNode(model, placed, target.ref, target.pin);
if (!a || !b) {
continue;
}
routes.push(routeManhattan(a, b, boxes));
}
return { net, routes };
});
const width =
Math.max(...placed.map((p) => p.placement.x + model.symbols[p.symbol].body.width), MARGIN_X * 2) + MARGIN_X;
const height =
Math.max(...placed.map((p) => p.placement.y + model.symbols[p.symbol].body.height), MARGIN_Y * 2) + MARGIN_Y;
return {
placed,
routed,
width: toGrid(width),
height: toGrid(height)
};
}
export function netAnchorPoint(net, model, placed) {
const first = net.nodes[0];
if (!first) {
return null;
}
return pointForNode(model, placed, first.ref, first.pin);
}

95
src/render.js Normal file
View File

@ -0,0 +1,95 @@
import { layoutAndRoute, netAnchorPoint } from "./layout.js";
function esc(text) {
return String(text)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function renderSvg(model) {
const layout = layoutAndRoute(model);
const components = layout.placed
.map((inst) => {
const sym = model.symbols[inst.symbol];
const x = inst.placement.x;
const y = inst.placement.y;
const pinSvg = sym.pins
.map((pin) => {
let px = x;
let py = y;
if (pin.side === "left") {
px = x;
py = y + pin.offset;
} else if (pin.side === "right") {
px = x + sym.body.width;
py = y + pin.offset;
} else if (pin.side === "top") {
px = x + pin.offset;
py = y;
} else {
px = x + pin.offset;
py = y + sym.body.height;
}
return [
`<circle cx="${px}" cy="${py}" r="3" fill="#1f2937" data-pin="${esc(pin.name)}" />`,
`<text x="${px + 6}" y="${py - 4}" font-size="10" fill="#374151">${esc(pin.name)}</text>`
].join("");
})
.join("");
return `
<g data-ref="${esc(inst.ref)}" data-symbol="${esc(inst.symbol)}">
<rect x="${x}" y="${y}" width="${sym.body.width}" height="${sym.body.height}" rx="8" fill="#ffffff" stroke="#111827" stroke-width="2" />
<text x="${x + 10}" y="${y + 18}" font-size="12" font-weight="700" fill="#111827">${esc(inst.ref)}</text>
<text x="${x + 10}" y="${y + 34}" font-size="10" fill="#4b5563">${esc(inst.properties?.value ?? inst.symbol)}</text>
${pinSvg}
</g>`;
})
.join("\n");
const wires = layout.routed
.flatMap((rn) =>
rn.routes.map((route) => {
const path = route
.map((seg, idx) => `${idx === 0 ? "M" : "L"} ${seg.a.x} ${seg.a.y} L ${seg.b.x} ${seg.b.y}`)
.join(" ");
return `<path d="${path}" fill="none" stroke="#2563eb" stroke-width="2" data-net="${esc(rn.net.name)}" />`;
})
)
.join("\n");
const labels = model.nets
.map((net) => {
const p = netAnchorPoint(net, model, layout.placed);
if (!p) {
return "";
}
return `<text x="${p.x + 8}" y="${p.y - 8}" font-size="10" fill="#0f766e" data-net-label="${esc(net.name)}">${esc(net.name)}</text>`;
})
.join("\n");
const annotations = (model.annotations ?? [])
.map((a, idx) => {
const x = a.x ?? 16;
const y = a.y ?? 24 + idx * 16;
return `<text x="${x}" y="${y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}" data-engine="schemeta-mvp">
<g data-layer="background">
<rect x="0" y="0" width="${layout.width}" height="${layout.height}" fill="#f9fafb" />
</g>
<g data-layer="components">${components}</g>
<g data-layer="wires">${wires}</g>
<g data-layer="net-labels">${labels}</g>
<g data-layer="annotations">${annotations}</g>
</svg>`;
}

68
src/server.js Normal file
View File

@ -0,0 +1,68 @@
import { createServer } from "node:http";
import { analyze, compile } from "./compile.js";
const PORT = Number(process.env.PORT ?? "8787");
function json(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload, null, 2));
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
resolve(value);
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
}
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
return json(res, 400, { error: "Invalid request." });
}
if (req.method === "GET" && req.url === "/health") {
return json(res, 200, {
service: "schemeta",
status: "ok",
date: new Date().toISOString()
});
}
if (req.method === "POST" && req.url === "/analyze") {
try {
const body = await readBody(req);
return json(res, 200, analyze(body));
} catch {
return json(res, 400, { error: "Invalid JSON payload." });
}
}
if (req.method === "POST" && req.url === "/compile") {
try {
const body = await readBody(req);
return json(res, 200, compile(body));
} catch {
return json(res, 400, { error: "Invalid JSON payload." });
}
}
return json(res, 404, { error: "Not found." });
});
server.listen(PORT, () => {
console.log(`Schemeta server listening on http://localhost:${PORT}`);
});

195
src/validate.js Normal file
View File

@ -0,0 +1,195 @@
const VALID_PIN_TYPES = new Set([
"power_in",
"power_out",
"input",
"output",
"bidirectional",
"passive",
"analog",
"ground"
]);
const VALID_NET_CLASSES = new Set([
"power",
"ground",
"signal",
"analog",
"differential",
"clock",
"bus"
]);
export function validateModel(model) {
const issues = [];
if (!model || typeof model !== "object") {
return {
model: null,
issues: [
{
code: "invalid_root",
message: "Root payload must be an object.",
severity: "error",
path: "$"
}
]
};
}
if (!model.symbols || typeof model.symbols !== "object") {
issues.push({
code: "symbols_missing",
message: "symbols must be an object map.",
severity: "error",
path: "symbols"
});
}
if (!Array.isArray(model.instances)) {
issues.push({
code: "instances_missing",
message: "instances must be an array.",
severity: "error",
path: "instances"
});
}
if (!Array.isArray(model.nets)) {
issues.push({
code: "nets_missing",
message: "nets must be an array.",
severity: "error",
path: "nets"
});
}
if (issues.some((x) => x.severity === "error")) {
return { model: null, issues };
}
const symbolIds = new Set(Object.keys(model.symbols));
for (const [id, sym] of Object.entries(model.symbols)) {
if (sym.symbol_id !== id) {
issues.push({
code: "symbol_id_mismatch",
message: `symbol_id '${sym.symbol_id}' must match map key '${id}'.`,
severity: "error",
path: `symbols.${id}.symbol_id`
});
}
if (!Array.isArray(sym.pins) || sym.pins.length === 0) {
issues.push({
code: "symbol_no_pins",
message: `Symbol '${id}' has no pins.`,
severity: "error",
path: `symbols.${id}.pins`
});
continue;
}
const pinNames = new Set();
for (const pin of sym.pins) {
if (pinNames.has(pin.name)) {
issues.push({
code: "duplicate_pin_name",
message: `Symbol '${id}' has duplicate pin '${pin.name}'.`,
severity: "error",
path: `symbols.${id}.pins`
});
}
pinNames.add(pin.name);
if (!VALID_PIN_TYPES.has(pin.type)) {
issues.push({
code: "invalid_pin_type",
message: `Invalid pin type '${pin.type}' on ${id}.${pin.name}.`,
severity: "error",
path: `symbols.${id}.pins.${pin.name}.type`
});
}
}
}
const refs = new Set();
const instanceSymbol = new Map();
for (const inst of model.instances) {
if (refs.has(inst.ref)) {
issues.push({
code: "duplicate_ref",
message: `Duplicate instance ref '${inst.ref}'.`,
severity: "error",
path: "instances"
});
}
refs.add(inst.ref);
if (!symbolIds.has(inst.symbol)) {
issues.push({
code: "unknown_symbol",
message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`,
severity: "error",
path: `instances.${inst.ref}.symbol`
});
}
instanceSymbol.set(inst.ref, inst.symbol);
}
const netNames = new Set();
for (const net of model.nets) {
if (netNames.has(net.name)) {
issues.push({
code: "duplicate_net_name",
message: `Duplicate net '${net.name}'.`,
severity: "error",
path: `nets.${net.name}`
});
}
netNames.add(net.name);
if (!VALID_NET_CLASSES.has(net.class)) {
issues.push({
code: "invalid_net_class",
message: `Invalid net class '${net.class}' on '${net.name}'.`,
severity: "error",
path: `nets.${net.name}.class`
});
}
if (!Array.isArray(net.nodes) || net.nodes.length < 2) {
issues.push({
code: "net_too_small",
message: `Net '${net.name}' must contain at least two nodes.`,
severity: "error",
path: `nets.${net.name}.nodes`
});
continue;
}
for (const node of net.nodes) {
const symId = instanceSymbol.get(node.ref);
if (!symId) {
issues.push({
code: "unknown_ref_in_net",
message: `Net '${net.name}' references unknown component '${node.ref}'.`,
severity: "error",
path: `nets.${net.name}.nodes`
});
continue;
}
const sym = model.symbols[symId];
const foundPin = sym.pins.some((p) => p.name === node.pin);
if (!foundPin) {
issues.push({
code: "unknown_pin_in_net",
message: `Net '${net.name}' references unknown pin '${node.ref}.${node.pin}'.`,
severity: "error",
path: `nets.${net.name}.nodes`
});
}
}
}
return {
model: issues.some((x) => x.severity === "error") ? null : model,
issues
};
}

19
tests/compile.test.js Normal file
View File

@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { compile } from "../src/compile.js";
import fixture from "../examples/esp32-audio.json" with { type: "json" };
test("compile returns svg and topology for valid model", () => {
const result = compile(fixture);
assert.equal(result.ok, true);
assert.ok(result.svg.includes("<svg"));
assert.ok(result.topology.power_domains.includes("3V3"));
assert.ok(result.topology.clock_sources.includes("U1"));
});
test("compile fails on invalid model", () => {
const bad = { symbols: {}, instances: [], nets: [] };
const result = compile(bad);
assert.equal(result.ok, false);
assert.ok(result.errors.length > 0);
});