← Commit history
.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(())+}