← Commit history

2.42.0: lost features are named in every lane; --strict refuses them (wiki #25)

Colby Knox ·5a114f397b ·1mo ago ·parent 198dd34
9 files changed +203−42
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.41.1"+version = "2.42.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.41.1"+version = "2.42.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
README.md+4
@@ -102,6 +102,10 @@ Load an `.lbr` and the viewer breaks it down at a glance (**symbols, packages, d  This is the whole point. Fusion will silently show "No Components" or refuse to place a part when a library is subtly broken. adom-lbr catches it first. Here the deviceset has **no pin-to-pad connect mappings**, so the linter flags `empty-connects` with a plain-English reason: *"Fusion cannot use this component."* It also catches missing packages, devices with no package attribute, malformed XML, and invisible symbol bodies, each with a code and a fix. +## What a target cannot carry is named, never dropped++Every source feature a target lane cannot represent is reported instead of silently lost. Today that is the EAGLE/Fusion lane's lack of per-pad solder-mask/paste margins (Fusion applies its library DRC stop/cream rules instead) and the Altium lane's lack of a paste **ratio** (mm expansions ride the pad record since 2.41.0). The policy is **warn and ship**: the file is written, stderr carries `WARN: [lost-pad-margins] pads 1,2,3 carry ...`, and stdout carries one machine-readable line per loss, `LOST: {"code":"lost-pad-margins","part":...,"feature":...,"pads":[...],"effect":...}`, which the wiki's export workers surface on the library receipt. Pass `--strict` to refuse instead: a single-part export writes nothing and exits 2 with `ERROR: [LOST_FEATURES] ...`; `assemble-altium` skips the refused parts into `refused[]`, still assembles the rest, reports `status: "partial"` and exits 2 with valid files on disk. The KiCad lane loses nothing, so `--strict` never refuses it.+ ## Generate from the command line  ![Generating an .lbr from KiCad sources](docs/generate.png)
SKILL.md+4
@@ -226,6 +226,10 @@ Checks: - `malformed-xml`: unbalanced angle brackets - `no-symbol-layer`: nothing on layer 94 (symbol body invisible) +## Lost features and `--strict` (2.42.0)++A source feature the target lane cannot carry is reported, never silently dropped, with one shape in every lane: `{code, part, feature, pads[], effect}` (today `code` is `lost-pad-margins`). Default policy is warn and ship: the file is written, stderr says `WARN: [lost-pad-margins] pads 1,2,3 carry <feature>; <effect>.`, and stdout carries one `LOST: {...json...}` line per loss for machine callers (`OK:` and `HINT:` lines are unchanged). Losses today: the EAGLE/Fusion lane (`generate`) cannot carry per-pad solder-mask/paste margins; the Altium lane (`export-altium`, `export-intlib`, `assemble-altium`) cannot carry a paste ratio; the KiCad lane loses nothing. The global `--strict` flag refuses instead: single-part verbs print the LOST line, then `ERROR: [LOST_FEATURES] nothing written for the <lane> lane: refused under --strict: ...` and exit 2 with nothing written; `assemble-altium` moves refused parts to `refused[]` (and `skipped[]` with the reason), assembles the rest, reports `status: "partial"` and exits 2 while the files written stay valid. `assemble` (EAGLE) takes finished `.lbr` files, so the loss was already reported at `generate` time.+ ## validate  Same as lint but human-readable output. Exits 0 = valid, 2 = errors found.
VERSION+1−1
@@ -1 +1 @@-2.41.1+2.42.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.41.1",+  "version": "2.42.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
page.json+8−8
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.41.1",+  "version": "2.42.0",   "type": "app",-  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",+  "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},   "tags": [     "eda",@@ -61,15 +61,15 @@     "screenshots/**",     "dockbar.json"   ],-  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad → Altium"+      "label": "KiCad \u2192 Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium → JSON"+      "label": "Altium \u2192 JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",@@ -77,15 +77,15 @@     },     {       "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",-      "label": "Altium → KiCad"+      "label": "Altium \u2192 KiCad"     },     {       "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",-      "label": "JSON → KiCad"+      "label": "JSON \u2192 KiCad"     },     {       "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
src/main.rs+183−30
@@ -19,6 +19,15 @@ const SKILL: &str = include_str!("../SKILL.md"); struct Cli {     #[command(subcommand)]     command: Commands,+    /// Refuse an export that would LOSE a source feature the target lane+    /// cannot carry (per-pad mask/paste margins on the EAGLE/Fusion lane, a+    /// paste RATIO on the Altium lane). The default is warn and ship: the+    /// loss is named on stderr as WARN: [lost-pad-margins] and on stdout as a+    /// LOST: json line, and the file is written. Under --strict a single-part+    /// export writes nothing and exits 2; assemble-altium skips the refused+    /// parts into refused[], ships the rest, reports status "partial", exit 2.+    #[arg(long, global = true)]+    strict: bool, }  #[derive(Subcommand)]@@ -1170,7 +1179,6 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> {     // DRC stop/cream rules. Name the pads instead of dropping them silently     // (wiki #25); the lint mirrors this as lost-pad-margins.     let margin_re = regex_lite::Regex::new(r#"\(solder_(?:mask|paste)_margin(?:_ratio)?\s+-?[\d.]+"#).unwrap();-    let mut margin_pads: Vec<String> = Vec::new();      let mut pads = Vec::new();     for block in sexpr_blocks(km, "pad") {@@ -1180,7 +1188,7 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> {             continue;         }         if margin_re.is_match(block) {-            margin_pads.push(name.clone());+            eagle_margin_collect(&name);         }         let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd";         let Some(at) = at_re.captures(block) else { continue };@@ -1208,17 +1216,6 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> {         }.to_string();         pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape, kicad_shape, roundness });     }-    if !margin_pads.is_empty() {-        // generate parses the same footprint more than once (baseline, variants,-        // connects), so the warning is keyed on its pad list and printed once.-        static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::OnceLock::new();-        let key = margin_pads.join(",");-        let fresh = WARNED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))-            .lock().map(|mut set| set.insert(key.clone())).unwrap_or(true);-        if fresh {-            eprintln!("WARN: [lost-pad-margins] pads {key} carry per-pad solder-mask/paste margins; EAGLE has no per-pad aperture margin, so Fusion applies the library DRC stop/cream rules instead. The copper is exact; review the mask/paste apertures in Fusion before fab.");-        }-    }     pads } @@ -2955,6 +2952,84 @@ pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { /// Pads whose per-pad solder-mask/paste margins the Altium encoder cannot /// carry yet (its pad-record expansion bytes are uncalibrated): named so the /// loss is explicit, never a silent success (wiki #25).+/// A source feature a target lane cannot carry. One shape for every lane+/// (Colby's policy call on wiki #25): `code` is the machine key, `pads` the+/// pads it applies to in source order, `effect` what the target does instead.+#[derive(Debug, Clone, PartialEq)]+struct LostFeature {+    code: &'static str,+    part: String,+    feature: String,+    pads: Vec<String>,+    effect: String,+}++impl LostFeature {+    fn json(&self) -> serde_json::Value {+        serde_json::json!({"code": self.code, "part": self.part, "feature": self.feature,+            "pads": self.pads, "effect": self.effect})+    }+    fn pad_list(&self) -> String { self.pads.join(",") }+}++/// The three outputs every lane emits for a loss, in the default warn-and-ship+/// policy: the human WARN on stderr (the shape people already read), and the+/// structured LOST: json line on stdout for the wiki export workers.+fn report_lost(l: &LostFeature) {+    eprintln!("WARN: [{}] pads {} carry {}; {}.", l.code, l.pad_list(), l.feature, l.effect);+    println!("LOST: {}", l.json());+}++/// The --strict decision for a single-part lane: refuse only when something+/// is actually lost. Pure on `strict` (not the global) so tests cannot race.+fn strict_refusal(strict: bool, lane: &str, lost: &[LostFeature]) -> Option<String> {+    if !strict || lost.is_empty() { return None; }+    let what: Vec<String> = lost.iter().map(|l| format!("{} on pads {}", l.feature, l.pad_list())).collect();+    Some(format!("nothing written for the {lane} lane: refused under --strict: {}", what.join("; ")))+}++/// Pads whose KiCad per-pad solder-mask/paste margins the EAGLE lane drops.+/// parse_mod_pads runs several times per part (baseline, variants, connects),+/// so it only COLLECTS here, deduped in source order; the generate handler+/// drains the collector once, before it lints or writes, and reports with the+/// part name (wiki #25).+static EAGLE_MARGIN_PADS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());++fn eagle_margin_collect(pad: &str) {+    if let Ok(mut v) = EAGLE_MARGIN_PADS.lock() {+        if !v.iter().any(|p| p == pad) { v.push(pad.to_string()); }+    }+}++fn eagle_margin_drain() -> Vec<String> {+    EAGLE_MARGIN_PADS.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()+}++fn eagle_lost_feature(part: &str, pads: Vec<String>) -> Option<LostFeature> {+    if pads.is_empty() { return None; }+    Some(LostFeature {+        code: "lost-pad-margins",+        part: part.to_string(),+        feature: "per-pad solder-mask/paste margins".into(),+        pads,+        effect: "EAGLE has no per-pad aperture margin, so Fusion applies the library DRC stop/cream rules instead; the copper is exact, review the mask/paste apertures in Fusion before fab".into(),+    })+}++/// The Altium lane's loss: signed mm expansions ride the pad record since+/// 2.41.0; a KiCad paste RATIO has no radial equivalent.+fn altium_lost_feature(part: &str, fp: &altium_codec::Footprint) -> Option<LostFeature> {+    let pads = lost_margin_pads(fp);+    if pads.is_empty() { return None; }+    Some(LostFeature {+        code: "lost-pad-margins",+        part: part.to_string(),+        feature: "per-pad solder-paste ratio".into(),+        pads,+        effect: "Altium carries the mm mask/paste expansions; a paste ratio has no radial equivalent and is dropped, review the paste apertures before fab".into(),+    })+}+ fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> {     // Signed mm expansions now ride the Altium pad record (manual mode); a     // KiCad paste RATIO has no radial equivalent, so it is the one that is@@ -2965,15 +3040,6 @@ fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> {         .collect() } -fn warn_lost_margins(fp: &altium_codec::Footprint, lane: &str) -> Vec<String> {-    let lost = lost_margin_pads(fp);-    if !lost.is_empty() {-        eprintln!("WARN: [lost-pad-margins] pads {} carry a per-pad solder-paste RATIO, which the {lane} pad record cannot express (its expansion is radial, in mm); the mm expansions are carried, the ratio is not. Review the paste apertures before fab.",-            lost.join(","));-    }-    lost-}- fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {     use altium_codec::Parameter;     let mut push = |name: &str, value: &str, visible: bool| {@@ -3247,6 +3313,15 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or             }             let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants); +            // Losses are decided here, once, before anything is linted or+            // written: the parser only collected the margin pads.+            let lost: Vec<LostFeature> = eagle_lost_feature(&parsed.name, eagle_margin_drain()).into_iter().collect();+            for l in &lost { report_lost(l); }+            if let Some(why) = strict_refusal(cli.strict, "EAGLE/Fusion", &lost) {+                eprintln!("ERROR: [LOST_FEATURES] {why}");+                process::exit(2);+            }+             // Lint before writing             let lint = lint_lbr(&lbr);             for err in &lint.errors {@@ -3532,7 +3607,12 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or          Commands::ExportAltium { part, out_dir } => {             let p = load_part(&part);-            warn_lost_margins(&p.footprint, "Altium");+            let lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+            for l in &lost { report_lost(l); }+            if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+                eprintln!("ERROR: [LOST_FEATURES] {why}");+                process::exit(2);+            }             let mut symbol = p.symbol.clone();             seed_parameters(&mut symbol, &p);             let sym_bytes = altium_codec::encode_schlib(&[symbol])@@ -3562,6 +3642,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or             let mut fp_index: BTreeMap<String, usize> = BTreeMap::new();             let mut model_missing: Vec<Value> = Vec::new();             let mut lost_features: Vec<Value> = Vec::new();+            let mut refused: Vec<Value> = Vec::new();             let mut fp_by_content: BTreeMap<String, String> = BTreeMap::new();             let mut fp_shared: BTreeMap<String, Vec<String>> = BTreeMap::new();             let mut fp_names: BTreeSet<String> = BTreeSet::new();@@ -3583,10 +3664,16 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                 };                 let mut sym = p.symbol.clone();                 seed_parameters(&mut sym, &p);-                let lost = warn_lost_margins(&p.footprint, "Altium");-                if !lost.is_empty() {-                    lost_features.push(json!({"part": p.mpn, "feature": "per-pad solder-paste ratio", "pads": lost,-                        "effect": "Altium carries the mm expansions; the paste ratio has no radial equivalent and is dropped"}));+                if let Some(l) = altium_lost_feature(&p.mpn, &p.footprint) {+                    report_lost(&l);+                    if let Some(why) = strict_refusal(cli.strict, "Altium", std::slice::from_ref(&l)) {+                        // Under --strict the part is refused, the rest still+                        // assemble; the loss stays a NAMED entry, never silent.+                        skipped.push(json!({"part": path.display().to_string(), "reason": why}));+                        refused.push(l.json());+                        continue;+                    }+                    lost_features.push(l.json());                 }                 // The component a user picks in Altium is the ORDERABLE part,                 // so it takes the MPN (the deviceset=MPN doctrine); the source@@ -3682,8 +3769,9 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or             if let Err(e) = fs::write(&pp, &pcb_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", pp.display())})); process::exit(2); }             let deduped: Vec<Value> = fp_shared.iter().filter(|(_, v)| v.len() > 1)                 .map(|(k, v)| json!({"footprint": k, "sharedBy": v})).collect();+            let partial = !refused.is_empty();             println!("{}", serde_json::to_string_pretty(&json!({-                "status": "ok",+                "status": if partial { "partial" } else { "ok" },                 "schlib": sp.display().to_string(),                 "pcblib": pp.display().to_string(),                 "components": symbols.len(),@@ -3696,13 +3784,22 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                     "missing": model_missing,                 },                 "lostFeatures": lost_features,-                "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent."+                "refused": refused,+                "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent. lostFeatures[] names what the Altium lane could not carry (warn and ship); under --strict those parts move to refused[] and skipped[], the rest still assemble, status is partial and the exit code 2 while the files written stay valid."             })).unwrap());+            if partial {+                process::exit(2);+            }         }          Commands::ExportIntlib { part, out } => {             let p = load_part(&part);-            warn_lost_margins(&p.footprint, "Altium");+            let lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+            for l in &lost { report_lost(l); }+            if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+                eprintln!("ERROR: [LOST_FEATURES] {why}");+                process::exit(2);+            }             let mut symbol = p.symbol.clone();             seed_parameters(&mut symbol, &p);             let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };@@ -4109,6 +4206,62 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>     Ok(()) } +#[cfg(test)]+mod lost_feature_tests {+    use super::*;++    #[test]+    fn eagle_collector_dedupes_across_parses_and_drains() {+        // the parser runs several times per part; the collector keeps one+        // entry per pad in source order and empties on drain+        let km = r#"(footprint "M"+            (pad "1" smd rect (at 0 0) (size 1 1) (solder_mask_margin -0.05))+            (pad "2" smd rect (at 1 0) (size 1 1) (solder_paste_margin -0.05))+            (pad "3" smd rect (at 2 0) (size 1 1)))"#;+        let _ = eagle_margin_drain();+        let _ = parse_mod_pads(km);+        let _ = parse_mod_pads(km);+        let _ = parse_mod_pads(km);+        let pads = eagle_margin_drain();+        assert_eq!(pads, vec!["1".to_string(), "2".to_string()], "one entry per pad, source order");+        assert!(eagle_margin_drain().is_empty(), "drain empties the collector");+        let lost = eagle_lost_feature("M", pads).expect("a loss");+        assert_eq!(lost.code, "lost-pad-margins");+        assert_eq!(lost.json()["pads"], serde_json::json!(["1", "2"]));+        assert!(eagle_lost_feature("M", vec![]).is_none(), "no margin pads, no loss");+    }++    #[test]+    fn strict_refuses_only_when_something_is_lost() {+        let l = LostFeature { code: "lost-pad-margins", part: "P".into(),+            feature: "per-pad solder-mask/paste margins".into(), pads: vec!["1".into(), "2".into()],+            effect: "x".into() };+        assert!(strict_refusal(false, "EAGLE/Fusion", std::slice::from_ref(&l)).is_none(), "default ships");+        assert!(strict_refusal(true, "EAGLE/Fusion", &[]).is_none(), "nothing lost, nothing refused");+        let why = strict_refusal(true, "EAGLE/Fusion", std::slice::from_ref(&l)).expect("refused");+        assert_eq!(why, "nothing written for the EAGLE/Fusion lane: refused under --strict: per-pad solder-mask/paste margins on pads 1,2");+    }++    #[test]+    fn altium_lane_loses_only_the_paste_ratio() {+        let sym = r#"(kicad_symbol_lib (version 20231120) (generator "t")+          (symbol "P" (property "Reference" "U" (at 0 0 0))+            (symbol "P_1_1"+              (pin passive line (at -5.08 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+              (pin passive line (at -5.08 -2.54 0) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))"#;+        let km = r#"(footprint "P" (layer "F.Cu")+            (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_mask_margin -0.05) (solder_paste_margin -0.05))+            (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_paste_margin_ratio -0.1)))"#;+        let part = altium_codec::import_kicad(sym, km, "P").expect("import");+        let lost = altium_lost_feature(&part.mpn, &part.footprint).expect("the ratio is a loss");+        assert_eq!(lost.pads, vec!["2".to_string()], "mm margins on pad 1 are carried; only the ratio pad is lost");+        assert_eq!(lost.feature, "per-pad solder-paste ratio");+        let mut carried = part.footprint.clone();+        carried.pads.retain(|p| p.number == "1");+        assert!(altium_lost_feature("P", &carried).is_none(), "mm margins alone are not a loss on the Altium lane");+    }+}+ #[cfg(test)] mod lint_tests {     #[test]