skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
← Commit history
standard 0.24.0: engine 0.21.0 lint_library (#28)
10 files changed
+219−8
README.md+1@@ -158,6 +158,7 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.24.0: engine 0.21.0 adds `lint_library` (#28): the shared library rules (pads, courtyard, origin, pins against pads, part numbers) with a fixed severity each, from the footprints and symbols a bridge already reads. Version 0.23.8: coverage reads KiCad bridge 1.0.88 (214 of 239: variants, and sch_zoom partial). Version 0.23.7: coverage reads Fusion 360 bridge 1.13.23 (219 of 239: diagnostics, instances, progress, foreground; panel and selection unsupported). Version 0.23.6: `variants` moves from platform to native (Altium, Fusion and KiCad 10 all have assembly variants), so the standard is 239 verbs; coverage reads Fusion 360 bridge 1.13.22 (variants and silkscreen_prep, partial).
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.20.0"+version = "0.21.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.20.0"+version = "0.21.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/README.md+1@@ -29,6 +29,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs |+| `lint_library` | library | is a symbol or footprint library fit to ship (standard #28): the shared rules with a fixed severity each (`no_pads`, `zero_size_pad`, `duplicate_pad_number`, `no_courtyard`, `origin_off_part`, `pin_without_pad`, `pad_without_pin`, `no_part_number`, ...; `rules` in the reply lists them), checked from the footprints and symbols the bridge sends; a key left out is not checked, a key sent empty is a finding; the bridge's own findings (`tool_refuses_file`) pass through | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |
engine/src/lib.rs+4−2@@ -4,7 +4,7 @@ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, library?, args?}` (see `input`). use serde_json::Value; pub mod assembly;@@ -21,6 +21,7 @@ pub mod input; pub mod landpattern; pub mod lengths; pub mod lengthtune;+pub mod liblint; pub mod pairroute; pub mod panel; pub mod report;@@ -39,7 +40,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "lint_library", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -65,6 +66,7 @@ fn answer(check: &str, input: &Value) -> Value { "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input), "length_tune" => lengthtune::length_tune(input),+ "lint_library" => liblint::lint_library(input), "net_lengths" => lengths::net_lengths(input), "panelize" => panel::panelize(input), "pin_pad_check" => schreview::pin_pad_check(input),
engine/src/liblint.rsadded+207@@ -0,0 +1,207 @@+//! Library lint (EDA Bridge Standard #28): is a symbol or footprint library fit to ship? The content rules every bridge+//! can share, from the footprints and symbols it already reads; a bridge adds its tool's own (KiCad's+//! `tool_refuses_file`) to the same findings. Severity belongs to the rule, not the bridge. A rule is only evaluated+//! when the bridge sent what it needs: a key left out means "not read" and emits nothing, a key sent empty is a finding.+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};++/// The shared vocabulary: (kind, rule, severity).+pub const RULES: &[(&str, &str, &str)] = &[+ ("footprint", "no_pads", "error"),+ ("footprint", "zero_size_pad", "error"),+ ("footprint", "unnumbered_pad", "warning"),+ ("footprint", "duplicate_pad_number", "warning"),+ ("footprint", "no_courtyard", "warning"),+ ("footprint", "origin_off_part", "warning"),+ ("footprint", "no_silkscreen", "info"),+ ("footprint", "no_3d_body", "info"),+ ("footprint", "no_description", "info"),+ ("symbol", "no_pins", "error"),+ ("symbol", "footprint_not_found", "error"),+ ("symbol", "pin_without_pad", "error"),+ ("symbol", "unnumbered_pin", "warning"),+ ("symbol", "duplicate_pin_number", "warning"),+ ("symbol", "no_footprint", "warning"),+ ("symbol", "pad_without_pin", "warning"),+ ("symbol", "no_part_number", "info"),+ ("library", "tool_refuses_file", "error"),+];++/// How far past the pads and courtyard the origin may sit before it counts as off the part (mm).+pub const ORIGIN_MARGIN_MM: f64 = 1.0;++fn severity(rule: &str) -> &'static str { RULES.iter().find(|r| r.1 == rule).map(|r| r.2).unwrap_or("warning") }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn text(v: &Value) -> String { match v { Value::String(s) => s.trim().to_string(), Value::Number(n) => n.to_string(), _ => String::new() } }+/// Sent but empty: null, "", [] or {}.+fn empty(v: &Value) -> bool { match v { Value::Null => true, Value::String(s) => s.trim().is_empty(), Value::Array(a) => a.is_empty(), Value::Object(o) => o.is_empty(), _ => false } }+/// A pad no pin connects to: a mechanical hole, or unplated.+fn mechanical(p: &Value) -> bool { p["mechanical"] == true || p["plated"] == false }+/// "Lib:Name" and "Name" name the same footprint.+fn base(name: &str) -> &str { name.rsplit(':').next().unwrap_or(name) }+/// "pin 3 has" or "pins 2, 3 have".+fn list(word: &str, items: &[&String]) -> String {+ let names = items.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");+ if items.len() == 1 { format!("{word} {names} has") } else { format!("{word}s {names} have") }+}++/// A courtyard's extent: [x0, y0, x1, y1], {x0, y0, x1, y1}, or a list of [x, y] points.+fn extent(c: &Value) -> Option<(f64, f64, f64, f64)> {+ if let Some(a) = c.as_array() {+ if a.len() == 4 && a.iter().all(|v| v.is_number()) { return Some((f(&a[0]).min(f(&a[2])), f(&a[1]).min(f(&a[3])), f(&a[0]).max(f(&a[2])), f(&a[1]).max(f(&a[3])))); }+ let pts: Vec<(f64, f64)> = a.iter().filter_map(|p| p.as_array().filter(|q| q.len() >= 2).map(|q| (f(&q[0]), f(&q[1])))).collect();+ if pts.is_empty() { return None; }+ return Some(pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))));+ }+ if c["x0"].is_number() { return Some((f(&c["x0"]).min(f(&c["x1"])), f(&c["y0"]).min(f(&c["y1"])), f(&c["x0"]).max(f(&c["x1"])), f(&c["y0"]).max(f(&c["y1"])))); }+ None+}++fn finding(name: &str, kind: &str, rule: &str, detail: String) -> Value {+ json!({"name": name, "kind": kind, "rule": rule, "severity": severity(rule), "detail": detail})+}++/// The findings for one footprint (pads in its own frame, mm, origin at 0,0).+pub fn lint_footprint(fp: &Value) -> Vec<Value> {+ let name = text(&fp["name"]);+ let mut out = Vec::new();+ let pads = fp["pads"].as_array().cloned().unwrap_or_default();+ if pads.is_empty() { out.push(finding(&name, "footprint", "no_pads", "the footprint has no pads".into())); }+ let mut seen: BTreeMap<String, usize> = BTreeMap::new();+ for p in &pads {+ let (w, h, hole) = (f(&p["w"]), f(&p["h"]), f(&p["hole"]));+ if (w <= 0.0 || h <= 0.0) && hole <= 0.0 { out.push(finding(&name, "footprint", "zero_size_pad", format!("pad {} is {w} x {h} mm", text(&p["number"])))); }+ let n = text(&p["number"]);+ if n.is_empty() { if !mechanical(p) { out.push(finding(&name, "footprint", "unnumbered_pad", format!("a pad at ({}, {}) has no number", f(&p["x"]), f(&p["y"])))); } }+ else { *seen.entry(n).or_default() += 1; }+ }+ for (n, k) in &seen { if *k > 1 { out.push(finding(&name, "footprint", "duplicate_pad_number", format!("{k} pads are numbered {n} (fine for a split thermal pad, a mistake otherwise)"))); } }+ let court = fp.get("courtyard");+ if court.map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_courtyard", "no courtyard".into())); }+ // the origin should sit on the part: inside the pads and courtyard, grown by the margin+ let mut b: Option<(f64, f64, f64, f64)> = None;+ let mut grow = |e: (f64, f64, f64, f64)| b = Some(b.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));+ for p in &pads { let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["w"]).max(f(&p["hole"])) / 2.0, f(&p["h"]).max(f(&p["hole"])) / 2.0); grow((x - w, y - h, x + w, y + h)); }+ if let Some(e) = court.and_then(extent) { grow(e); }+ if let Some((x0, y0, x1, y1)) = b {+ let m = ORIGIN_MARGIN_MM;+ if 0.0 < x0 - m || 0.0 > x1 + m || 0.0 < y0 - m || 0.0 > y1 + m {+ let (dx, dy) = ((x0 - m).max(0.0) + (0.0f64).max(-(x1 + m)), (y0 - m).max(0.0) + (0.0f64).max(-(y1 + m)));+ out.push(finding(&name, "footprint", "origin_off_part", format!("the origin is {:.3} mm outside the pads and courtyard (part spans x {x0:.3}..{x1:.3}, y {y0:.3}..{y1:.3})", (dx * dx + dy * dy).sqrt())));+ }+ }+ if fp.get("silk").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_silkscreen", "no silkscreen".into())); }+ if fp.get("bodies").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_3d_body", "no 3D body".into())); }+ if fp.get("description").map(empty).unwrap_or(false) { out.push(finding(&name, "footprint", "no_description", "no description".into())); }+ out+}++/// The findings for one symbol, against the footprints the bridge sent (None: footprints not read, so the+/// footprint rules that need them are not evaluated).+pub fn lint_symbol(sym: &Value, footprints: Option<&BTreeMap<String, Value>>) -> Vec<Value> {+ let name = text(&sym["name"]);+ let mut out = Vec::new();+ let pins = sym["pins"].as_array().cloned().unwrap_or_default();+ if pins.is_empty() { out.push(finding(&name, "symbol", "no_pins", "the symbol has no pins".into())); }+ let mut numbers: BTreeMap<String, usize> = BTreeMap::new();+ for p in &pins {+ let n = text(&p["number"]);+ if n.is_empty() { out.push(finding(&name, "symbol", "unnumbered_pin", format!("pin {} has no number", text(&p["name"])))); }+ else { *numbers.entry(n).or_default() += 1; }+ }+ for (n, k) in &numbers { if *k > 1 { out.push(finding(&name, "symbol", "duplicate_pin_number", format!("{k} pins are numbered {n}"))); } }+ let fps: Vec<String> = match sym.get("footprint") {+ Some(Value::Array(a)) => a.iter().map(text).filter(|s| !s.is_empty()).collect(),+ Some(v) => Some(text(v)).filter(|s| !s.is_empty()).into_iter().collect(),+ None => Vec::new(),+ };+ if sym.get("footprint").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_footprint", "no footprint is linked".into())); }+ if let Some(lib) = footprints {+ for fpn in &fps {+ let Some(fp) = lib.get(fpn).or_else(|| lib.get(base(fpn))) else {+ out.push(finding(&name, "symbol", "footprint_not_found", format!("footprint {fpn} is not in the footprint libraries checked")));+ continue;+ };+ let pads: BTreeSet<String> = fp["pads"].as_array().map(|a| a.iter().filter(|p| !mechanical(p)).map(|p| text(&p["number"])).filter(|n| !n.is_empty()).collect()).unwrap_or_default();+ let missing: Vec<&String> = numbers.keys().filter(|n| !pads.contains(*n)).collect();+ if !missing.is_empty() { out.push(finding(&name, "symbol", "pin_without_pad", format!("{} no pad on {fpn}", list("pin", &missing)))); }+ let spare: Vec<&String> = pads.iter().filter(|n| !numbers.contains_key(*n)).collect();+ if !spare.is_empty() { out.push(finding(&name, "symbol", "pad_without_pin", format!("{} no pin on {fpn}", list("pad", &spare)))); }+ }+ }+ if sym.get("partNumber").map(empty).unwrap_or(false) { out.push(finding(&name, "symbol", "no_part_number", "no manufacturer part number".into())); }+ out+}++/// lint_library: input {library: {footprints?: [{name, pads: [{number, x, y, w, h, hole?, mechanical?, plated?}],+/// courtyard?, silk?, bodies?, description?}], symbols?: [{name, pins: [{number, name?}], footprint?, partNumber?}],+/// unread?: [{name, error}], findings?: [the bridge's own]}, args: {names?}}. Symbols are checked against the footprints+/// sent (the library's own, or those of footprintLibraries the bridge resolved).+pub fn lint_library(input: &Value) -> Value {+ let lib = &input["library"];+ if !lib.is_object() { return crate::input::refuse("bad_args", "library is required: {footprints?, symbols?, unread?, findings?}"); }+ let names: Option<BTreeSet<String>> = input["args"]["names"].as_array().map(|a| a.iter().map(text).collect());+ let wanted = |v: &Value| names.as_ref().map(|n| n.contains(&text(&v["name"]))).unwrap_or(true);+ let fps = lib["footprints"].as_array().cloned().unwrap_or_default();+ let syms = lib["symbols"].as_array().cloned().unwrap_or_default();+ let by_name: Option<BTreeMap<String, Value>> = lib.get("footprints").filter(|v| v.is_array()).map(|_| fps.iter().map(|fp| (text(&fp["name"]), fp.clone())).flat_map(|(n, fp)| [(base(&n).to_string(), fp.clone()), (n, fp)]).collect());+ let mut findings: Vec<Value> = lib["findings"].as_array().cloned().unwrap_or_default();+ let mut checked = 0;+ for fp in fps.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_footprint(fp)); }+ for s in syms.iter().filter(|v| wanted(v)) { checked += 1; findings.extend(lint_symbol(s, by_name.as_ref())); }+ let count = |sev: &str| findings.iter().filter(|x| x["severity"] == sev).count();+ let (errors, warnings, infos) = (count("error"), count("warning"), count("info"));+ let unread = lib["unread"].as_array().cloned().unwrap_or_default();+ json!({"success": true, "ok": errors == 0 && unread.is_empty(), "checked": checked, "errors": errors, "warnings": warnings, "infos": infos,+ "findings": findings, "unread": unread,+ "rules": RULES.iter().map(|r| json!({"kind": r.0, "rule": r.1, "severity": r.2})).collect::<Vec<_>>(),+ "_hint": "Errors stop a part working; warnings are usually mistakes; infos are gaps. A rule is only checked when the bridge read what it needs, so a missing finding is not a pass for a rule the bridge could not read."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(n: &str, x: f64, y: f64) -> Value { json!({"number": n, "x": x, "y": y, "w": 0.6, "h": 1.5}) }++ #[test]+ fn a_clean_footprint_and_its_symbol_pass() {+ let r = lint_library(&json!({"library": {+ "footprints": [{"name": "SOT23", "pads": [pad("1", -0.95, -1.1), pad("2", 0.95, -1.1), pad("3", 0.0, 1.1)], "courtyard": [-1.7, -2.0, 1.7, 2.0], "silk": [[0, 0]], "bodies": [{"h": 1.1}], "description": "SOT-23"}],+ "symbols": [{"name": "BJT", "pins": [{"number": "1"}, {"number": "2"}, {"number": "3"}], "footprint": "Lib:SOT23", "partNumber": "MMBT3904"}]}}));+ assert_eq!(r["ok"], true, "{r}");+ assert_eq!(r["checked"], 2);+ assert_eq!(r["findings"], json!([]));+ }++ #[test]+ fn the_shared_rules_fire_with_their_own_severity() {+ // the xcopy bug of altium-bridge#29: a copy 1270 mm off its origin+ let far = json!({"name": "BAT_FP_X", "pads": [pad("1", 1270.0, 0.0), pad("1", 1272.0, 0.0), json!({"number": "", "x": 1271.0, "y": 0.0, "w": 0.0, "h": 0.0})], "courtyard": null, "silk": [], "description": ""});+ let r = lint_library(&json!({"library": {"footprints": [far],+ "symbols": [{"name": "BAT", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "BAT_FP_X", "partNumber": ""}, {"name": "LOST", "pins": [{"number": "1"}], "footprint": "NOPE"}, {"name": "BARE", "pins": [], "footprint": ""}],+ "findings": [{"name": "X", "kind": "library", "rule": "tool_refuses_file", "severity": "error", "detail": "kicad-cli: bad token"}]}}));+ let rules: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|x| x["rule"].as_str().unwrap()).collect();+ for want in ["tool_refuses_file", "zero_size_pad", "unnumbered_pad", "duplicate_pad_number", "no_courtyard", "origin_off_part", "no_silkscreen", "no_description", "pin_without_pad", "no_part_number", "footprint_not_found", "no_pins", "no_footprint"] {+ assert!(rules.contains(&want), "{want} missing from {rules:?}");+ }+ // bodies was not sent, so no_3d_body is not evaluated+ assert!(!rules.contains(&"no_3d_body"));+ assert_eq!(r["ok"], false);+ assert!(r["findings"].as_array().unwrap().iter().all(|x| x["severity"] == severity(x["rule"].as_str().unwrap())));+ let off = r["findings"].as_array().unwrap().iter().find(|x| x["rule"] == "origin_off_part").unwrap();+ assert!(off["detail"].as_str().unwrap().starts_with("the origin is 1268.700 mm"), "{off}");+ }++ #[test]+ fn a_spare_pad_warns_and_a_mechanical_one_does_not() {+ let fp = json!({"name": "USB", "pads": [pad("1", 0.0, 0.0), pad("2", 0.65, 0.0), pad("S1", 3.0, 0.0), json!({"number": "", "x": -2.0, "y": 1.0, "w": 0.0, "h": 0.0, "hole": 0.65, "plated": false})]});+ let r = lint_library(&json!({"library": {"footprints": [fp], "symbols": [{"name": "USB", "pins": [{"number": "1"}, {"number": "2"}], "footprint": "USB"}]}}));+ let f: Vec<(&str, &str)> = r["findings"].as_array().unwrap().iter().map(|x| (x["rule"].as_str().unwrap(), x["detail"].as_str().unwrap())).collect();+ assert_eq!(f, vec![("pad_without_pin", "pad S1 has no pin on USB")], "{r}");+ assert_eq!(r["ok"], true, "a warning does not fail the library");+ // a name filter, and unread items fail ok+ let r = lint_library(&json!({"library": {"footprints": [{"name": "A", "pads": []}, {"name": "B", "pads": [pad("1", 0.0, 0.0)]}], "unread": [{"name": "C", "error": "locked"}]}, "args": {"names": ["B"]}}));+ assert_eq!((r["checked"].clone(), r["errors"].clone(), r["ok"].clone()), (json!(1), json!(0), json!(false)));+ }+}
engine/src/main.rs+1−1@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> { while let Some(flag) = it.next() { let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?; match flag.as_str() {- "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" => {+ "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" | "--library" => { let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] }; b[key] = read(val)?; }
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.23.8",+ "version": "0.24.0", "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.23.8",+ "standard": "0.24.0", "reference": { "bridge": "altium", "version": "0.2.83"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.23.8",+ "standard": "0.24.0", "reference": { "bridge": "altium", "version": "0.2.83"