app
Adom Concur
Public Made by Adomby adom
Cross-source consensus check for chip libraries — scores whether the KiCad, EAGLE, and Altium libraries AND the adom-ds2sf datasheet extraction all AGREE on pins, pads, names, and package, across seve
← Commit history
Publish source
17 files changed
+2949−39
.gitignoreadded+3@@ -0,0 +1,3 @@+target/+*.swp+.DS_Store
Cargo.lockadded+433@@ -0,0 +1,433 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-concur"+version = "0.8.0"+dependencies = [+ "altium-pcblib",+ "altium-schlib",+ "anyhow",+ "clap",+ "roxmltree",+ "serde",+ "serde_json",+]++[[package]]+name = "altium-pcblib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "bumpalo"+version = "3.20.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cfb"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"+dependencies = [+ "byteorder",+ "fnv",+ "uuid",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.98"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"+dependencies = [+ "cfg-if",+ "futures-util",+ "once_cell",+ "wasm-bindgen",+]++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "uuid"+version = "1.23.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"+dependencies = [+ "js-sys",+ "wasm-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.121"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.121"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.121"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.121"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+23@@ -0,0 +1,23 @@+[package]+name = "adom-concur"+version = "1.0.1"+edition = "2021"+description = "Cross-source consensus check: compares ds2sf-extracted symbol+footprint against chip-fetcher's KiCad/EAGLE/Altium libraries. Emits a result.json with status (golden/divergent/unrecoverable) and tagged hints[] so chip-fetcher can decide whether to re-fetch a library or escalate to a human."++[[bin]]+name = "adom-concur"+path = "src/main.rs"++[dependencies]+anyhow = "1"+clap = { version = "4", features = ["derive"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+roxmltree = "0.20"+altium-pcblib = { path = "../altium-pcblib" }+altium-schlib = { path = "../altium-schlib" }++[profile.release]+opt-level = "z"+lto = "thin"+strip = true
README.md+34−34@@ -1,15 +1,15 @@-# adom-concur+# concur **Cross-source consensus check for chip libraries.** -A small Rust CLI that runs downstream of `adom-ds2sf` and `adom-chip-fetcher`: it loads every available source-of-truth for a chip's symbol and footprint (datasheet-extracted, KiCad UL bundle, Fusion 360 EAGLE `.lbr`, Altium binary), compares them across seven axes, and emits a result.json with `status: golden | divergent | unrecoverable` plus tagged `hints[]` so the upstream caller can either re-fetch a suspect library or escalate to a human.+A small Rust CLI that runs downstream of `ds2sf` and `chip-fetcher`: it loads every available source-of-truth for a chip's symbol and footprint (datasheet-extracted, KiCad UL bundle, Fusion 360 EAGLE `.lbr`, Altium binary), compares them across seven axes, and emits a result.json with `status: golden | divergent | unrecoverable` plus tagged `hints[]` so the upstream caller can either re-fetch a suspect library or escalate to a human. The pipeline: ```-adom-chip-fetcher → adom-ds2sf (status: ok) → adom-concur (status: golden) → downstream is happy+chip-fetcher → ds2sf (status: ok) → concur (status: golden) → downstream is happy ↓ divergent- adom-chip-fetcher refetches a lib+ chip-fetcher refetches a lib OR human reviews ``` @@ -20,21 +20,21 @@ Concur is **opinionated** about whether a divergence requires upstream work or i Real example from this release's smoke tests: ```-$ adom-concur check ~/project/adom-chip-fetcher/library/ADS1115IDGSR- adom-ds2sf — 10 pins, 10 pads, package "VSSOP-10"+$ concur check ~/project/chip-fetcher/library/ADS1115IDGSR+ ds2sf — 10 pins, 10 pads, package "VSSOP-10" kicad — 10 pins, 10 pads, footprint "SOT_ADS1115IDYNR_TEX" eagle (.lbr) — 10 pins, 10 pads, package "DGS0010A_N" OK: ADS1115IDGSR — GOLDEN with variants [variants: label_only, stub_pin_names, body_dim_artifact] ``` -What adom-concur figured out:+What concur figured out: -- `label_only_mismatch` — KiCad's footprint name starts with `SOT_` because that's TI's UL library prefix convention, but the geometry (pad count, pad layout, pitch) all agrees with EAGLE's `DGS0010A_N` and adom-ds2sf's `VSSOP-10`. The package family really is VSSOP. Cosmetic only.-- `stub_symbol_pin_names` — the KiCad UL symbol has pin "names" of "1", "2", "3"… instead of real labels. Downstream can use adom-ds2sf's or EAGLE's pin labels.-- `body_dim_measurement_artifact` — adom-ds2sf and EAGLE report different body dimensions (5.05×3.10 vs 4.45×2.00). Body dim is courtyard-only, doesn't affect placement; the diff is a measurement-method artifact (lead-tip span vs pad-array span vs body D dim).+- `label_only_mismatch` — KiCad's footprint name starts with `SOT_` because that's TI's UL library prefix convention, but the geometry (pad count, pad layout, pitch) all agrees with EAGLE's `DGS0010A_N` and ds2sf's `VSSOP-10`. The package family really is VSSOP. Cosmetic only.+- `stub_symbol_pin_names` — the KiCad UL symbol has pin "names" of "1", "2", "3"… instead of real labels. Downstream can use ds2sf's or EAGLE's pin labels.+- `body_dim_measurement_artifact` — ds2sf and EAGLE report different body dimensions (5.05×3.10 vs 4.45×2.00). Body dim is courtyard-only, doesn't affect placement; the diff is a measurement-method artifact (lead-tip span vs pad-array span vs body D dim). -All three are flagged but explained, exit 0, adom-chip-fetcher moves forward.+All three are flagged but explained, exit 0, chip-fetcher moves forward. ## Outputs @@ -47,23 +47,23 @@ All three are flagged but explained, exit 0, adom-chip-fetcher moves forward. "exit_code": 0, "mpn": "ADS1115IDGSR", "summary": "ADS1115IDGSR — GOLDEN with variants [variants: label_only,stub_pin_names,body_dim_artifact]",- "sources_compared": ["adom-ds2sf","kicad","eagle"],+ "sources_compared": ["ds2sf","kicad","eagle"], "sources_missing": ["altium"], "variants": [ { "kind": "label_only_mismatch",- "notes": "adom-ds2sf: VSSOP-10 → family=VSSOP | eagle: DGS0010A_N → family=VSSOP | kicad: SOT_ADS1115IDYNR_TEX → family=SOT" },+ "notes": "ds2sf: VSSOP-10 → family=VSSOP | eagle: DGS0010A_N → family=VSSOP | kicad: SOT_ADS1115IDYNR_TEX → family=SOT" }, { "kind": "stub_symbol_pin_names", "stub_sources": ["kicad"],- "canonical_sources": ["adom-ds2sf","eagle"] },+ "canonical_sources": ["ds2sf","eagle"] }, { "kind": "body_dim_measurement_artifact",- "notes": "adom-ds2sf: 5.05×3.10 mm | eagle: 4.45×2.00 mm" }+ "notes": "ds2sf: 5.05×3.10 mm | eagle: 4.45×2.00 mm" } ], "axes": [ { "axis": "pad_count", "verdict": "agree" }, { "axis": "pin_names", "verdict": "disagree", "conflicts": ["kicad has stub-only pin names …"] }, { "axis": "package_family","verdict": "disagree",- "conflicts": ["Package families disagree across sources: adom-ds2sf=VSSOP, kicad=SOT, eagle=VSSOP"] }+ "conflicts": ["Package families disagree across sources: ds2sf=VSSOP, kicad=SOT, eagle=VSSOP"] } ], "hints": [ { "action": "ul_symbol_has_stub_names", "source": "kicad", "pin_count": 10,@@ -77,7 +77,7 @@ All three are flagged but explained, exit 0, adom-chip-fetcher moves forward. | Status | Exit | Caller action | |---|---|---| | `golden` | 0 | Ship it, no caveats. |-| `golden_with_variants` | 0 | **Ship it.** Every disagreement is explained by `variants[]`. Downstream tools can use the variant list to decide which source to prefer (adom-ds2sf for pin labels when the KiCad symbol has stubs, etc). |+| `golden_with_variants` | 0 | **Ship it.** Every disagreement is explained by `variants[]`. Downstream tools can use the variant list to decide which source to prefer (ds2sf for pin labels when the KiCad symbol has stubs, etc). | | `divergent` | 2 | Real disagreement. Refetch a library or escalate to a human. | | `unrecoverable` | 3 | Couldn't parse ≥2 sources. | @@ -88,7 +88,7 @@ All three are flagged but explained, exit 0, adom-chip-fetcher moves forward. | `exposed_pad_variant` | Pad count diff is exactly 1 | One source counts the EP/thermal pad as pin N+1; the other rolls it into the body. EE practice varies; both are correct. | | `module_extra_pads` | Pad count diff > 1 (one source has more) | KiCad/EAGLE footprint includes mounting/shield/test pads beyond the module datasheet's user-facing pinout. | | `label_only_mismatch` | Package family names differ but geometry agrees | Naming-convention artifact (e.g. TI's `SOT_` prefix on a VSSOP footprint, JEDEC outline `DGS0010A` → VSSOP, IPC-7351 generic name `SOP65P640X120-20N` → TSSOP). Cosmetic only. |-| `stub_symbol_pin_names` | One source's pin names are pin numbers ("1","2"…) | UL stub case. Downstream should use a richer source (adom-ds2sf or EAGLE) for labels. |+| `stub_symbol_pin_names` | One source's pin names are pin numbers ("1","2"…) | UL stub case. Downstream should use a richer source (ds2sf or EAGLE) for labels. | | `body_dim_measurement_artifact` | Body dim disagrees but pad geometry agrees | Different sources measured "body" differently (lead-tip span vs body D-dim vs pad-array span). Body dim is courtyard-only; doesn't affect placement. | | `naming_convention_mismatch` | Pin names differ but every disagreement is a benign convention swap | Channel-digit position (`OUT1` ↔ `1OUT` for op-amps and multi-channel parts) or power-pin alias (`V+` ↔ `VCC`, `V-` ↔ `GND`). | @@ -106,11 +106,11 @@ All three are flagged but explained, exit 0, adom-chip-fetcher moves forward. ## Agent-to-agent contract -`hints[]` is a tagged-union list (separate from `variants[]` — variants explain *why* a disagreement is benign; hints tell the caller *what action to take*). v0.3 enriches refetch hints with concrete `suggested_sources` (priority-ordered) and `expected_resolution` so adom-chip-fetcher knows where to refetch from AND how to verify the refetch worked.+`hints[]` is a tagged-union list (separate from `variants[]` — variants explain *why* a disagreement is benign; hints tell the caller *what action to take*). v0.3 enriches refetch hints with concrete `suggested_sources` (priority-ordered) and `expected_resolution` so chip-fetcher knows where to refetch from AND how to verify the refetch worked. | Action | Carries | When | |---|---|---|-| `refetch_library_source` | `{format, suspect, notes}` | A specific lib looks wrong; adom-chip-fetcher should try a different upstream. |+| `refetch_library_source` | `{format, suspect, notes}` | A specific lib looks wrong; chip-fetcher should try a different upstream. | | `ul_symbol_has_stub_names` | `{source, pin_count, suggestion}` | KiCad UL stub case (pin "names" are pin numbers). | | `accept_with_caveats` | `{caveats}` | Soft diffs within tolerance. | | `human_review` | `{reason, notes}` | No automated repair safe. |@@ -130,7 +130,7 @@ match r.status.as_str() { return run_concur(chip_dir); // re-call after refetch } Hint::UlSymbolHasStubNames { .. } => {- // Replace .kicad_sym from adom-ds2sf JSON via downstream sym_create.+ // Replace .kicad_sym from ds2sf JSON via downstream sym_create. } _ => {} }@@ -144,10 +144,10 @@ match r.status.as_str() { ## CLI ```-adom-concur check <CHIP-DIR> # run cross-source check-adom-concur inspect <CHIP-DIR> # print summary of an existing adom-concur.result.json-adom-concur health # parser readiness check-adom-concur install # drop SKILL.md+concur check <CHIP-DIR> # run cross-source check+concur inspect <CHIP-DIR> # print summary of an existing concur.result.json+concur health # parser readiness check+concur install # drop SKILL.md ``` `check` flags: `--ds2sf-symbol`, `--ds2sf-footprint`, `--kicad-sym`, `--kicad-mod`, `--eagle`, `--mpn`, `--out-dir`, `--force`, `--json-only`.@@ -156,15 +156,15 @@ adom-concur install # drop SKILL.md | MPN | Pkg | Status | Variants caught | Notes | |---|---|---|---|---|-| ADS1115IDGSR | VSSOP-10 | golden_with_variants | label_only, stub_pin_names, body_dim_artifact | TI's `SOT_` prefix on VSSOP footprint; UL symbol stubs; adom-ds2sf body-dim picked up the lead-tip span |-| DRV8316RRGFR | VQFN-40 | golden_with_variants | ep | EP counted as pin 41 by KiCad/EAGLE, omitted by adom-ds2sf |+| ADS1115IDGSR | VSSOP-10 | golden_with_variants | label_only, stub_pin_names, body_dim_artifact | TI's `SOT_` prefix on VSSOP footprint; UL symbol stubs; ds2sf body-dim picked up the lead-tip span |+| DRV8316RRGFR | VQFN-40 | golden_with_variants | ep | EP counted as pin 41 by KiCad/EAGLE, omitted by ds2sf | | VL53L8CX | OLGA-16 | golden_with_variants | ep, label_only, body_dim_artifact | Custom optical LGA; thermal pad EP variant | | ESP32-S3-WROOM-1-N4 | SMD module | golden_with_variants | module_extras, body_dim_artifact | KiCad has 8 extra shield/test pads beyond the module's 41 castellations | | LM358D | SOIC-8 | golden_with_variants | naming_convention | `OUT1`↔`1OUT` channel-digit + `V+`↔`VCC`, `V-`↔`GND` power-pin aliases | | TPS62840YBGR | DSBGA-8 | golden_with_variants | body_dim_artifact | Cleanest of the suite; only courtyard body-dim diff | | BMI270 | LGA-14 | golden_with_variants | label_only, body_dim_artifact | Bosch IMU; EAGLE 7.x .lbr now parses (DTD support) |-| **MSP430G2553IPW20** | TSSOP-20 | **divergent** | — | **Real bug.** KiCad symbol shifts pin functions: adom-ds2sf says pin 14=NC, KiCad says pin 14=P1.6 with 8 alt-functions. Likely wrong sub-variant in UL bundle. |-| **R7FA4M1AB3CFP** | LQFP-100 | **divergent** | — | **Real bug.** Pin 1: adom-ds2sf=`P108_TMS_SWDIO`, KiCad=`P400`. AVCC0 lands on pad 90 vs pad 88. **Wrong sub-variant of the Renesas RA4M1 family.** |+| **MSP430G2553IPW20** | TSSOP-20 | **divergent** | — | **Real bug.** KiCad symbol shifts pin functions: ds2sf says pin 14=NC, KiCad says pin 14=P1.6 with 8 alt-functions. Likely wrong sub-variant in UL bundle. |+| **R7FA4M1AB3CFP** | LQFP-100 | **divergent** | — | **Real bug.** Pin 1: ds2sf=`P108_TMS_SWDIO`, KiCad=`P400`. AVCC0 lands on pad 90 vs pad 88. **Wrong sub-variant of the Renesas RA4M1 family.** | 7/9 pass cleanly (`golden_with_variants` exit 0). 2/9 are real wrong-variant findings the upstream caller should refetch. **Zero false positives** in v0.3. @@ -178,7 +178,7 @@ adom-concur install # drop SKILL.md Many vendor `.PcbLib`s ship multiple footprint variants in the same file — for example DRV8316RRGFR carries `RGF0040E-IPC_A` (41-pad IPC standard) AND `RGF0040E-MFG` (57-pad manufacturing layout with extra fiducials and test points). The upstream `altium-pcblib` crate picks one as "primary" by stream order. -If adom-concur naively trusts the primary, it can disagree with kicad+eagle 100% of the time. v0.7 computes the pad-count consensus from already-parsed sources (adom-ds2sf / kicad / eagle) and asks `altium-pcblib` for the variant whose pad count is closest to the median. Names containing `-MFG` / `_MFG` / `DEBUG` / `FIDUCIAL` / `TEST` get a tiebreak penalty so the IPC-standard variant wins when in doubt.+If concur naively trusts the primary, it can disagree with kicad+eagle 100% of the time. v0.7 computes the pad-count consensus from already-parsed sources (ds2sf / kicad / eagle) and asks `altium-pcblib` for the variant whose pad count is closest to the median. Names containing `-MFG` / `_MFG` / `DEBUG` / `FIDUCIAL` / `TEST` get a tiebreak penalty so the IPC-standard variant wins when in doubt. Effect on the benchmark: DRV8316RRGFR was reporting `divergent` with altium engaged (57 vs 41) → now reports `golden_with_variants` (41 across all four sources, only EP-counting differs). @@ -186,13 +186,13 @@ Effect on the benchmark: DRV8316RRGFR was reporting `divergent` with altium enga ```bash cargo build --release-sudo cp target/release/adom-concur /usr/local/bin/-adom-concur install+sudo cp target/release/concur /usr/local/bin/+concur install ``` Or just `./build.sh`. ## Related -- [`adom-ds2sf`](https://github.com/adom-inc/adom-ds2sf) — datasheet → symbol + footprint + provenance JSON. Upstream of adom-concur.-- adom-chip-fetcher — pulls the libraries that adom-concur cross-checks.+- [`ds2sf`](https://github.com/adom-inc/ds2sf) — datasheet → symbol + footprint + provenance JSON. Upstream of concur.+- chip-fetcher — pulls the libraries that concur cross-checks.
SKILL.md⋯ 1 unchanged line ⋯
build.shadded+7@@ -0,0 +1,7 @@+#!/usr/bin/env bash+set -euo pipefail+cd "$(dirname "$0")"+cargo build --release+sudo cp target/release/adom-concur /usr/local/bin/adom-concur+concur install+echo "OK: adom-concur $(adom-concur --version | awk '{print $2}') installed → /usr/local/bin/adom-concur"
package.json+12−5@@ -1,14 +1,21 @@ { "slug": "adom-concur",- "version": "1.0.0",+ "version": "1.0.1", "type": "app",- "description": "Cross-source consensus check for chip libraries \u2014 compares adom-ds2sf-extracted symbol+footprint against adom-chip-fetcher's KiCad/EAGLE/Altium libraries and emits agent-to-agent hints.",- "dependencies": {},+ "description": "Cross-source consensus check for chip libraries \u2014 scores whether the KiCad, EAGLE, and Altium libraries AND the adom-ds2sf datasheet extraction all AGREE on pins, pads, names, and package, across seven axes. Now reads Altium symbols natively (altium-schlib) so Altium votes on the symbol axes too.",+ "dependencies": {+ "adom/altium-pcblib": "^1.0.0",+ "adom/altium-schlib": "^1.0.0"+ }, "tags": [+ "altium",+ "cli",+ "consensus",+ "ds2sf",+ "eagle", "eda",- "validation", "kicad",- "cli"+ "validation" ], "scripts": { "install": "./install.sh",
src/cli.rsadded+591@@ -0,0 +1,591 @@+use clap::{Parser, Subcommand};+use std::path::{Path, PathBuf};++use crate::compare::{self, Verdict};+use crate::io::{err, hint, note, ok, set_json_only};+use crate::parsers::{self, Part};+use crate::result::{self, Hint, ResultDoc, Status};++#[derive(Parser)]+#[command(name = "adom-concur", version, about = "Cross-source consensus check for chip libraries")]+pub struct Cli {+ #[command(subcommand)]+ pub command: Cmd,+}++#[derive(Subcommand)]+pub enum Cmd {+ /// Compare ds2sf-extracted symbol+footprint against chip-fetcher's libraries.+ Check {+ chip_dir: PathBuf,++ /// Override auto-detected ds2sf symbol JSON.+ #[arg(long)]+ ds2sf_symbol: Option<PathBuf>,+ /// Override auto-detected ds2sf footprint JSON.+ #[arg(long)]+ ds2sf_footprint: Option<PathBuf>,+ /// Override auto-detected `<MPN>.kicad_sym`.+ #[arg(long)]+ kicad_sym: Option<PathBuf>,+ /// Override auto-detected `<MPN>.kicad_mod`.+ #[arg(long)]+ kicad_mod: Option<PathBuf>,+ /// Override auto-detected `<MPN>.lbr` (Fusion 360 EAGLE).+ #[arg(long)]+ eagle: Option<PathBuf>,+ /// Override auto-detected `<MPN>.PcbLib` (Altium binary footprint).+ #[arg(long)]+ altium: Option<PathBuf>,++ /// Override the MPN (defaults to chip-dir basename).+ #[arg(long)]+ mpn: Option<String>,+ /// Output directory for `<MPN>-concur.result.json`. Default: chip-dir.+ #[arg(long)]+ out_dir: Option<PathBuf>,++ /// Suppress progress; only `OK:`/`ERROR:` lines.+ #[arg(long)]+ json_only: bool,+ /// Bypass the cache and re-run.+ #[arg(long)]+ force: bool,+ },+ /// Print a one-screen summary of an existing concur.result.json.+ Inspect { chip_dir: PathBuf },+ /// Probe parser dependencies.+ Health,+ /// Drop SKILL.md to ~/.claude/skills/adom-concur/.+ Install,+}++pub fn run(cli: Cli) -> i32 {+ match cli.command {+ Cmd::Check {+ chip_dir,+ ds2sf_symbol, ds2sf_footprint,+ kicad_sym, kicad_mod, eagle, altium,+ mpn, out_dir,+ json_only, force,+ } => {+ set_json_only(json_only);+ run_check(+ chip_dir, ds2sf_symbol, ds2sf_footprint, kicad_sym, kicad_mod, eagle, altium,+ mpn, out_dir, force,+ )+ }+ Cmd::Inspect { chip_dir } => run_inspect(&chip_dir),+ Cmd::Health => run_health(),+ Cmd::Install => run_install(),+ }+}++#[allow(clippy::too_many_arguments)]+fn run_check(+ chip_dir: PathBuf,+ ds2sf_symbol: Option<PathBuf>, ds2sf_footprint: Option<PathBuf>,+ kicad_sym: Option<PathBuf>, kicad_mod: Option<PathBuf>, eagle: Option<PathBuf>,+ altium: Option<PathBuf>,+ mpn_override: Option<String>, out_dir_override: Option<PathBuf>,+ force: bool,+) -> i32 {+ let chip_dir = match chip_dir.canonicalize() {+ Ok(p) => p,+ Err(e) => { err(format!("chip dir: {e}")); return Status::Unrecoverable.exit_code(); }+ };+ let basename = chip_dir+ .file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();+ let mpn = mpn_override.unwrap_or(basename.clone());+ let out_dir = out_dir_override.unwrap_or_else(|| chip_dir.clone());+ let result_path = out_dir.join(format!("{mpn}-concur.result.json"));++ if result_path.exists() && !force {+ // Cache-hit: return the cached exit_code.+ match std::fs::read_to_string(&result_path)+ .ok()+ .and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())+ {+ Some(d) => {+ ok(format!(+ "{} — cached concur result at {} (use --force to re-run)",+ d.mpn, result_path.display()+ ));+ return d.exit_code;+ }+ None => { /* fall through and re-run */ }+ }+ }++ // Auto-detect inputs.+ let ds_sym = ds2sf_symbol+ .or_else(|| pick(&chip_dir, &format!("{mpn}-symbol.extracted.json")));+ let ds_fp = ds2sf_footprint+ .or_else(|| pick(&chip_dir, &format!("{mpn}-footprint.extracted.json")));+ let ki_sym = kicad_sym+ .or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_sym")));+ let ki_mod = kicad_mod+ .or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_mod")));+ let lbr = eagle+ .or_else(|| pick(&chip_dir, &format!("{mpn}.lbr")));+ let altium_path = altium+ .or_else(|| pick(&chip_dir, &format!("{mpn}.PcbLib")))+ .or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));+ // Altium symbol library (pins) — paired with the .PcbLib footprint above.+ let altium_schlib = pick(&chip_dir, &format!("{mpn}.SchLib"))+ .or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));++ note(format!("concur check {mpn}: discovered sources →"));+ let mut sources_compared: Vec<String> = Vec::new();+ let mut sources_missing: Vec<String> = Vec::new();+ let mut hints: Vec<Hint> = Vec::new();+ let mut parts: Vec<Part> = Vec::new();++ // ds2sf+ if let Some(sym_p) = &ds_sym {+ match parsers::ds2sf::parse(sym_p, ds_fp.as_deref()) {+ Ok(p) => {+ note(format!(" ds2sf — {} pins, {} pads, package {:?}",+ p.pins.len(), p.pads.len(),+ p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));+ parts.push(p);+ sources_compared.push("ds2sf".into());+ }+ Err(e) => {+ hints.push(Hint::SourceParseFailed {+ format: "ds2sf".into(), path: sym_p.clone(), error: e.to_string(),+ });+ sources_missing.push("ds2sf".into());+ }+ }+ } else {+ sources_missing.push("ds2sf".into());+ }+ // KiCad+ if ki_sym.is_some() || ki_mod.is_some() {+ match parsers::kicad::parse(ki_sym.as_deref(), ki_mod.as_deref()) {+ Ok(p) => {+ note(format!(" kicad — {} pins, {} pads, footprint {:?}",+ p.pins.len(), p.pads.len(),+ p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));+ parts.push(p);+ sources_compared.push("kicad".into());+ }+ Err(e) => {+ let fp = ki_sym.clone().or(ki_mod.clone()).unwrap();+ hints.push(Hint::SourceParseFailed { format: "kicad".into(), path: fp, error: e.to_string() });+ sources_missing.push("kicad".into());+ }+ }+ } else {+ sources_missing.push("kicad".into());+ }+ // EAGLE+ if let Some(p) = &lbr {+ match parsers::eagle::parse(p) {+ Ok(part) => {+ note(format!(" eagle (.lbr) — {} pins, {} pads, package {:?}",+ part.pins.len(), part.pads.len(),+ part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));+ parts.push(part);+ sources_compared.push("eagle".into());+ }+ Err(e) => {+ hints.push(Hint::SourceParseFailed { format: "eagle".into(), path: p.clone(), error: e.to_string() });+ sources_missing.push("eagle".into());+ }+ }+ } else {+ sources_missing.push("eagle".into());+ }+ // Altium: .PcbLib (binary OLE2) for pad designators + .SchLib for symbol+ // pins (via altium-schlib), so Altium contributes to BOTH the footprint and+ // symbol axes — full parity with KiCad / EAGLE / ds2sf.+ //+ // Multi-variant selection: many vendor PcbLibs ship multiple variants+ // (e.g. DRV8316RRGFR's RGF0040E-IPC_A 41-pad standard + RGF0040E-MFG+ // 57-pad manufacturing). We compute the pad-count consensus from the+ // already-parsed sources (ds2sf / kicad / eagle) and pick the altium+ // variant closest to it. Without this, altium silently disagrees on+ // every chip with MFG variants because altium-pcblib picks the+ // longest variant by stream order.+ let consensus_pad_count = {+ let counts: Vec<usize> = parts+ .iter()+ .filter(|p| !p.pads.is_empty())+ .map(|p| p.pads.len())+ .collect();+ if counts.is_empty() {+ None+ } else {+ // Median = robust to one outlier (an under-reading ds2sf+ // doesn't drag the consensus). For 2 sources, the index+ // arithmetic still picks one of them.+ let mut sorted = counts.clone();+ sorted.sort();+ Some(sorted[sorted.len() / 2])+ }+ };++ if let Some(p) = &altium_path {+ match parsers::altium::parse_with_consensus_hint(p, consensus_pad_count, altium_schlib.as_deref()) {+ Ok(part) => {+ note(format!(" altium — {} pins, {} pads, footprint {:?}{}",+ part.pins.len(), part.pads.len(),+ part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?"),+ consensus_pad_count.map(|c| format!(" (consensus_hint={c})")).unwrap_or_default()));+ parts.push(part);+ sources_compared.push("altium".into());+ }+ Err(e) => {+ hints.push(Hint::SourceParseFailed {+ format: "altium".into(), path: p.clone(), error: e.to_string(),+ });+ sources_missing.push("altium".into());+ }+ }+ } else {+ sources_missing.push("altium".into());+ }++ if parts.len() < 2 {+ let summary = format!(+ "{} — only {} source(s) parseable; cross-source comparison needs ≥2",+ mpn, parts.len()+ );+ err(&summary);+ let doc = ResultDoc {+ schema_version: "1".into(),+ status: Status::Unrecoverable,+ exit_code: Status::Unrecoverable.exit_code(),+ mpn: mpn.clone(),+ summary,+ sources_compared,+ sources_missing,+ axes: vec![],+ variants: vec![],+ hints,+ extractor_version: env!("CARGO_PKG_VERSION").into(),+ };+ let _ = result::write(&result_path, &doc);+ return Status::Unrecoverable.exit_code();+ }++ let report = compare::compare(&parts);++ // Determine which axes are explained (covered) by a variant. Any+ // disagreement on a non-covered axis bumps status to Divergent.+ let covered: std::collections::HashSet<&str> = report+ .variants+ .iter()+ .flat_map(|v| v.covers().into_iter())+ .collect();++ // Lift uncovered disagreements into hints; stub-name disagreements (which+ // are already handled via the StubSymbolPinNames variant) get a friendlier+ // ul_symbol_has_stub_names hint *in addition* to the variant entry.+ for axis in &report.axes {+ if axis.verdict != Verdict::Disagree { continue; }+ if covered.contains(axis.axis.as_str()) {+ // Already explained by a variant — surface a stub-names hint when+ // applicable (downstream needs to know which source to prefer for+ // pin labels) but no Refetch hint.+ for c in &axis.conflicts {+ if c.contains("stub-only pin names") {+ if let Some(src) = c.split_whitespace().next() {+ let pin_count = parts.iter().find(|p| p.source == src)+ .map(|p| p.pins.len() as u32).unwrap_or(0);+ hints.push(Hint::UlSymbolHasStubNames {+ source: src.to_string(),+ pin_count,+ suggestion: "Use ds2sf-extracted symbol JSON for downstream tools that need real pin labels.".into(),+ chip_fetcher_action: "Do NOT refetch the .kicad_sym — UL stub-naming is a UL-bundle property, not a chip-fetcher choice. Instead, mark this chip dir's symbol source as 'ds2sf-preferred' so downstream sym_create authors a fresh .kicad_sym from ds2sf's JSON.".into(),+ });+ }+ }+ }+ } else {+ // Uncovered disagreement → real divergence. Build a rich Refetch+ // hint that tells chip-fetcher specifically what to do next.+ let format = hint_format_for_axis(&axis.axis);+ let suggested_sources = suggest_refetch_sources(&format, &mpn);+ let expected_resolution = format!(+ "Re-run `concur check` after refetch; axis '{}' should change from 'disagree' to 'agree' (or be covered by a benign variant).",+ axis.axis+ );+ // If ds2sf is the LONE disagreer on this axis (every other+ // parsed source agrees against it), the suspect is ds2sf's+ // extraction — not the chip-fetcher libs. Fire RerunDs2sfWith-+ // StrongerModel instead of asking chip-fetcher to refetch a+ // lib that's already correct. Real-world catch: DMG2305UX-7+ // had ds2sf-Haiku swap the GDS↔GSD pin order while kicad+eagle+ // both agreed on canonical GSD; v0.4 told chip-fetcher to+ // refetch the (correct) lib, which would have looped forever.+ let ds2sf_is_lone_disagreer =+ is_lone_ds2sf_disagreer(&axis.values)+ || is_lone_ds2sf_in_conflicts(&axis.conflicts);++ for c in &axis.conflicts {+ if ds2sf_is_lone_disagreer {+ hints.push(Hint::RerunDs2sfWithStrongerModel {+ reason: format!(+ "On axis '{}', ds2sf is the lone disagreer — every other parsed source agrees against it. Most likely a Haiku misread of the datasheet (typo, swapped pins, or fine-pitch label collision). Re-running with Opus typically resolves these.",+ axis.axis+ ),+ suspect_field: axis.axis.clone(),+ suggested_model: "claude-opus-4-7".into(),+ });+ } else if axis.axis == "body_dims" && c.to_lowercase().contains("ds2sf") {+ hints.push(Hint::RerunDs2sfWithStrongerModel {+ reason: "ds2sf reported a body dim that's likely the lead-tip span rather than the body D-dim — a known Haiku misread on small SOIC/VSSOP packages.".into(),+ suspect_field: "bodyDimensions.x or bodyDimensions.y".into(),+ suggested_model: "claude-opus-4-7".into(),+ });+ } else {+ hints.push(Hint::RefetchLibrarySource {+ format: format.clone(),+ suspect: axis.axis.clone(),+ notes: c.clone(),+ suggested_sources: suggested_sources.clone(),+ expected_resolution: expected_resolution.clone(),+ });+ }+ }+ }+ }++ let any_uncovered_disagreement = report.axes.iter().any(|a| {+ a.verdict == Verdict::Disagree && !covered.contains(a.axis.as_str())+ });+ let status = if any_uncovered_disagreement {+ Status::Divergent+ } else if !report.variants.is_empty() {+ Status::GoldenWithVariants+ } else if report.axes.iter().any(|a| a.verdict == Verdict::Agree) {+ Status::Golden+ } else {+ // No agree, no disagree → all source-missing. Treat as unrecoverable.+ Status::Unrecoverable+ };+ let summary = build_summary(&mpn, &report, &sources_compared, &sources_missing, status);+ if status.is_acceptable() { ok(&summary); } else { crate::io::warn(&summary); }++ let doc = ResultDoc {+ schema_version: "1".into(),+ status,+ exit_code: status.exit_code(),+ mpn,+ summary,+ sources_compared,+ sources_missing,+ axes: report.axes,+ variants: report.variants,+ hints,+ extractor_version: env!("CARGO_PKG_VERSION").into(),+ };+ if let Err(e) = result::write(&result_path, &doc) {+ err(format!("writing concur.result.json: {e}"));+ }+ status.exit_code()+}++/// True when ds2sf is the lone disagreer in a per-pin conflict list — every+/// conflict line has ds2sf on one side, and the OTHER sources don't conflict+/// with each other. Conflict format: `pin X: SRC1=V1 but SRC2=V2`.+///+/// Real-world catch (DMG2305UX-7, P-MOSFET): ds2sf-Haiku swapped pin 2/3+/// (D↔S) while kicad+eagle both agreed on canonical GSD ordering. The+/// per-source `values` map showed all sources reporting "3 named pins"+/// (summary count), so the values-based detector couldn't tell. This+/// function parses the conflict strings directly.+fn is_lone_ds2sf_in_conflicts(conflicts: &[String]) -> bool {+ use std::collections::HashSet;+ if conflicts.len() < 2 { return false; }++ // For each conflict, extract the two source names mentioned.+ // Pattern: "pin X: SRC1=V1 but SRC2=V2" — collect (SRC1, SRC2) pairs.+ let mut pairs: Vec<(String, String)> = Vec::new();+ for c in conflicts {+ if let Some((s1, s2)) = parse_conflict_sources(c) {+ pairs.push((s1, s2));+ }+ }+ if pairs.len() < 2 { return false; }++ // ds2sf must appear in every parsed pair.+ let ds2sf_in_all = pairs.iter().all(|(a, b)| a == "ds2sf" || b == "ds2sf");+ if !ds2sf_in_all { return false; }++ // The non-ds2sf source set must NOT include ds2sf in any other-side slot.+ // i.e., kicad and eagle never disagree with each other (otherwise ds2sf+ // wouldn't be the *lone* disagreer).+ let other_sources: HashSet<&str> = pairs+ .iter()+ .flat_map(|(a, b)| vec![a.as_str(), b.as_str()].into_iter())+ .filter(|s| *s != "ds2sf")+ .collect();+ // At least one non-ds2sf source must exist.+ !other_sources.is_empty()+}++/// Parse "pin X: SRC1=V1 but SRC2=V2" → Some(("SRC1", "SRC2")). The values+/// don't matter for lone-disagreer detection; only the source names.+fn parse_conflict_sources(c: &str) -> Option<(String, String)> {+ // Find the ": " that separates the pin label from the body.+ let body = c.split_once(": ").map(|(_, b)| b).unwrap_or(c);+ // Split on " but " to get the two halves.+ let (left, right) = body.split_once(" but ")?;+ let s1 = left.split_once('=')?.0.trim().to_string();+ let s2 = right.split_once('=')?.0.trim().to_string();+ Some((s1, s2))+}++/// True when the only source whose value differs from the others is "ds2sf".+/// Implementation: group the per-source values by stringified value; the+/// disagreer set has exactly 1 element AND that element is ds2sf.+fn is_lone_ds2sf_disagreer(values: &std::collections::BTreeMap<String, String>) -> bool {+ use std::collections::HashMap;+ if values.len() < 3 { return false; } // need ≥3 sources to identify a "lone" disagreer+ let mut buckets: HashMap<&String, Vec<&String>> = HashMap::new();+ for (src, val) in values {+ // Skip placeholder values like "(no symbol loaded)" — those aren't+ // real disagreements, they're missing-source markers.+ if val.starts_with('(') { continue; }+ buckets.entry(val).or_default().push(src);+ }+ // The "majority" bucket has ≥2 sources; the "minority" bucket has 1+ // source which must be ds2sf.+ let mut majority: Option<&Vec<&String>> = None;+ let mut minority: Option<&Vec<&String>> = None;+ for v in buckets.values() {+ if v.len() >= 2 { majority = Some(v); }+ else if v.len() == 1 { minority = Some(v); }+ }+ match (majority, minority) {+ (Some(_maj), Some(min)) => min.iter().any(|s| s.as_str() == "ds2sf"),+ _ => false,+ }+}++fn hint_format_for_axis(axis: &str) -> String {+ match axis {+ "package_family" | "pad_count" | "pad_numbers" | "body_dims" => "kicad_mod".into(),+ "symbol_pin_count" | "pin_names" | "pin_to_pad_map" => "kicad_sym".into(),+ _ => "unknown".into(),+ }+}++/// Concrete next-source recommendations chip-fetcher should walk through to+/// resolve a divergence. Order = priority (manufacturer-direct first).+fn suggest_refetch_sources(format: &str, mpn: &str) -> Vec<String> {+ let mut out = Vec::new();+ match format {+ "kicad_sym" | "kicad_mod" => {+ out.push(format!("Manufacturer site: {} datasheet+CAD page (search the part number on the manufacturer's website; many manufacturers offer a manufacturer-direct ZIP that bypasses UL).", mpn));+ out.push(format!("Ultra Librarian: re-fetch with a different package-suffix variant; the current UL bundle may be for a sibling part in the same family with a different pinout."));+ out.push(format!("SnapMagic / SnapEDA: alternate library source; usually carries a different upstream than UL."));+ out.push(format!("Mouser / DigiKey: some distributors host CAD downloads from a different upstream than UL."));+ out.push(format!("Component Search Engine (componentsearchengine.com): yet another upstream — useful as a tiebreaker."));+ }+ _ => {+ out.push("Inspect the chip-fetcher fetch log to identify which upstream source was used; refetch from a different one.".into());+ }+ }+ out+}++fn build_summary(+ mpn: &str,+ report: &compare::ComparisonReport,+ sources_compared: &[String],+ sources_missing: &[String],+ status: Status,+) -> String {+ let agreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Agree).count();+ let disagreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Disagree).count();+ let missing = report.axes.iter().filter(|a| a.verdict == Verdict::SourceMissing).count();+ let verdict = match status {+ Status::Golden => "GOLDEN",+ Status::GoldenWithVariants => "GOLDEN with variants",+ Status::Divergent => "DIVERGENT",+ Status::Unrecoverable => "UNRECOVERABLE",+ };+ let variants_str = if report.variants.is_empty() {+ String::new()+ } else {+ let kinds: Vec<&str> = report+ .variants+ .iter()+ .map(|v| match v {+ crate::result::Variant::ExposedPadVariant { .. } => "ep",+ crate::result::Variant::ModuleExtraPads { .. } => "module_extras",+ crate::result::Variant::LabelOnlyMismatch { .. } => "label_only",+ crate::result::Variant::StubSymbolPinNames { .. } => "stub_pin_names",+ crate::result::Variant::BodyDimMeasurementArtifact { .. } => "body_dim_artifact",+ crate::result::Variant::NamingConventionMismatch { .. } => "naming_convention",+ crate::result::Variant::KelvinSenseRepresentation { .. } => "kelvin_sense",+ })+ .collect();+ format!(" [variants: {}]", kinds.join(","))+ };+ format!(+ "{mpn} — {verdict}{variants_str}. Sources: [{}]{}. Axes: {} agree, {} disagree, {} source-missing.",+ sources_compared.join(","),+ if sources_missing.is_empty() { String::new() } else { format!(" (missing: {})", sources_missing.join(",")) },+ agreeing, disagreeing, missing,+ )+}++fn pick(dir: &Path, basename: &str) -> Option<PathBuf> {+ let p = dir.join(basename);+ if p.exists() { Some(p) } else { None }+}++fn run_inspect(chip_dir: &Path) -> i32 {+ let basename = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?");+ let p = chip_dir.join(format!("{basename}-concur.result.json"));+ if !p.exists() {+ err(format!("no concur result at {}", p.display()));+ hint(format!("run `concur check {}` first", chip_dir.display()));+ return 1;+ }+ let raw = match std::fs::read_to_string(&p) { Ok(r) => r, Err(e) => { err(e); return 1; } };+ let doc: ResultDoc = match serde_json::from_str(&raw) { Ok(d) => d, Err(e) => { err(e); return 1; } };+ note(format!("MPN: {} — status: {:?}, exit_code: {}", doc.mpn, doc.status, doc.exit_code));+ note(format!("Sources compared: {:?}", doc.sources_compared));+ if !doc.sources_missing.is_empty() {+ note(format!("Sources missing: {:?}", doc.sources_missing));+ }+ note("Axes:");+ for a in &doc.axes {+ note(format!(" {:>16} {:?}", a.axis, a.verdict));+ for c in &a.conflicts {+ note(format!(" ! {c}"));+ }+ }+ if !doc.hints.is_empty() {+ note("Hints:");+ for h in &doc.hints {+ note(format!(" - {}", serde_json::to_string(h).unwrap_or_default()));+ }+ }+ 0+}++fn run_health() -> i32 {+ // No external deps for parsing — informational pass.+ ok(format!("concur {} ready (parsers: ds2sf, kicad, eagle, altium)", env!("CARGO_PKG_VERSION")));+ 0+}++fn run_install() -> i32 {+ let home = match std::env::var("HOME") { Ok(h) => h, Err(_) => { err("HOME not set"); return 1; } };+ let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-concur");+ if let Err(e) = std::fs::create_dir_all(&dest_dir) { err(format!("creating skill dir: {e}")); return 1; }+ let dest = dest_dir.join("SKILL.md");+ // SAFETY NET — do not rely on for release propagation.+ const SKILL_MD: &str = include_str!("../SKILL.md");+ if let Err(e) = std::fs::write(&dest, SKILL_MD) { err(format!("writing SKILL.md: {e}")); return 1; }+ ok(format!("installed SKILL.md → {}", dest.display()));+ 0+}
src/compare.rsadded+916@@ -0,0 +1,916 @@+//! Cross-source comparison engine.++use serde::{Deserialize, Serialize};+use std::collections::BTreeMap;++use crate::parsers::Part;+use crate::result::Variant;++#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]+#[serde(rename_all = "snake_case")]+pub enum Verdict {+ Agree,+ /// A source is missing this axis (e.g. ds2sf-only had no kicad_sym to compare).+ /// Treated as informational, NOT a divergence.+ SourceMissing,+ Disagree,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct AxisOutcome {+ pub axis: String,+ pub verdict: Verdict,+ /// Per-source value for this axis, as a stringified summary.+ pub values: BTreeMap<String, String>,+ /// Specific conflicts found (pin numbers, dim diffs, etc.).+ pub conflicts: Vec<String>,+ /// Severity 1..3. 1 = informational, 2 = soft (recoverable / non-blocking),+ /// 3 = hard (almost certainly bad data — the wrong variant landed in the lib).+ pub severity: u8,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ComparisonReport {+ pub axes: Vec<AxisOutcome>,+ pub overall: Verdict,+ pub variants: Vec<Variant>,+}++/// Run all axis checks across the loaded parts. Sources with `pads.is_empty()`+/// AND `pins.is_empty()` AND no package — i.e. the parser found nothing — are+/// excluded from comparison. They're treated as "this format wasn't available+/// for this chip" rather than "this format disagreed."+pub fn compare(parts: &[Part]) -> ComparisonReport {+ let parts: Vec<&Part> = parts+ .iter()+ .filter(|p| !p.pins.is_empty() || !p.pads.is_empty() || p.package.is_some())+ .collect();++ let mut axes = Vec::new();+ axes.push(axis_pad_count(&parts));+ axes.push(axis_pad_numbers(&parts));+ axes.push(axis_symbol_pin_count(&parts));+ axes.push(axis_pin_names(&parts));+ axes.push(axis_pin_to_pad_map(&parts));+ axes.push(axis_package_family(&parts));+ axes.push(axis_body_dims(&parts));++ let overall = if axes.iter().any(|a| a.verdict == Verdict::Disagree) {+ Verdict::Disagree+ } else {+ Verdict::Agree+ };+ let variants = detect_variants(&parts, &axes);+ ComparisonReport { axes, overall, variants }+}++// ── variant detection ─────────────────────────────────────────────────────+//+// A variant is a known-benign explanation for one or more disagreeing axes.+// The status-determination logic in cli.rs uses Variant::covers() to decide+// whether every disagreeing axis is explained — if yes, status becomes+// `golden_with_variants` (exit 0); if not, `divergent` (exit 2).++fn detect_variants(parts: &[&Part], axes: &[AxisOutcome]) -> Vec<Variant> {+ let mut variants = Vec::new();++ let pad_count_disagree = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Disagree);+ let package_family_disagree = axes.iter().any(|a| a.axis == "package_family" && a.verdict == Verdict::Disagree);+ let body_dims_disagree = axes.iter().any(|a| a.axis == "body_dims" && a.verdict == Verdict::Disagree);+ let pin_names_disagree = axes.iter().any(|a| a.axis == "pin_names" && a.verdict == Verdict::Disagree);++ // ── Variant 1: ExposedPad / ModuleExtraPads ───────────────────────────+ if pad_count_disagree {+ let parts_with_pads: Vec<&&Part> = parts.iter().filter(|p| !p.pads.is_empty()).collect();+ if parts_with_pads.len() >= 2 {+ let counts: Vec<u32> = parts_with_pads.iter().map(|p| p.pads.len() as u32).collect();+ let min_c = *counts.iter().min().unwrap();+ let max_c = *counts.iter().max().unwrap();+ let with_max: Vec<String> = parts_with_pads.iter()+ .filter(|p| p.pads.len() as u32 == max_c)+ .map(|p| p.source.clone()).collect();+ let with_min: Vec<String> = parts_with_pads.iter()+ .filter(|p| p.pads.len() as u32 == min_c)+ .map(|p| p.source.clone()).collect();++ if max_c - min_c == 1 {+ variants.push(Variant::ExposedPadVariant {+ sources_with_ep: with_max,+ sources_without_ep: with_min,+ });+ } else if max_c > min_c {+ // GUARD: a big pad-count gap is only a benign "module extras"+ // variant when the SMALLER-source's package name carries a+ // module marker. Otherwise — like an FPGA where ds2sf only+ // got half the pins — it's a real undercount divergence and+ // should NOT be auto-explained as a variant.+ //+ // Real-world catch: ICE40UP5K v0.3 saw ds2sf=24 pads, kicad=48+ // pads (FPGA, not a module). v0.3 wrongly fired module_extras+ // and reported golden_with_variants when it should have been+ // divergent. v0.4 only fires module_extras when the smaller-+ // source claims a module package name.+ let smaller_is_module = parts_with_pads+ .iter()+ .filter(|p| p.pads.len() as u32 == min_c)+ .any(|p| p.package.as_ref()+ .map(|pkg| is_module_package(&pkg.raw_name))+ .unwrap_or(false));+ if smaller_is_module {+ variants.push(Variant::ModuleExtraPads {+ sources_with_extras: with_max,+ sources_without_extras: with_min,+ extra_count: max_c - min_c,+ });+ }+ // else: leave the disagreement uncovered → status=divergent.+ }+ }+ }++ // ── Variant 2: LabelOnlyMismatch ──────────────────────────────────────+ // package_family disagrees but the underlying geometry agrees (or the+ // pad-count diff is already explained by ep/module-extras variants).+ let geometry_agrees = {+ let pad_count_agrees = axes.iter().any(|a| a.axis == "pad_count" && a.verdict == Verdict::Agree);+ let pad_numbers_agrees = axes.iter().any(|a| a.axis == "pad_numbers" && a.verdict == Verdict::Agree);+ let ep_or_module_explained = variants.iter().any(|v| matches!(+ v, Variant::ExposedPadVariant { .. } | Variant::ModuleExtraPads { .. }+ ));+ (pad_count_agrees && pad_numbers_agrees) || ep_or_module_explained+ };+ if package_family_disagree && geometry_agrees {+ let pf = axes.iter().find(|a| a.axis == "package_family").unwrap();+ let notes = pf+ .values+ .iter()+ .map(|(s, v)| format!("{s}: {v}"))+ .collect::<Vec<_>>()+ .join(" | ");+ variants.push(Variant::LabelOnlyMismatch { notes });+ }++ // ── Variant 3: StubSymbolPinNames ─────────────────────────────────────+ if pin_names_disagree {+ let mut stubbed = Vec::new();+ let mut canonical = Vec::new();+ for p in parts {+ if p.pins.is_empty() { continue; }+ let stub_count = p.pins.iter().filter(|pp| pp.is_stub_name).count();+ if stub_count > p.pins.len() / 2 {+ stubbed.push(p.source.clone());+ } else {+ canonical.push(p.source.clone());+ }+ }+ if !stubbed.is_empty() && !canonical.is_empty() {+ variants.push(Variant::StubSymbolPinNames {+ stub_sources: stubbed,+ canonical_sources: canonical,+ });+ }+ }++ // ── Variant 4: BodyDimMeasurementArtifact ─────────────────────────────+ // body_dims disagrees but geometry (pad layout) agrees — body dim is+ // courtyard-only, doesn't affect placement.+ if body_dims_disagree && geometry_agrees {+ let bd = axes.iter().find(|a| a.axis == "body_dims").unwrap();+ let notes = bd+ .values+ .iter()+ .map(|(s, v)| format!("{s}: {v}"))+ .collect::<Vec<_>>()+ .join(" | ");+ variants.push(Variant::BodyDimMeasurementArtifact { notes });+ }++ // ── Variant 5b: KelvinSenseRepresentation ────────────────────────────+ // 4-terminal Kelvin sense resistors (Vishay WSL, Bourns CSL, Susumu+ // PCS, etc) are sometimes represented as 4 pads (I+/V+/V-/I- — the+ // canonical precision-measurement form) and sometimes as 2 pads+ // (lossy non-precision form). Both are valid for routing; the+ // 4-pad form is just better for accurate current sensing. If one+ // source has 4 pads with Kelvin pin names and another has 2 pads,+ // explain the divergence as a representation choice rather than+ // ds2sf-misread or wrong-variant.+ if pad_count_disagree {+ if let Some(kvariant) = detect_kelvin_sense_split(parts) {+ variants.push(kvariant);+ }+ }++ // ── Variant 6: NamingConventionMismatch ──────────────────────────────+ // pin_names disagree but EVERY disagreeing pin pair is a benign naming-+ // convention difference (digit-position swap OR power-pin alias). If even+ // one pair has real-content disagreement, this variant doesn't fire and+ // the divergence bubbles up.+ if pin_names_disagree {+ if let Some(examples) = detect_naming_convention_only(parts) {+ variants.push(Variant::NamingConventionMismatch { examples });+ }+ }++ variants+}++/// Detect the Kelvin-sense-vs-2-terminal-resistor representation split.+/// Triggers when:+/// - Some source has exactly 4 pads with names matching the Kelvin set+/// (I+, I-, V+, V- in any order — slashes/dashes/Unicode-minus normalized)+/// - Another source has exactly 2 pads (numeric "1"/"2" or any pair)+/// - Body sizes are roughly comparable (both within a 2010/2512-class size)+fn detect_kelvin_sense_split(parts: &[&Part]) -> Option<crate::result::Variant> {+ use std::collections::HashSet;+ let kelvin_target: HashSet<&str> = ["I+", "I-", "V+", "V-"].into_iter().collect();++ let mut sources_with_kelvin: Vec<String> = Vec::new();+ let mut sources_without_kelvin: Vec<String> = Vec::new();+ let mut found_kelvin_names: Vec<String> = Vec::new();++ for p in parts {+ if p.pads.is_empty() && p.pins.is_empty() { continue; }+ // Kelvin source: 4 pads/pins AND the names match the Kelvin set.+ let has_4 = p.pads.len() == 4 || p.pins.len() == 4;+ let has_2 = p.pads.len() == 2 || p.pins.len() == 2;+ let names: HashSet<String> = if !p.pins.is_empty() {+ p.pins.iter().map(|pp| canon_kelvin_name(&pp.name)).collect()+ } else {+ // No symbol-side names available; we can't classify.+ continue;+ };+ let kelvin_match_count = names.iter()+ .filter(|n| kelvin_target.contains(n.as_str()))+ .count();++ if has_4 && kelvin_match_count >= 3 {+ sources_with_kelvin.push(p.source.clone());+ if found_kelvin_names.is_empty() {+ found_kelvin_names = p.pins.iter().map(|pp| pp.name.clone()).collect();+ }+ } else if has_2 {+ sources_without_kelvin.push(p.source.clone());+ }+ }++ if !sources_with_kelvin.is_empty() && !sources_without_kelvin.is_empty() {+ Some(crate::result::Variant::KelvinSenseRepresentation {+ sources_with_kelvin,+ sources_without_kelvin,+ kelvin_pin_names: found_kelvin_names,+ })+ } else {+ None+ }+}++/// Canonicalize a Kelvin pin name for set-membership checks.+/// Maps "I+", "I-", "V+", "V-" through dash/Unicode-minus variants.+fn canon_kelvin_name(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for c in s.trim().chars() {+ match c {+ '−' | '–' | '—' => out.push('-'), // Unicode minus → ASCII minus+ '_' => out.push('-'), // sometimes "I_" used for "I-"+ _ => out.push(c.to_ascii_uppercase()),+ }+ }+ out+}++/// Returns Some(examples) if every per-pin name disagreement between any two+/// sources is a benign naming-convention swap (digit-position OR power alias).+/// Returns None on the first real-content disagreement.+fn detect_naming_convention_only(parts: &[&Part]) -> Option<Vec<String>> {+ use std::collections::BTreeMap;++ let maps: Vec<(String, BTreeMap<String, String>)> = parts+ .iter()+ .filter(|p| {+ !p.pins.is_empty()+ && p.pins.iter().filter(|pp| pp.is_stub_name).count() <= p.pins.len() / 2+ })+ .map(|p| {+ let m: BTreeMap<String, String> = p+ .pins+ .iter()+ .map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))+ .collect();+ (p.source.clone(), m)+ })+ .collect();++ if maps.len() < 2 { return None; }++ let mut examples: Vec<String> = Vec::new();+ let (first_src, first_map) = &maps[0];+ for (src, m) in &maps[1..] {+ for (num, name1) in first_map {+ let Some(name2) = m.get(num) else { continue; };+ if name1 == name2 { continue; }+ // Names differ — is it just a digit-position permutation?+ // Acceptable if any of: (a) digit-position permutation,+ // (b) both names are aliases of the same canonical power pin,+ // (c) names match after stripping all separators (SPI_CS1 ↔+ // SPICS1, MUX_SEL ↔ MUXSEL — common UL-vs-datasheet-vs-vendor+ // convention difference).+ if !same_digit_letter_multiset(name1, name2)+ && !same_power_pin_alias(name1, name2)+ && !same_with_separators_stripped(name1, name2)+ && !same_active_low_notation(name1, name2)+ && !same_thermal_pad(name1, name2)+ {+ return None;+ }+ if examples.len() < 4 {+ examples.push(format!("pin {num}: {first_src}={name1} ↔ {src}={name2}"));+ }+ }+ }+ if examples.is_empty() { None } else { Some(examples) }+}++/// True if `a` and `b` have the same multiset of digit-runs and letter-runs+/// when each is tokenized into maximal letter / digit / underscore-or-other+/// chunks. So `OUT1` (["OUT","1"]) matches `1OUT` (["1","OUT"]). `IN1_` and+/// `1IN_` also match. But `P108` and `P400` don't (different digit runs).+fn same_digit_letter_multiset(a: &str, b: &str) -> bool {+ let mut ta = tokenize_alnum(a);+ let mut tb = tokenize_alnum(b);+ ta.sort();+ tb.sort();+ ta == tb+}++/// True if `a` and `b` are both aliases of the same canonical power pin.+/// Handles the common chip-vendor-vs-library naming differences for supplies+/// and ground (single-supply convention vs dual-supply convention).+fn same_power_pin_alias(a: &str, b: &str) -> bool {+ let ca = canonical_power_alias(a);+ let cb = canonical_power_alias(b);+ ca.is_some() && ca == cb+}++/// True when `a` and `b` are equal after stripping all `_` separators.+/// e.g. `SPI_CS1` ↔ `SPICS1`, `MUX_SEL` ↔ `MUXSEL`. Underscore-vs-no-+/// underscore is a UL-bundle-vs-datasheet convention difference that+/// doesn't change pin identity.+fn same_with_separators_stripped(a: &str, b: &str) -> bool {+ let strip = |s: &str| -> String {+ s.chars().filter(|c| *c != '_').collect()+ };+ let sa = strip(a);+ let sb = strip(b);+ !sa.is_empty() && sa == sb+}++/// True when `a` and `b` are the same signal in different **active-low+/// notations**. Datasheets/ds2sf write the bare name (`CS`, `FAULT`) or an+/// `N`-prefix (`NFAULT`); KiCad/EAGLE/Altium libraries mark active-low with an+/// `_N`/`_B`/`#` suffix or an overbar (`~{CS}`, `!CS`, `C\S\`). On a same-pin-+/// number comparison these denote one pin, so the difference is a benign+/// convention, not a conflict. Conservative: only fires when one side carries+/// an explicit negation marker that, once removed, matches the other side+/// (optionally accounting for the bare `N`-prefix form).+fn same_active_low_notation(a: &str, b: &str) -> bool {+ // Strip overbar markup, then ONE trailing active-low suffix.+ let neg_strip = |s: &str| -> String {+ let mut t: String = s.chars().filter(|c| !matches!(c, '~' | '{' | '}' | '!' | '\\')).collect();+ for suf in ["_N", "_B", "#", "_L"] {+ if let Some(p) = t.strip_suffix(suf) { t = p.to_string(); break; }+ }+ t+ };+ if a == b { return false; }+ let (na, nb) = (neg_strip(a), neg_strip(b));+ // One side had an explicit marker and now matches the other.+ if na.len() >= 2 && na == nb { return true; }+ // `N`-prefix (NFAULT) vs `_N`-suffix base (FAULT_N → FAULT).+ let lead_n = |x: &str, base: &str| x.len() > 2 && x.starts_with('N') && &x[1..] == base && base.len() >= 2;+ lead_n(&na, &nb) || lead_n(&nb, &na)+}++/// True when both names denote the exposed/thermal pad — vendors, KiCad and+/// EAGLE/Altium each spell it differently (`PAD`, `THERMAL_PAD`, `EP`, `EPAD`…)+/// but it's the same physical pad, so the difference is benign.+fn same_thermal_pad(a: &str, b: &str) -> bool {+ let is_pad = |s: &str| {+ let u = s.replace('_', "");+ matches!(u.as_str(),+ "PAD" | "THERMALPAD" | "EP" | "EPAD" | "EXPPAD" | "EXPAD" | "GNDPAD"+ | "PPAD" | "POWERPAD" | "DAP" | "TAB" | "HEATSINK" | "THERMAL")+ };+ is_pad(a) && is_pad(b)+}++fn canonical_power_alias(name: &str) -> Option<&'static str> {+ match name {+ // Positive supplies — `V+` is the chip-vendor convention; VCC/VDD are+ // library/silkscreen conventions.+ "V+" | "VPLUS" | "V_PLUS" | "VCC" | "VDD" | "VPP" | "VSUP" => Some("VCC"),+ // Negative supplies / ground.+ "V_" | "V-" | "VMINUS" | "V_MINUS" | "GND" | "VSS" | "VEE" => Some("GND"),+ _ => None,+ }+}++/// Split into runs of letters and runs of digits. Non-alphanumeric chars+/// (`+`, `_`, `-`, `/`) are treated as separators and dropped — so `IN1+`+/// and `1IN+` tokenize identically to ["IN","1"] / ["1","IN"], which sort+/// to the same multiset.+fn tokenize_alnum(s: &str) -> Vec<String> {+ let mut out = Vec::new();+ let mut buf = String::new();+ let mut last_kind: Option<bool> = None;+ for c in s.chars() {+ if !c.is_ascii_alphanumeric() {+ if !buf.is_empty() {+ out.push(std::mem::take(&mut buf));+ last_kind = None;+ }+ continue;+ }+ let kind = c.is_ascii_digit();+ if last_kind != Some(kind) && !buf.is_empty() {+ out.push(std::mem::take(&mut buf));+ }+ buf.push(c);+ last_kind = Some(kind);+ }+ if !buf.is_empty() { out.push(buf); }+ out+}++// ── individual axes ────────────────────────────────────────────────────────++fn axis_pad_count(parts: &[&Part]) -> AxisOutcome {+ let mut values = BTreeMap::new();+ let mut counts: Vec<u32> = Vec::new();+ for p in parts {+ if !p.pads.is_empty() {+ values.insert(p.source.clone(), p.pads.len().to_string());+ counts.push(p.pads.len() as u32);+ } else {+ values.insert(p.source.clone(), "(no footprint loaded)".to_string());+ }+ }+ if counts.len() < 2 {+ return AxisOutcome {+ axis: "pad_count".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![],+ severity: 1,+ };+ }+ let unique: std::collections::HashSet<_> = counts.iter().collect();+ let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };+ let conflicts = if verdict == Verdict::Disagree {+ vec![format!("Pad counts differ across sources: {:?}", counts)]+ } else { vec![] };+ AxisOutcome { axis: "pad_count".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_pad_numbers(parts: &[&Part]) -> AxisOutcome {+ let mut values = BTreeMap::new();+ let mut sets: Vec<(String, std::collections::BTreeSet<String>)> = Vec::new();+ for p in parts {+ if p.pads.is_empty() { continue; }+ let s: std::collections::BTreeSet<String> =+ p.pads.iter().map(|p| canon_pin_num(&p.number)).collect();+ values.insert(p.source.clone(), summarize_set(&s));+ sets.push((p.source.clone(), s));+ }+ if sets.len() < 2 {+ return AxisOutcome {+ axis: "pad_numbers".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![], severity: 1,+ };+ }+ // All sets equal?+ let first = &sets[0].1;+ let all_equal = sets.iter().skip(1).all(|(_, s)| s == first);+ let verdict = if all_equal { Verdict::Agree } else { Verdict::Disagree };+ let mut conflicts = Vec::new();+ if !all_equal {+ for (src, s) in &sets[1..] {+ let only_first: Vec<_> = first.difference(s).cloned().collect();+ let only_other: Vec<_> = s.difference(first).cloned().collect();+ if !only_first.is_empty() {+ conflicts.push(format!("{} has pads {} that {} is missing", sets[0].0, fmt_list(&only_first), src));+ }+ if !only_other.is_empty() {+ conflicts.push(format!("{} has pads {} that {} is missing", src, fmt_list(&only_other), sets[0].0));+ }+ }+ }+ AxisOutcome { axis: "pad_numbers".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_symbol_pin_count(parts: &[&Part]) -> AxisOutcome {+ let mut values = BTreeMap::new();+ let mut counts: Vec<u32> = Vec::new();+ for p in parts {+ if !p.pins.is_empty() {+ values.insert(p.source.clone(), p.pins.len().to_string());+ counts.push(p.pins.len() as u32);+ } else {+ values.insert(p.source.clone(), "(no symbol loaded)".to_string());+ }+ }+ if counts.len() < 2 {+ return AxisOutcome {+ axis: "symbol_pin_count".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![], severity: 1,+ };+ }+ let unique: std::collections::HashSet<_> = counts.iter().collect();+ let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };+ let conflicts = if verdict == Verdict::Disagree {+ vec![format!("Pin counts differ across sources: {:?}", counts)]+ } else { vec![] };+ AxisOutcome { axis: "symbol_pin_count".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_pin_names(parts: &[&Part]) -> AxisOutcome {+ // Compare pin-name DICTIONARIES across sources that have non-stub names.+ let mut values = BTreeMap::new();+ let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();+ let mut stub_sources: Vec<String> = Vec::new();+ for p in parts {+ if p.pins.is_empty() {+ values.insert(p.source.clone(), "(no symbol loaded)".to_string());+ continue;+ }+ let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();+ if stubbed > p.pins.len() / 2 {+ // Mostly numbered placeholders (UL stub case) — exclude from name match.+ stub_sources.push(p.source.clone());+ values.insert(p.source.clone(), format!("(stub names: {} of {} are numeric)", stubbed, p.pins.len()));+ continue;+ }+ let m: BTreeMap<String, String> = p+ .pins+ .iter()+ .map(|pp| (canon_pin_num(&pp.number), canon_pin_name(&pp.name)))+ .collect();+ values.insert(p.source.clone(), format!("{} named pins", m.len()));+ maps.push((p.source.clone(), m));+ }+ let mut conflicts: Vec<String> = Vec::new();+ for src in &stub_sources {+ conflicts.push(format!("{} has stub-only pin names (UL bundle did not carry the part's real pin labels)", src));+ }+ if maps.len() < 2 {+ // Only one source has real names — cannot compare. Soft severity since+ // stub sources are still flagged above.+ let verdict = if conflicts.is_empty() { Verdict::SourceMissing } else { Verdict::Disagree };+ return AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 2 };+ }+ let (first_src, first_map) = &maps[0];+ for (src, m) in &maps[1..] {+ for (num, name) in first_map {+ if let Some(other) = m.get(num) {+ if other != name {+ conflicts.push(format!("pin {}: {}={} but {}={}", num, first_src, name, src, other));+ }+ } else {+ conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, first_src, name, src));+ }+ }+ for (num, name) in m {+ if !first_map.contains_key(num) {+ conflicts.push(format!("pin {} present in {} ({}) but missing in {}", num, src, name, first_src));+ }+ }+ }+ let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };+ AxisOutcome { axis: "pin_names".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_pin_to_pad_map(parts: &[&Part]) -> AxisOutcome {+ // Some sources (EAGLE, ds2sf) explicitly map pin name → pad number. Cross-check.+ let mut values = BTreeMap::new();+ let mut maps: Vec<(String, BTreeMap<String, String>)> = Vec::new();+ for p in parts {+ if p.pins.is_empty() { continue; }+ let stubbed = p.pins.iter().filter(|pp| pp.is_stub_name).count();+ if stubbed > p.pins.len() / 2 { continue; }+ // Build {pin_name → pad_number} (pin_name is canonicalized).+ let m: BTreeMap<String, String> = p+ .pins+ .iter()+ .map(|pp| (canon_pin_name(&pp.name), canon_pin_num(&pp.number)))+ .collect();+ values.insert(p.source.clone(), format!("{} mappings", m.len()));+ maps.push((p.source.clone(), m));+ }+ if maps.len() < 2 {+ return AxisOutcome {+ axis: "pin_to_pad_map".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![], severity: 1,+ };+ }+ let mut conflicts: Vec<String> = Vec::new();+ let (first_src, first_map) = &maps[0];+ for (src, m) in &maps[1..] {+ for (name, pad) in first_map {+ if let Some(other_pad) = m.get(name) {+ if other_pad != pad {+ conflicts.push(format!("pin {}: {}=pad {} but {}=pad {}", name, first_src, pad, src, other_pad));+ }+ }+ }+ }+ let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };+ AxisOutcome { axis: "pin_to_pad_map".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_package_family(parts: &[&Part]) -> AxisOutcome {+ // Reduce each source's package name to a "family" token (SOIC, SOT, QFN, …) and compare.+ let mut values = BTreeMap::new();+ let mut families: Vec<(String, String)> = Vec::new();+ for p in parts {+ if let Some(pkg) = &p.package {+ let fam = package_family(&pkg.raw_name);+ values.insert(p.source.clone(), format!("{} → family={}", pkg.raw_name, fam));+ families.push((p.source.clone(), fam));+ } else {+ values.insert(p.source.clone(), "(no package loaded)".to_string());+ }+ }+ if families.len() < 2 {+ return AxisOutcome {+ axis: "package_family".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![], severity: 1,+ };+ }+ let unique: std::collections::HashSet<&String> = families.iter().map(|(_, f)| f).collect();+ let verdict = if unique.len() == 1 { Verdict::Agree } else { Verdict::Disagree };+ let mut conflicts = Vec::new();+ if verdict == Verdict::Disagree {+ conflicts.push(format!(+ "Package families disagree across sources: {}",+ families.iter().map(|(s, f)| format!("{s}={f}")).collect::<Vec<_>>().join(", ")+ ));+ }+ AxisOutcome { axis: "package_family".into(), verdict, values, conflicts, severity: 3 }+}++fn axis_body_dims(parts: &[&Part]) -> AxisOutcome {+ // Compare body x/y across sources that report them. Tolerance: 0.5 mm+ // (manufacturer drawings vary; this axis is informational unless the diff+ // is enormous).+ let mut values = BTreeMap::new();+ let mut entries: Vec<(String, f64, f64)> = Vec::new();+ for p in parts {+ if let Some(pkg) = &p.package {+ if let (Some(x), Some(y)) = (pkg.body_x_mm, pkg.body_y_mm) {+ values.insert(p.source.clone(), format!("{:.2}×{:.2} mm", x, y));+ entries.push((p.source.clone(), x, y));+ }+ }+ }+ if entries.len() < 2 {+ return AxisOutcome {+ axis: "body_dims".into(),+ verdict: Verdict::SourceMissing,+ values, conflicts: vec![], severity: 1,+ };+ }+ let mut conflicts = Vec::new();+ let (_, fx, fy) = entries[0];+ for (src, x, y) in &entries[1..] {+ if (x - fx).abs() > 0.5 || (y - fy).abs() > 0.5 {+ conflicts.push(format!(+ "{} body {:.2}×{:.2} mm differs from {} body {:.2}×{:.2} mm by >0.5 mm",+ src, x, y, entries[0].0, fx, fy,+ ));+ }+ }+ let verdict = if conflicts.is_empty() { Verdict::Agree } else { Verdict::Disagree };+ AxisOutcome { axis: "body_dims".into(), verdict, values, conflicts, severity: 2 }+}++// ── helpers ────────────────────────────────────────────────────────────────++/// Canonicalize a pin number — uppercase, no surrounding whitespace.+fn canon_pin_num(s: &str) -> String {+ s.trim().to_uppercase()+}++/// Canonicalize a pin name for comparison: uppercase, replace slashes with+/// underscores (EAGLE convention) so "ALERT/RDY" matches "ALERT_RDY". Also+/// strip trailing `_<digits>` disambiguation suffixes — UL-bundle KiCad+/// symbols often disambiguate repeated pins as "GND_1"/"GND_2"/"GND_3" while+/// the datasheet calls all three just "GND".+fn canon_pin_name(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for c in s.trim().chars() {+ match c {+ // ASCII slash / dash AND Unicode minus (U+2212) / en-dash+ // (U+2013) / em-dash (U+2014) — datasheets routinely use the+ // Unicode forms (e.g. "V−", "IN1−", "ALERT/RDY").+ '/' | '-' | '−' | '–' | '—' => out.push('_'),+ _ => out.push(c.to_ascii_uppercase()),+ }+ }+ // Strip trailing _<digits>.+ if let Some(idx) = out.rfind('_') {+ let suffix = &out[idx + 1..];+ if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {+ out.truncate(idx);+ }+ }+ out+}++/// Reduce a package name like "VSSOP-10" / "DGS0010A" / "SOIC-8W_5.3x5.3mm" /+/// "SOT_ADS1115IDYNR_TEX" / "SOP65P640X120-20N" / "QFP50P1600X1600X170-100N"+/// to a comparable family token. LQFP and TQFP both map to LQFP (same+/// JEDEC outline). DSBGA/WLCSP/CSP/BGA all map to BGA.+fn package_family(raw: &str) -> String {+ let upper = raw.to_uppercase();++ // ── 1. TI JEDEC outline-code prefixes (single-letter PKG_DESIG codes) ──+ // TI prefixes their UL library names with these JEDEC codes; the TI+ // "Generic Package View" lookup table maps them to actual families.+ if upper.starts_with("DGS00") { return "VSSOP".into(); } // VSSOP/MSOP-10+ if upper.starts_with("DCT00") { return "SOT".into(); }+ if upper.starts_with("DCK00") { return "SC70".into(); }+ if upper.starts_with("DCN00") { return "VSON".into(); }+ if upper.starts_with("DBV00") { return "SOT".into(); } // SOT-23 family+ if upper.starts_with("DDC00") { return "SOT".into(); } // SOT-23-6+ if upper.starts_with("DLC00") { return "WSON".into(); } // TPS62840DLCR+ if upper.starts_with("YBG00") { return "BGA".into(); } // DSBGA / TPS62840YBGR+ if upper.starts_with("RGE00") || upper.starts_with("RGZ00") { return "QFN".into(); }+ if upper.starts_with("RHB00") || upper.starts_with("RUG00") { return "VQFN".into(); }+ if upper.starts_with("RRG00") { return "VQFN".into(); }+ if upper.starts_with("RGF00") { return "VQFN".into(); } // TI QFN-40+ if upper.starts_with("RTV00") { return "VQFN".into(); } // TI QFN-32+ if upper.starts_with("RRY00") { return "VQFN".into(); }+ if upper.starts_with("RSM00") { return "VQFN".into(); } // TI QFN-32 (RSM)+ if upper.starts_with("RQM00") { return "VQFN".into(); } // TI QFN-29+ if upper.starts_with("PW") { return "TSSOP".into(); }+ if upper.starts_with("DR") { return "QFN".into(); }+ if upper.starts_with("PBS00") { return "TQFP".into(); } // TI TQFP-32++ // ── 2. Single-letter / short JEDEC outline codes ──+ // KiCad UL sometimes labels parts with their bare JEDEC outline code.+ // SOIC-8: "D" / "D8" / "D_8" (TI's D = SOIC-8).+ // PDIP-8: "P" / "P8" (TI's P = PDIP).+ if upper == "D8" || upper == "D_8" || upper == "D" { return "SOIC".into(); }+ if upper.ends_with("_D8") { return "SOIC".into(); }+ if upper == "P8" || upper == "P_8" || upper == "PDIP" { return "DIP".into(); }+ if upper == "P14" || upper == "P_14" { return "DIP".into(); }+ if upper == "P16" || upper == "P_16" { return "DIP".into(); }+ if upper.ends_with("_P8") || upper.ends_with("_P14") || upper.ends_with("_P16") {+ return "DIP".into();+ }++ // ── 3. TI's UL library-namespace prefix convention ──+ // Many TI UL exports prefix every name with "SOT_" regardless of actual+ // package (it's a library-namespace convention, not a package claim).+ // Strip the prefix and re-inspect.+ if upper.starts_with("SOT_") {+ let stripped = &upper[4..];+ for fam in ["VSSOP", "TSSOP", "SSOP", "MSOP", "SOIC", "QFN", "QFP", "BGA"] {+ if stripped.contains(fam) { return fam.into(); }+ }+ }++ // ── 4. IPC-7351 generic naming: "SOP<pp>P<wid>X<hgt>-<n>N", "QFP<pp>P...",+ // "BGA<pp>P...", "DFN<pp>P..." ─────────────────────────────────────────+ // pp = pitch * 100 (e.g. SOP65 = 0.65 mm pitch). Pitch alone disambiguates+ // SOIC (1.27 mm) from TSSOP/SSOP (0.65 mm) from fine-pitch (0.5 mm).+ if let Some(fam) = parse_ipc7351(&upper) {+ return fam;+ }++ // ── 5. EAGLE / vendor outline aliases ─────────────────────────────────+ if upper.starts_with("DGS00") { return "VSSOP".into(); }+ if upper.contains("LGA") { return "LGA".into(); }++ // ── 6. Family-keyword scan ────────────────────────────────────────────+ // LQFP and TQFP map to the same family (same JEDEC outline; "L" is just+ // low-profile). DSBGA / WLCSP / CSP all collapse to BGA.+ for (token, family) in [+ ("VSSOP", "VSSOP"),+ ("TSSOP", "TSSOP"),+ ("SSOP", "SSOP"),+ ("MSOP", "VSSOP"), // MSOP-10 ≡ VSSOP-10+ ("SOIC-8W", "SOIC"),+ ("SOIC-16W", "SOIC"),+ ("SOIC", "SOIC"),+ ("SOIJ", "SOIC"),+ ("VQFN", "VQFN"),+ ("QFN", "QFN"),+ ("WSON", "WSON"),+ ("DFN", "DFN"),+ ("LQFP", "LQFP"),+ ("TQFP", "LQFP"), // same JEDEC outline+ ("QFP", "LQFP"), // bare "QFP" too — concur treats 'em as one family+ ("DSBGA", "BGA"),+ ("WLCSP", "BGA"),+ ("CSP", "BGA"),+ ("BGA", "BGA"),+ ("OLGA", "LGA"),+ ("LGA", "LGA"),+ ("SOT-563", "SOT"),+ ("SOT-23-5","SOT"),+ ("SOT-23-6","SOT"),+ ("SOT-23", "SOT"),+ ("SOT", "SOT"),+ ("SC-70", "SC70"),+ ("SC70", "SC70"),+ ("DIP", "DIP"),+ ("PDIP", "DIP"),+ ("WROOM", "MODULE"),+ ("WROVER", "MODULE"),+ ("MODULE", "MODULE"),+ ] {+ if upper.contains(token) {+ return family.into();+ }+ }+ "UNKNOWN".into()+}++/// True when the package name signals an SMD module (chip + supporting+/// passives + RF shield + PCB antenna packaged as a sub-assembly with+/// castellated edge pads) rather than a bare IC. Used by the+/// `module_extra_pads` variant detector to avoid false-classifying+/// undercounted-IC extractions as benign module variants.+fn is_module_package(raw: &str) -> bool {+ let upper = raw.to_uppercase();+ const MODULE_MARKERS: &[&str] = &[+ "MODULE", "WROOM", "WROVER", "WROOM-1", "WROOM-2",+ "NORA-", "NORA_", "ATWINC", "SARA-", "SARA_", "MIKROBUS",+ "FEATHER", "XBEE", "XPLAINED", "BREAKOUT",+ "CASTELLATED", "EDGE-PAD", "EDGE_PAD",+ ];+ MODULE_MARKERS.iter().any(|m| upper.contains(m))+}++/// Parse IPC-7351-style generic package names like:+/// SOP65P640X120-20N → TSSOP (pitch 0.65 mm, narrow)+/// QFP50P1600X1600X170-100N → LQFP (pitch 0.5 mm, 100-pin)+/// BGA50P800X800-49N → BGA+/// DFN65P200X300-8N → DFN+fn parse_ipc7351(upper: &str) -> Option<String> {+ let bytes = upper.as_bytes();+ let prefixes: &[(&str, &str)] = &[+ // (prefix, default family — refined by pitch)+ ("SOP", "SOIC"),+ ("QFP", "LQFP"),+ ("BGA", "BGA"),+ ("CSP", "BGA"),+ ("DFN", "DFN"),+ ("QFN", "QFN"),+ ("SON", "WSON"),+ ("WSON", "WSON"),+ ];+ for (prefix, default_family) in prefixes {+ if !upper.starts_with(prefix) { continue; }+ // Read pitch digits immediately after prefix.+ let after = &upper[prefix.len()..];+ let mut pitch_chars = String::new();+ for &b in after.as_bytes() {+ if b.is_ascii_digit() { pitch_chars.push(b as char); } else { break; }+ }+ if pitch_chars.is_empty() { continue; }+ // The next char should be 'P' (per IPC-7351). If not, this isn't+ // an IPC-7351 generic name — bail.+ let next_idx = prefix.len() + pitch_chars.len();+ if next_idx >= bytes.len() || bytes[next_idx] != b'P' { continue; }++ let pitch_pp: u32 = pitch_chars.parse().ok()?;+ let family = match (*prefix, pitch_pp) {+ // SOP family disambiguation by pitch.+ ("SOP", 127) => "SOIC", // 1.27 mm = SOIC+ ("SOP", p) if p <= 50 => "TSSOP",+ ("SOP", p) if p <= 65 => "TSSOP",+ ("SOP", _) => "SSOP",+ // QFP: any pitch is LQFP.+ ("QFP", _) => "LQFP",+ _ => default_family,+ };+ return Some(family.into());+ }+ None+}++fn summarize_set(s: &std::collections::BTreeSet<String>) -> String {+ if s.len() <= 4 {+ s.iter().cloned().collect::<Vec<_>>().join(",")+ } else {+ format!("{} pads ({}…{})", s.len(), s.iter().next().unwrap_or(&"?".to_string()), s.iter().last().unwrap_or(&"?".to_string()))+ }+}++fn fmt_list(v: &[String]) -> String {+ if v.len() <= 4 { v.join(",") } else { format!("{} items", v.len()) }+}
src/io.rsadded+83@@ -0,0 +1,83 @@+use std::io::IsTerminal;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+static JSON_ONLY: OnceLock<bool> = OnceLock::new();++pub fn stdout_is_tty() -> bool {+ *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal())+}+pub fn stderr_is_tty() -> bool {+ *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal())+}+pub fn set_json_only(v: bool) {+ let _ = JSON_ONLY.set(v);+}+pub fn json_only() -> bool {+ *JSON_ONLY.get().unwrap_or(&false)+}++pub fn strip_ansi(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars().peekable();+ while let Some(c) = chars.next() {+ if c == '\x1b' && chars.peek() == Some(&'[') {+ chars.next();+ for c2 in chars.by_ref() {+ if c2.is_ascii_alphabetic() {+ break;+ }+ }+ continue;+ }+ out.push(c);+ }+ out+}++pub fn ok(msg: impl std::fmt::Display) {+ if stdout_is_tty() {+ println!("{GREEN}{BOLD}OK:{RESET} {msg}");+ } else {+ println!("OK: {}", strip_ansi(&msg.to_string()));+ }+}+pub fn err(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");+ } else {+ eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));+ }+}+pub fn warn(msg: impl std::fmt::Display) {+ if json_only() { return; }+ if stderr_is_tty() {+ eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}");+ } else {+ eprintln!("WARN: {}", strip_ansi(&msg.to_string()));+ }+}+pub fn hint(msg: impl std::fmt::Display) {+ if stderr_is_tty() {+ eprintln!("{DIM}Hint:{RESET} {msg}");+ } else {+ eprintln!("Hint: {}", strip_ansi(&msg.to_string()));+ }+}+pub fn note(msg: impl std::fmt::Display) {+ if json_only() { return; }+ if stdout_is_tty() {+ println!("{CYAN}{msg}{RESET}");+ } else {+ println!("{}", strip_ansi(&msg.to_string()));+ }+}
src/main.rsadded+13@@ -0,0 +1,13 @@+use clap::Parser;++mod cli;+mod compare;+mod io;+mod parsers;+mod result;++fn main() {+ let cli = cli::Cli::parse();+ let exit_code = cli::run(cli);+ std::process::exit(exit_code);+}
src/parsers/altium.rsadded+163@@ -0,0 +1,163 @@+//! Altium `.PcbLib` (and `.IntLib`'s embedded PcbLib stream) adapter.+//!+//! Wraps the shared `altium-pcblib` crate and adapts its rich pad JSON down+//! to concur's lean [`Part`] shape — concur only needs pad designators ++//! the footprint name to score consensus, so we drop position / size /+//! shape / drill on the floor.+//!+//! ## Multi-variant selection+//!+//! Many vendor `.PcbLib`s ship multiple footprint variants in one file —+//! e.g. DRV8316RRGFR carries `RGF0040E-IPC_A` (41-pad IPC standard, the+//! one that matches the chip's actual pinout) and `RGF0040E-MFG` (57-pad+//! manufacturing layout with extra test points and fiducials). The+//! upstream `altium-pcblib` crate picks one as "primary" by stream order+//! and shoves the rest into `_alternates`. That order isn't guaranteed+//! to give us the IPC variant, and getting the wrong one silently+//! breaks the consensus check (DRV8316 v0.4 → divergent because altium+//! reported 57 pads vs kicad+eagle's 41).+//!+//! [`parse_with_consensus_hint`] walks all the variants (primary ++//! alternates) and picks the one whose pad-count is closest to the+//! `consensus_pad_count` hint passed in by cli.rs (computed from the+//! other parsed sources). If no consensus is available yet, falls back+//! to the primary the upstream picked.++use anyhow::{Context as _, Result};+use std::path::Path;++use super::{Pad, PackageInfo, Part, Pin};++#[derive(Debug, Clone)]+struct AltiumVariant {+ module_name: Option<String>,+ pads: Vec<Pad>,+ parse_error: Option<String>,+}++/// Parse without any consensus hint (caller doesn't know what other sources+/// say yet). Returns the primary footprint as upstream chose it. Kept as+/// public for callers that don't have other sources to reason against.+#[allow(dead_code)]+pub fn parse(path: &Path) -> Result<Part> {+ parse_with_consensus_hint(path, None, None)+}++/// Read the Altium `.SchLib` symbol pins (number + name) via `altium-schlib`,+/// so the Altium source contributes to the symbol-side axes (pin count, pin+/// names, pin→pad map) — not just footprint pads. Best-effort: a missing or+/// unparseable `.SchLib` just yields no pins (footprint-only, as before).+pub fn parse_schlib_pins(schlib: &Path) -> Vec<Pin> {+ let Ok(sym) = altium_schlib::parse_altium_schlib_path(schlib) else { return Vec::new() };+ let Some(arr) = sym.get("pins").and_then(|p| p.as_array()) else { return Vec::new() };+ arr.iter()+ .filter_map(|p| {+ let number = p.get("number").and_then(|n| n.as_str())?.to_string();+ // Altium SchLib pins carry per-char overbar backslashes (C\S\); strip+ // them so names compare apples-to-apples with KiCad/EAGLE/ds2sf.+ let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("").replace('\\', "");+ if number.is_empty() { return None; }+ let is_stub_name = name.is_empty() || name == number;+ Some(Pin { number, name, is_stub_name })+ })+ .collect()+}++/// Parse and pick the variant whose pad count is closest to+/// `consensus_pad_count`. When `Some`, walks primary + every alternate.+/// `schlib` (when present) supplies the symbol-side pins.+pub fn parse_with_consensus_hint(+ path: &Path,+ consensus_pad_count: Option<usize>,+ schlib: Option<&Path>,+) -> Result<Part> {+ let parsed = altium_pcblib::parse_altium_pcblib_path(path)+ .with_context(|| format!("altium-pcblib parsing {}", path.display()))?;++ let mut variants: Vec<AltiumVariant> = Vec::new();+ variants.push(extract_variant(&parsed));+ if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {+ for alt in alts {+ variants.push(extract_variant(alt));+ }+ }++ // Reject variants that didn't parse (parse_error AND no pads) — they+ // can't contribute to a consensus and would just add noise.+ variants.retain(|v| !v.pads.is_empty() || v.parse_error.is_none());++ if variants.is_empty() {+ anyhow::bail!("no parseable footprint variants in PcbLib");+ }++ let chosen = match consensus_pad_count {+ Some(target) => {+ // Pick the variant whose pad count is closest to target.+ // Tiebreak: prefer the one whose name doesn't scream "MFG" /+ // "DEBUG" / "TEST" / "FIDUCIAL" (manufacturing layouts with+ // extra non-pinout pads).+ variants+ .iter()+ .min_by_key(|v| {+ let diff = v.pads.len().abs_diff(target);+ let name_penalty = v+ .module_name+ .as_ref()+ .map(|n| {+ let upper = n.to_uppercase();+ if upper.contains("-MFG") || upper.contains("_MFG")+ || upper.contains("DEBUG") || upper.contains("FIDUCIAL")+ || upper.contains("TEST")+ {+ 100usize+ } else {+ 0+ }+ })+ .unwrap_or(0);+ diff * 1000 + name_penalty+ })+ .expect("variants is non-empty")+ .clone()+ }+ None => variants[0].clone(),+ };++ if chosen.pads.is_empty() {+ if let Some(err_msg) = chosen.parse_error.as_ref() {+ anyhow::bail!("chosen variant failed to parse: {err_msg}");+ }+ }++ let package = chosen.module_name.clone().map(|raw_name| PackageInfo {+ raw_name,+ body_x_mm: None,+ body_y_mm: None,+ });++ let pins = schlib.map(parse_schlib_pins).unwrap_or_default();++ Ok(Part {+ source: "altium".to_string(),+ source_part_name: chosen.module_name,+ pads: chosen.pads,+ pins,+ package,+ })+}++fn extract_variant(v: &serde_json::Value) -> AltiumVariant {+ let module_name = v.get("module_name").and_then(|x| x.as_str()).map(String::from);+ let pads: Vec<Pad> = v+ .get("pads")+ .and_then(|x| x.as_array())+ .map(|arr| {+ arr.iter()+ .filter_map(|p| p.get("number").and_then(|n| n.as_str()).map(String::from))+ .map(|number| Pad { number })+ .collect()+ })+ .unwrap_or_default();+ let parse_error = v.get("_parse_error").and_then(|x| x.as_str()).map(String::from);+ AltiumVariant { module_name, pads, parse_error }+}
src/parsers/ds2sf.rsadded+67@@ -0,0 +1,67 @@+//! Parser for ds2sf's emitted JSONs.++use anyhow::{Context as _, Result};+use serde_json::Value;+use std::fs;+use std::path::Path;++use super::{Pad, PackageInfo, Part, Pin};++pub fn parse(symbol_path: &Path, footprint_path: Option<&Path>) -> Result<Part> {+ let sym_raw = fs::read_to_string(symbol_path)+ .with_context(|| format!("reading {}", symbol_path.display()))?;+ let sym: Value = serde_json::from_str(&sym_raw).context("parsing ds2sf symbol JSON")?;++ let source_part_name = sym.get("symbolName").and_then(|v| v.as_str()).map(String::from);+ let pins: Vec<Pin> = sym+ .get("pins")+ .and_then(|v| v.as_array())+ .map(|arr| {+ arr.iter()+ .map(|p| {+ let number = p.get("number").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let is_stub_name = name.is_empty() || name == number;+ Pin { number, name, is_stub_name }+ })+ .collect()+ })+ .unwrap_or_default();++ let (pads, package) = if let Some(fp_path) = footprint_path {+ let fp_raw = fs::read_to_string(fp_path)+ .with_context(|| format!("reading {}", fp_path.display()))?;+ let fp: Value = serde_json::from_str(&fp_raw).context("parsing ds2sf footprint JSON")?;++ let pads = fp+ .get("padDescriptions")+ .and_then(|v| v.as_object())+ .map(|m| m.keys().map(|k| Pad { number: k.clone() }).collect())+ .unwrap_or_default();++ let package_name = fp+ .get("package")+ .and_then(|v| v.as_str())+ .unwrap_or("")+ .to_string();+ let body_x = fp.pointer("/bodyDimensions/x").and_then(|v| v.as_f64());+ let body_y = fp.pointer("/bodyDimensions/y").and_then(|v| v.as_f64());++ let package = if !package_name.is_empty() || body_x.is_some() || body_y.is_some() {+ Some(PackageInfo { raw_name: package_name, body_x_mm: body_x, body_y_mm: body_y })+ } else {+ None+ };+ (pads, package)+ } else {+ (Vec::new(), None)+ };++ Ok(Part {+ source: "ds2sf".to_string(),+ source_part_name,+ pads,+ pins,+ package,+ })+}
src/parsers/eagle.rsadded+136@@ -0,0 +1,136 @@+//! EAGLE `.lbr` (Fusion 360 Library) parser.+//!+//! XML format. Parts of interest:+//! - `<deviceset name="...">` — the device's catalog name (closest to MPN).+//! - `<symbol name="...">` containing `<pin name="..."/>` — symbol-side pin labels.+//! - `<package name="...">` containing `<smd name="N"/>` and/or `<pad name="N"/>`.+//! - `<connect gate="..." pin="VDD" pad="8"/>` — the bridge from symbol pin to+//! footprint pad. Lets us reconstruct a per-pin {number, name} map.++use anyhow::{Context as _, Result};+use std::collections::BTreeMap;+use std::fs;+use std::path::Path;++use super::{Pad, PackageInfo, Part, Pin};++pub fn parse(path: &Path) -> Result<Part> {+ let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;+ // Older EAGLE files (7.x and earlier) carry a `<!DOCTYPE eagle SYSTEM "eagle.dtd">`+ // declaration. roxmltree disallows DTDs by default, so opt in to parsing them.+ let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+ let doc = roxmltree::Document::parse_with_options(&raw, opts).context("parsing EAGLE XML")?;++ let mut deviceset_name: Option<String> = None;+ // Per-symbol: map<pin_name, _>. We only need names for now.+ // Per-package: list<pad_name>.+ let mut package_pads: BTreeMap<String, Vec<String>> = BTreeMap::new();+ let mut package_dims: BTreeMap<String, (Option<f64>, Option<f64>)> = BTreeMap::new();+ // Connections: deviceset -> Vec<(pin_name, pad_number)>.+ let mut connections: Vec<(String, String)> = Vec::new();+ let mut chosen_package: Option<String> = None;++ // Walk every node (DFS, document order).+ for node in doc.descendants() {+ let tag = node.tag_name().name();+ match tag {+ "deviceset" => {+ if deviceset_name.is_none() {+ if let Some(n) = node.attribute("name") {+ deviceset_name = Some(n.to_string());+ }+ }+ }+ "package" => {+ if let Some(name) = node.attribute("name") {+ let mut pads: Vec<String> = Vec::new();+ let mut min_x = f64::INFINITY;+ let mut max_x = f64::NEG_INFINITY;+ let mut min_y = f64::INFINITY;+ let mut max_y = f64::NEG_INFINITY;+ for child in node.descendants() {+ let ct = child.tag_name().name();+ if ct == "smd" || ct == "pad" {+ if let Some(n) = child.attribute("name") {+ pads.push(n.to_string());+ }+ if let (Some(xs), Some(ys)) =+ (child.attribute("x"), child.attribute("y"))+ {+ if let (Ok(x), Ok(y)) = (xs.parse::<f64>(), ys.parse::<f64>()) {+ min_x = min_x.min(x);+ max_x = max_x.max(x);+ min_y = min_y.min(y);+ max_y = max_y.max(y);+ }+ }+ }+ }+ let bx = if max_x.is_finite() { Some((max_x - min_x).abs()) } else { None };+ let by = if max_y.is_finite() { Some((max_y - min_y).abs()) } else { None };+ package_pads.insert(name.to_string(), pads);+ package_dims.insert(name.to_string(), (bx, by));+ }+ }+ "connect" => {+ if let (Some(pin), Some(pad)) = (node.attribute("pin"), node.attribute("pad")) {+ connections.push((pin.to_string(), pad.to_string()));+ }+ }+ "device" => {+ // <device name="..." package="..."> — pick the first package referenced.+ if chosen_package.is_none() {+ if let Some(p) = node.attribute("package") {+ chosen_package = Some(p.to_string());+ }+ }+ }+ _ => {}+ }+ }++ // Reconstruct pins: prefer the connect[]'s name+pad mapping. Dedupe on (name, pad).+ let mut pins: Vec<Pin> = Vec::new();+ let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();+ for (pin_name, pad_num) in connections.iter() {+ let key = (pad_num.clone(), pin_name.clone());+ if seen.insert(key) {+ pins.push(Pin {+ number: pad_num.clone(),+ name: pin_name.clone(),+ is_stub_name: pin_name.is_empty() || pin_name == pad_num,+ });+ }+ }++ // Choose a package. Prefer the one referenced by the first <device package=...>, else+ // the first declared in document order.+ let pkg_key = chosen_package+ .clone()+ .or_else(|| package_pads.keys().next().cloned());++ let (pads, package) = if let Some(k) = pkg_key {+ let pads_vec = package_pads+ .get(&k)+ .cloned()+ .unwrap_or_default()+ .into_iter()+ .map(|n| Pad { number: n })+ .collect();+ let (bx, by) = package_dims.get(&k).cloned().unwrap_or((None, None));+ (+ pads_vec,+ Some(PackageInfo { raw_name: k, body_x_mm: bx, body_y_mm: by }),+ )+ } else {+ (Vec::new(), None)+ };++ Ok(Part {+ source: "eagle".to_string(),+ source_part_name: deviceset_name,+ pads,+ pins,+ package,+ })+}
src/parsers/kicad.rsadded+203@@ -0,0 +1,203 @@+//! KiCad `.kicad_sym` and `.kicad_mod` parser.+//!+//! Both files are KiCad-flavored S-expressions. We don't need a full parser —+//! we extract specific tokens with a tiny tokenizer that handles parens ++//! double-quoted strings (with escapes) + bare atoms. Good enough for what we+//! compare and noticeably smaller than pulling in a kicad-specific crate.++use anyhow::{Context as _, Result};+use std::fs;+use std::path::Path;++use super::{Pad, PackageInfo, Part, Pin};++pub fn parse(sym_path: Option<&Path>, mod_path: Option<&Path>) -> Result<Part> {+ let mut source_part_name = None;+ let mut pins: Vec<Pin> = Vec::new();+ let mut pads: Vec<Pad> = Vec::new();+ let mut package: Option<PackageInfo> = None;++ if let Some(p) = sym_path {+ let raw = fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?;+ let toks = tokenize(&raw);+ let (name, parsed_pins) = scan_kicad_sym(&toks);+ source_part_name = name;+ pins = parsed_pins;+ }+ if let Some(p) = mod_path {+ let raw = fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?;+ let toks = tokenize(&raw);+ let (fp_name, parsed_pads) = scan_kicad_mod(&toks);+ pads = parsed_pads;+ if let Some(name) = fp_name {+ package = Some(PackageInfo { raw_name: name, body_x_mm: None, body_y_mm: None });+ }+ }++ Ok(Part {+ source: "kicad".to_string(),+ source_part_name,+ pads,+ pins,+ package,+ })+}++#[derive(Debug, Clone, PartialEq)]+enum Tok {+ LParen,+ RParen,+ /// A double-quoted string with escapes resolved.+ Str(String),+ /// A bare atom (identifier or number).+ Atom(String),+}++fn tokenize(s: &str) -> Vec<Tok> {+ let mut toks = Vec::with_capacity(s.len() / 8);+ let bytes = s.as_bytes();+ let mut i = 0;+ while i < bytes.len() {+ let c = bytes[i];+ if c.is_ascii_whitespace() {+ i += 1;+ continue;+ }+ if c == b'(' {+ toks.push(Tok::LParen);+ i += 1;+ continue;+ }+ if c == b')' {+ toks.push(Tok::RParen);+ i += 1;+ continue;+ }+ if c == b'"' {+ // Quoted string with backslash escapes.+ i += 1;+ let mut buf = String::new();+ while i < bytes.len() && bytes[i] != b'"' {+ if bytes[i] == b'\\' && i + 1 < bytes.len() {+ buf.push(bytes[i + 1] as char);+ i += 2;+ } else {+ buf.push(bytes[i] as char);+ i += 1;+ }+ }+ if i < bytes.len() {+ i += 1; // closing "+ }+ toks.push(Tok::Str(buf));+ continue;+ }+ // Bare atom: read until whitespace or paren.+ let start = i;+ while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'(' && bytes[i] != b')' {+ i += 1;+ }+ toks.push(Tok::Atom(s[start..i].to_string()));+ }+ toks+}++/// Scan the kicad_sym tokens for the first symbol name and every (pin ...)'s+/// (number "X") + (name "Y") pairs.+fn scan_kicad_sym(toks: &[Tok]) -> (Option<String>, Vec<Pin>) {+ let mut name = None;+ let mut pins: Vec<Pin> = Vec::new();++ let mut i = 0;+ while i < toks.len() {+ // Find first `( symbol "..."` form for the part name.+ if name.is_none()+ && toks[i] == Tok::LParen+ && matches!(toks.get(i + 1), Some(Tok::Atom(a)) if a == "symbol")+ {+ if let Some(Tok::Str(s)) = toks.get(i + 2) {+ name = Some(s.clone());+ }+ }+ // Find `( pin <type> <shape> ... ) and read the (name ...) + (number ...).+ if toks[i] == Tok::LParen+ && matches!(toks.get(i + 1), Some(Tok::Atom(a)) if a == "pin")+ {+ // Scan until matching RParen, collecting first "(name ...)" + "(number ...)".+ let mut depth = 1;+ let mut j = i + 2;+ let mut pin_name: Option<String> = None;+ let mut pin_number: Option<String> = None;+ while j < toks.len() && depth > 0 {+ match &toks[j] {+ Tok::LParen => {+ depth += 1;+ if pin_name.is_none()+ && matches!(toks.get(j + 1), Some(Tok::Atom(a)) if a == "name")+ {+ if let Some(Tok::Str(s)) = toks.get(j + 2) {+ pin_name = Some(s.clone());+ }+ }+ if pin_number.is_none()+ && matches!(toks.get(j + 1), Some(Tok::Atom(a)) if a == "number")+ {+ if let Some(Tok::Str(s)) = toks.get(j + 2) {+ pin_number = Some(s.clone());+ }+ }+ }+ Tok::RParen => {+ depth -= 1;+ }+ _ => {}+ }+ j += 1;+ }+ if let (Some(num), nm) = (pin_number.clone(), pin_name.clone()) {+ let nm = nm.unwrap_or_default();+ let is_stub = nm.is_empty() || nm == num;+ pins.push(Pin { number: num, name: nm, is_stub_name: is_stub });+ }+ i = j;+ continue;+ }+ i += 1;+ }++ (name, pins)+}++/// Scan the kicad_mod tokens for the (footprint "name" ...) and every (pad "X" ...).+fn scan_kicad_mod(toks: &[Tok]) -> (Option<String>, Vec<Pad>) {+ let mut name = None;+ let mut pads: Vec<Pad> = Vec::new();++ let mut i = 0;+ while i < toks.len() {+ // Either `(footprint "name"` (modern KiCad ≥ 6) or `(module "name"`+ // (older KiCad / many UL exports) carries the package name.+ if name.is_none()+ && toks[i] == Tok::LParen+ && matches!(toks.get(i + 1), Some(Tok::Atom(a)) if a == "footprint" || a == "module")+ {+ if let Some(Tok::Str(s)) = toks.get(i + 2) {+ name = Some(s.clone());+ }+ }+ if toks[i] == Tok::LParen+ && matches!(toks.get(i + 1), Some(Tok::Atom(a)) if a == "pad")+ {+ // (pad "1" smd rect ...). The pad number is always the first arg.+ if let Some(Tok::Str(num)) = toks.get(i + 2) {+ pads.push(Pad { number: num.clone() });+ } else if let Some(Tok::Atom(num)) = toks.get(i + 2) {+ // Some KiCad libs emit pad numbers as bare atoms (e.g. "1" without quotes).+ pads.push(Pad { number: num.clone() });+ }+ }+ i += 1;+ }++ (name, pads)+}
src/parsers/mod.rsadded+51@@ -0,0 +1,51 @@+//! Each parser produces a [`Part`] — the normalized cross-source representation+//! that [`crate::compare`] reasons over. Parsers MUST NOT do alias resolution or+//! tolerance fuzzing — that lives in compare.rs so it's one place.++pub mod altium;+pub mod ds2sf;+pub mod eagle;+pub mod kicad;++use serde::{Deserialize, Serialize};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Part {+ /// "ds2sf" | "kicad_sym+mod" | "eagle" | "altium" — used for hint phrasing.+ pub source: String,+ /// What identifier the source called this part. KiCad uses the first symbol+ /// name in the file; EAGLE uses the deviceset name; ds2sf uses symbolName.+ pub source_part_name: Option<String>,+ /// Pads from the footprint (mechanical pin numbers/letters: "1", "10", "A4").+ pub pads: Vec<Pad>,+ /// Symbol pins. For sources that link symbol→footprint (EAGLE deviceset),+ /// the pad field is populated. For sources where the symbol is numbered-only+ /// (KiCad UL stub bundles), name == number.+ pub pins: Vec<Pin>,+ /// Package metadata if discoverable from the source.+ pub package: Option<PackageInfo>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pin {+ pub number: String,+ pub name: String,+ /// True when the source's pin "name" is just the pin number (UL stub case).+ /// Detected per-source so compare.rs can flag it without re-checking.+ pub is_stub_name: bool,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pad {+ pub number: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct PackageInfo {+ /// As-found name. e.g. "VSSOP-10", "DGS0010A", "SOIC-8".+ pub raw_name: String,+ /// Body x in mm if discoverable.+ pub body_x_mm: Option<f64>,+ /// Body y in mm if discoverable.+ pub body_y_mm: Option<f64>,+}
src/result.rsadded+214@@ -0,0 +1,214 @@+//! Unified result JSON — mirrors ds2sf's agent-to-agent contract.+//!+//! Exit codes:+//! 0 → status=golden (all sources agree exactly)+//! 0 → status=golden_with_variants (all disagreements are known-benign patterns+//! enumerated in `variants[]` — caller can+//! ship, just remember the variants)+//! 2 → status=divergent (recoverable: chip-fetcher should re-fetch+//! a lib or have the user intervene)+//! 3 → status=unrecoverable (parser failure / missing inputs)++use anyhow::{Context as _, Result};+use serde::{Deserialize, Serialize};+use std::fs;+use std::path::PathBuf;++#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]+#[serde(rename_all = "snake_case")]+pub enum Status {+ Golden,+ GoldenWithVariants,+ Divergent,+ Unrecoverable,+}++impl Status {+ pub fn exit_code(self) -> i32 {+ match self {+ Status::Golden => 0,+ Status::GoldenWithVariants => 0,+ Status::Divergent => 2,+ Status::Unrecoverable => 3,+ }+ }+ pub fn is_acceptable(self) -> bool {+ matches!(self, Status::Golden | Status::GoldenWithVariants)+ }+}++/// A variant explains a disagreement as known-benign rather than as a real+/// divergence. When every disagreeing axis is covered by a variant, status+/// becomes `golden_with_variants` (exit 0) instead of `divergent` (exit 2).+///+/// The `covers` method declares which axes a variant explains — used by the+/// status-determination logic in cli.rs to decide whether to ship or block.+#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "kind", rename_all = "snake_case")]+pub enum Variant {+ /// One source counts an exposed/thermal pad as pin N+1 while another doesn't.+ /// Pad-count diff is exactly 1 across sources. EE practice varies on whether+ /// to expose the EP as a symbol pin; either is acceptable.+ ExposedPadVariant {+ sources_with_ep: Vec<String>,+ sources_without_ep: Vec<String>,+ },+ /// A module's KiCad/EAGLE footprint includes mounting/shield/test pads+ /// beyond the user-facing pinout (the module datasheet's N pads). Caller+ /// can use whichever set fits their assembly process.+ ModuleExtraPads {+ sources_with_extras: Vec<String>,+ sources_without_extras: Vec<String>,+ extra_count: u32,+ },+ /// Package-family names disagree (different prefixes / library naming+ /// conventions, e.g. TI's `SOT_` prefix on a VSSOP footprint) BUT the+ /// underlying geometry — pad count, pad numbers, pad pitch — agrees.+ /// Cosmetic only; downstream tooling should look at geometry, not name.+ LabelOnlyMismatch {+ notes: String,+ },+ /// One source's symbol uses pin numbers as labels ("1","2","3"…) instead+ /// of real pin names (VDD, SCL, …). Symbol is unusable for schematic+ /// capture as-is, but a richer source (ds2sf or EAGLE) can supply names.+ StubSymbolPinNames {+ stub_sources: Vec<String>,+ canonical_sources: Vec<String>,+ },+ /// Body-dim x or y disagrees by >0.5 mm but pad geometry agrees. Body dims+ /// are courtyard-only — they don't affect placement. The disagreement is+ /// usually a measurement-method artifact (lead-tip span vs body D dim, etc).+ BodyDimMeasurementArtifact {+ notes: String,+ },+ /// Pin names disagree but every disagreeing pin pair is a benign naming-+ /// convention difference, not a real mismatch. Two cases handled:+ /// - Channel-digit position swaps: `OUT1` ↔ `1OUT`, `IN1+` ↔ `1IN+`+ /// (op-amps, dual DACs, multi-rail regulators)+ /// - Power-pin aliases: `V+` ↔ `VCC` ↔ `VDD`, `V-` ↔ `GND` ↔ `VSS`+ /// (chip-vendor convention vs library convention)+ NamingConventionMismatch {+ examples: Vec<String>,+ },+ /// One source represents a 4-terminal Kelvin sense resistor (Vishay WSL,+ /// Bourns CSL, Susumu PCS) with 4 pads (I+, V+, V-, I- — separate force+ /// and sense terminals for precision current measurement) while another+ /// source simplifies to 2 pads. Both are valid representations: 4-pad+ /// preserves the Kelvin connection benefit (no IR drop in the sense+ /// path) while 2-pad is fine for non-precision routing. Downstream+ /// tooling should prefer the 4-pad representation when low-µΩ accuracy+ /// matters for the design.+ KelvinSenseRepresentation {+ sources_with_kelvin: Vec<String>,+ sources_without_kelvin: Vec<String>,+ kelvin_pin_names: Vec<String>,+ },+}++impl Variant {+ /// Which `axis` names does this variant explain? Used by cli.rs to decide+ /// whether the run is `golden_with_variants` (every disagreeing axis+ /// covered) or `divergent` (something is uncovered).+ pub fn covers(&self) -> Vec<&'static str> {+ match self {+ Variant::ExposedPadVariant { .. } => vec![+ "pad_count", "pad_numbers", "symbol_pin_count", "pin_names", "pin_to_pad_map",+ ],+ Variant::ModuleExtraPads { .. } => vec![+ "pad_count", "pad_numbers", "symbol_pin_count", "pin_names", "pin_to_pad_map",+ ],+ Variant::LabelOnlyMismatch { .. } => vec!["package_family"],+ Variant::StubSymbolPinNames { .. } => vec!["pin_names", "pin_to_pad_map"],+ Variant::BodyDimMeasurementArtifact { .. } => vec!["body_dims"],+ Variant::NamingConventionMismatch { .. } => vec!["pin_names", "pin_to_pad_map"],+ Variant::KelvinSenseRepresentation { .. } => vec![+ "pad_count", "pad_numbers", "symbol_pin_count",+ "pin_names", "pin_to_pad_map", "body_dims",+ ],+ }+ }+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ResultDoc {+ pub schema_version: String,+ pub status: Status,+ pub exit_code: i32,+ pub mpn: String,+ pub summary: String,+ pub sources_compared: Vec<String>,+ pub sources_missing: Vec<String>,+ pub axes: Vec<crate::compare::AxisOutcome>,+ /// Known-benign explanations for any disagreeing axes. When every+ /// disagreeing axis is covered by a variant, status is `golden_with_variants`.+ pub variants: Vec<Variant>,+ pub hints: Vec<Hint>,+ pub extractor_version: String,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "action", rename_all = "snake_case")]+pub enum Hint {+ /// One specific library source looks suspect — chip-fetcher should re-fetch+ /// it from a different upstream (manufacturer site, alternate UL bundle, etc.).+ /// `expected_resolution` tells chip-fetcher what to verify after refetch:+ /// re-run concur and check that this specific axis now agrees.+ RefetchLibrarySource {+ /// Which source format to re-fetch — "kicad_sym" (just the symbol),+ /// "kicad_mod" (just the footprint), "kicad_both", "eagle", "altium".+ format: String,+ /// The disagreeing axis (pin_names, package_family, …) so chip-fetcher+ /// can correlate to the conflict that triggered this hint.+ suspect: String,+ notes: String,+ /// Concrete next-source recommendations, in priority order. chip-fetcher+ /// should walk these in order, re-fetch from each, and re-run concur+ /// until status flips to golden / golden_with_variants.+ suggested_sources: Vec<String>,+ /// What concur expects to see after a successful refetch — chip-fetcher+ /// uses this to confirm the refetch worked. e.g.+ /// "axis 'pin_to_pad_map' should change from disagree → agree".+ expected_resolution: String,+ },+ /// The KiCad symbol from UL has stub pin names ("1","2",…) instead of real+ /// labels (VDD,SCL,…). The footprint is fine. Best response: skip the+ /// `<MPN>.kicad_sym` from chip-fetcher entirely and feed ds2sf's symbol+ /// JSON to `sym_create` to author a fresh KiCad symbol with real labels.+ UlSymbolHasStubNames {+ source: String,+ pin_count: u32,+ suggestion: String,+ /// chip-fetcher's recommended next step: NOT a refetch — instead route+ /// downstream tooling (sym_create) to use ds2sf's pin labels.+ chip_fetcher_action: String,+ },+ /// ds2sf's output is suspect (e.g. body_dim is the lead-tip span instead of+ /// the body D dim — a classic Haiku misread that Opus usually gets right).+ /// Suggest re-running ds2sf with a stronger model.+ RerunDs2sfWithStrongerModel {+ reason: String,+ suspect_field: String,+ suggested_model: String,+ },+ /// Differences exist but are within normal tolerance. Caller can accept.+ AcceptWithCaveats {+ caveats: Vec<String>,+ },+ /// Surface to a human — automated repair isn't safe.+ HumanReview {+ reason: String,+ notes: String,+ },+ /// One or more source files weren't parseable at all.+ SourceParseFailed {+ format: String,+ path: PathBuf,+ error: String,+ },+}++pub fn write(out_path: &std::path::Path, doc: &ResultDoc) -> Result<()> {+ fs::write(out_path, serde_json::to_string_pretty(doc)? + "\n")+ .with_context(|| format!("writing {}", out_path.display()))?;+ Ok(())+}