app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
bridge-rs: Rust bridge vertical slice (health via ab, agent file-IPC, Phase 1 verbs) — verified live on AdomLapper; docs: Phase 2 lint catalog
9 files changed
+1286
bridge-rs/.cargo/config.tomladded+3@@ -0,0 +1,3 @@+[target.x86_64-pc-windows-gnu]+linker = "x86_64-w64-mingw32-gcc"+ar = "x86_64-w64-mingw32-ar"
bridge-rs/Cargo.tomladded+18@@ -0,0 +1,18 @@+[package]+name = "altium-bridge"+version = "0.2.0"+edition = "2021"+description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium."+license = "MIT"++[dependencies]+tiny_http = "0.12"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+ureq = { version = "2", default-features = false, features = ["json"] }++[profile.release]+opt-level = "z"+lto = true+codegen-units = 1+strip = true
bridge-rs/README.mdadded+79@@ -0,0 +1,79 @@+# altium-bridge (Rust) — the bridge process, rewritten++The next major version of the bridge *process* (the part ab spawns on the Windows box). Decisions+on record (John, 2026-08-31):++1. **The bridge calls ab's verbs for everything desktop-shaped** — window enumeration, dialogs,+ screenshots, UIA, shell, taskbar — instead of re-implementing Win32. No `windows-rs`, no ctypes.+2. **The DelphiScript agent shrinks to generic primitives; the bridge becomes the brain.** Only+ ~213 of the agent's 3,675 lines touch Altium's API; the rest (JSON assembly, parsing, tallies,+ filters) moves here where it is typed, testable against recorded dumps, and never needs an+ Altium restart to change.+3. **No standalone MCP server.** External agents reach Altium through `adom-bridge-cli` on PATH+ (the Adom Anywhere lane), same as this container does.++## Status — v0 vertical slice++Speaks the **existing** agent's file-IPC protocol unchanged (`request.txt` / `request.payload` /+`response_<id>.json` / `hb.txt`), so it serves these verbs with **zero template changes**:++`status`, `readiness`, `agent_status`, `eval`, `run_command`, `open_document`, and the Phase 1+introspection surface — `list_designators`, `component_data`, `component_pins`, `list_nets`,+`net_connections`, `layers`, `copper_layers`, `read_rules`, `set_layer_visibility`,+`footprint_primitives`, `schematic_data`.++`/health` detects Altium through ab's `desktop_find_window` and parses the title the same way the+Python detector does (edition word without version parens — issue #2 — included).++**Not yet ported:** launch/close, agent bootstrap/recover (`X2 -R` lanes), dialogs, surfaces with+frames, checks/outputs, library authoring, boards, licence driving, web-control bundle install.+Until parity, the Python bridge (`../src`) stays the shipped runtime.++## The gate: ab needs a compiled-binary runtime kind++ab's SDK provisions `spawn.kind: "node" | "python"` only. This exe needs `spawn.kind: "binary"`+— requested as **adom/adom-bridge#987**. Until it lands the exe is tested by running it manually on+the box on a spare port and calling it directly (see below).++## Build (cross-compile from the container; the box has no MSVC linker)++```bash+# one-time: rustup + the windows-gnu target + mingw cross linker+curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal+rustup target add x86_64-pc-windows-gnu && sudo apt-get install -y mingw-w64+# build+cd bridge-rs && cargo build --release --target x86_64-pc-windows-gnu+# -> target/x86_64-pc-windows-gnu/release/altium-bridge.exe+```++## Test on the box (manual, until ab spawns it)++```bash+adom-bridge --ai-thread "<t>" send_files '{"filePaths":["<repo>/bridge-rs/target/x86_64-pc-windows-gnu/release/altium-bridge.exe"]}'+# on the box (PowerShell), with the Python bridge's agent already resident:+# $env:ADOM_DIRECT_API_URL='http://127.0.0.1:24680'; & "$HOME\Downloads\altium-bridge.exe" --port 8991+curl -s http://127.0.0.1:8991/health+curl -s -X POST http://127.0.0.1:8991/command -H 'content-type: application/json' -d '{"command":"altium_list_designators","args":{}}'+```++Both bridges can read the same resident agent (the inbox is serialized by the agent, one request+at a time) — but only ONE session should drive it at a time; see the dev skill's one-driver rule.++## Layout++| File | Role |+|---|---|+| `src/main.rs` | `--port`, `ADOM_BIND_HOST`, `/health` `/status` `/verbs` `/command`, envelope timing |+| `src/ab.rs` | loopback client to ab's direct API (token badge, forwarded `X-Adom-Caller-*`, delegate) |+| `src/agent.rs` | the file-drop protocol to the resident DelphiScript agent |+| `src/health.rs` | Altium detection via ab + the title parser |+| `src/verbs.rs` | dispatch; v0 views over existing agent commands |++## Next++1. Port lifecycle (launch/close, bootstrap, recover with the busy-notice reaper via ab).+2. Introduce the dump primitives (`dump_components`, `dump_pads`, `dump_primitives`, `dump_nets`,+ `dump_rules`, `dump_layers`, `dump_sch_components`, `dump_sch_pins`, `apply`) and re-express the+ verbs as views; record fixtures from Bluetooth Sentinel for unit tests.+3. Surfaces + frames (all via ab screenshots), checks/outputs, library authoring, boards.+4. Swap: `bridge.json` → `spawn.kind: "binary"` once ab#987 ships; retire `../src`.
bridge-rs/src/ab.rsadded+49@@ -0,0 +1,49 @@+//! Loopback client for Adom Bridge's direct API — the bridge calls ab for everything+//! desktop-shaped (windows, dialogs, screenshots, UIA, shell) instead of re-implementing Win32.+use serde_json::{json, Value};++pub fn call(command: &str, args: Value, forwarded: &[(String, String)], timeout_s: u64) -> Value {+ let base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_else(|_| "http://127.0.0.1:24680".into());+ let url = format!("{}/command", base.trim_end_matches('/'));+ let mut args = args;+ if forwarded.is_empty() {+ if let Some(o) = args.as_object_mut() {+ o.entry("caller").or_insert(json!({"aiThread": "altium-bridge", "source": "bridge"}));+ }+ }+ let mut req = ureq::post(&url)+ .timeout(std::time::Duration::from_secs(timeout_s))+ .set("Content-Type", "application/json");+ if let Ok(tok) = std::env::var("ADOM_BRIDGE_TOKEN") {+ req = req.set("X-Adom-Bridge-Token", &tok);+ }+ for (k, v) in forwarded {+ req = req.set(k, v);+ }+ req = req.set("X-Adom-Caller-Delegate", "altium");+ match req.send_json(json!({"command": command, "args": args})) {+ Ok(r) => unwrap_output(r.into_json::<Value>().unwrap_or_else(|e| json!({"success": false, "error": format!("ab reply not JSON: {e}")}))),+ Err(ureq::Error::Status(code, r)) => {+ let body = r.into_string().unwrap_or_default();+ json!({"success": false, "error": format!("ab HTTP {code}: {}", body.chars().take(200).collect::<String>()), "errorCode": "ab_callback_failed"})+ }+ Err(e) => json!({"success": false, "error": format!("ab unreachable: {e}"), "errorCode": "ab_unreachable"}),+ }+}++/// ab's direct API returns {error, output, status, success} with the verb's real result JSON-encoded+/// as a STRING in `output`. Surface the inner object, keeping the outer status fields.+fn unwrap_output(v: Value) -> Value {+ let inner = v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok());+ match inner {+ Some(Value::Object(mut m)) => {+ if let Some(o) = v.as_object() {+ for k in ["success", "status", "error", "errorCode"] {+ if let Some(x) = o.get(k) { m.entry(k.to_string()).or_insert(x.clone()); }+ }+ }+ Value::Object(m)+ }+ _ => v,+ }+}
bridge-rs/src/agent.rsadded+83@@ -0,0 +1,83 @@+//! File-drop IPC to the resident DelphiScript agent (protocol identical to the Python bridge):+//! request.txt lines `id=<rid>`, `command=<cmd>`, `k=v`... (CRLF), written atomically via .tmp+rename+//! request.payload optional multi-line body (statements for eval, specs, ...)+//! response_<rid>.json the agent's reply; `data.ok` decides success+//! hb.txt heartbeat the loop refreshes every tick+use serde_json::{json, Value};+use std::path::PathBuf;+use std::time::{Duration, Instant, SystemTime};++pub fn workspace_dir() -> PathBuf {+ let local = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into());+ PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium").join("agent")+}++pub fn is_alive() -> bool {+ let hb = workspace_dir().join("hb.txt");+ match std::fs::metadata(&hb).and_then(|m| m.modified()) {+ Ok(t) => SystemTime::now().duration_since(t).map(|d| d < Duration::from_secs(8)).unwrap_or(false),+ Err(_) => false,+ }+}++fn rid() -> String {+ let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);+ format!("{:x}", n & 0xffff_ffff_ffff)+}++pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {+ if !is_alive() {+ return json!({"success": false, "errorCode": "agent_not_running", "_hint": "altium_agent_start (the Python bridge bootstraps the agent; the Rust bridge reuses a running one)"});+ }+ let ws = workspace_dir();+ let id = rid();+ let mut lines = vec![format!("id={id}"), format!("command={command}")];+ for (k, v) in params {+ if v.contains('\n') || v.contains('\r') {+ return json!({"success": false, "error": format!("param {k} must be single-line; use payload")});+ }+ lines.push(format!("{k}={v}"));+ }+ let inbox = ws.join("request.txt");+ let t0 = Instant::now();+ while inbox.exists() && t0.elapsed() < timeout {+ std::thread::sleep(Duration::from_millis(20));+ }+ if inbox.exists() {+ return json!({"success": false, "errorCode": "agent_busy", "error": "previous request still in the inbox"});+ }+ if let Some(p) = payload {+ let tmp = ws.join("request.payload.tmp");+ if std::fs::write(&tmp, p).is_ok() {+ let _ = std::fs::rename(&tmp, ws.join("request.payload"));+ }+ }+ let tmp = ws.join("request.txt.tmp");+ if let Err(e) = std::fs::write(&tmp, format!("{}\r\n", lines.join("\r\n"))) {+ return json!({"success": false, "error": format!("cannot write request: {e}")});+ }+ if let Err(e) = std::fs::rename(&tmp, &inbox) {+ return json!({"success": false, "error": format!("cannot post request: {e}")});+ }+ let resp = ws.join(format!("response_{id}.json"));+ while t0.elapsed() < timeout {+ if resp.exists() {+ if let Ok(raw) = std::fs::read_to_string(&resp) {+ if let Ok(mut v) = serde_json::from_str::<Value>(&raw) {+ let _ = std::fs::remove_file(&resp);+ let ok = v.get("data").and_then(|d| d.get("ok")).and_then(|b| b.as_bool()).unwrap_or(true);+ if let Some(o) = v.as_object_mut() {+ o.insert("success".into(), json!(ok));+ o.insert("roundtripMs".into(), json!(t0.elapsed().as_millis() as u64));+ }+ return v;+ }+ }+ std::thread::sleep(Duration::from_millis(50));+ continue;+ }+ std::thread::sleep(Duration::from_millis(10));+ }+ json!({"success": false, "errorCode": "timed_out", "error": format!("agent did not answer within {}s", timeout.as_secs()),+ "agentAlive": is_alive(), "_hint": "a slow Altium process, or the engine halted on a modal — altium_check_dialogs / altium_agent_recover"})+}
bridge-rs/src/health.rsadded+91@@ -0,0 +1,91 @@+//! /health — Altium detection through ab's window enumeration (no Win32 in the bridge).+use crate::{ab, agent, BRIDGE_VERSION};+use serde_json::{json, Value};++/// Parse "<doc> - Altium Designer[ Develop][ (26.9.1)][ - Trial]" (issue #2: edition without parens).+pub fn parse_title(t: &str) -> Option<Value> {+ let i = t.find("Altium Designer")?;+ let rest = &t[i + "Altium Designer".len()..];+ let mut edition = None;+ let mut version = None;+ let mut lic = None;+ let mut s = rest.trim_start();+ for ed in ["Develop", "Viewer", "Standard", "Professional", "Pro", "Enterprise"] {+ if let Some(r) = s.strip_prefix(ed) {+ edition = Some(ed.to_string());+ s = r.trim_start();+ break;+ }+ }+ if let Some(r) = s.strip_prefix('(') {+ if let Some(j) = r.find(')') {+ version = Some(r[..j].to_string());+ s = r[j + 1..].trim_start();+ }+ }+ if let Some(r) = s.strip_prefix("- ") {+ lic = Some(r.trim().to_string());+ }+ if edition.is_none() && version.is_none() && lic.is_none() && rest.trim().is_empty() && i != 0 {+ return None; // bare mid-string "Altium Designer" inside a dialog title+ }+ let doc = if i > 3 && t[..i].ends_with(" - ") { Some(t[..i - 3].to_string()) } else { None };+ Some(json!({"edition": edition, "adVersion": version, "licenceState": lic, "document": doc}))+}++static CACHE: std::sync::Mutex<Option<(std::time::Instant, Value)>> = std::sync::Mutex::new(None);++/// Licence/window state via ab, cached 5 s: an ab round trip costs ~400-800 ms and every verb gates on it.+pub fn licence_state() -> Value {+ if let Ok(g) = CACHE.lock() {+ if let Some((t, v)) = g.as_ref() {+ if t.elapsed() < std::time::Duration::from_secs(5) { return v.clone(); }+ }+ }+ let v = licence_state_uncached();+ if let Ok(mut g) = CACHE.lock() { *g = Some((std::time::Instant::now(), v.clone())); }+ v+}++fn licence_state_uncached() -> Value {+ let r = ab::call("desktop_find_window", json!({"titleContains": "Altium Designer"}), &[], 8);+ let wins: Vec<Value> = r.get("windows").and_then(|m| m.as_array()).cloned()+ .or_else(|| r.get("matches").and_then(|m| m.as_array()).cloned())+ .or_else(|| r.get("best").filter(|b| b.is_object()).map(|b| vec![b.clone()]))+ .unwrap_or_default();+ for w in wins {+ if let Some(t) = w.get("title").and_then(|t| t.as_str()) {+ if let Some(mut p) = parse_title(t) {+ let lic = p.get("licenceState").and_then(|l| l.as_str()).map(|s| s.to_string());+ let licensed = lic.as_deref().map(|l| l != "No License");+ if let Some(o) = p.as_object_mut() {+ o.insert("running".into(), json!(true));+ o.insert("hwnd".into(), w.get("hwnd").cloned().unwrap_or(Value::Null));+ o.insert("licensed".into(), json!(licensed));+ o.insert("state".into(), json!(lic));+ }+ return p;+ }+ }+ }+ let ab_down = r.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");+ json!({"running": false, "abUnreachable": ab_down})+}++pub fn report() -> Value {+ let lic = licence_state();+ let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);+ let (led, summary) = if lic.get("abUnreachable").and_then(|b| b.as_bool()).unwrap_or(false) {+ ("yellow", "Adom Bridge unreachable".to_string())+ } else if !running {+ ("yellow", "Altium not running".to_string())+ } else if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+ ("yellow", format!("Altium {}", lic.get("state").and_then(|s| s.as_str()).unwrap_or("No License")))+ } else if agent::is_alive() {+ ("green", "Altium + agent ready".to_string())+ } else {+ ("green", "Altium running (agent idle)".to_string())+ };+ json!({"status": "ok", "led": led, "summary": summary, "tooltip": format!("altium-bridge {BRIDGE_VERSION} - {summary}"),+ "bridgeVersion": BRIDGE_VERSION, "licence": lic, "engine": "rust"})+}
bridge-rs/src/main.rsadded+67@@ -0,0 +1,67 @@+//! altium-bridge (Rust) — spawned by ab with `--port <n>`, binds ADOM_BIND_HOST.+//! GET /health|/status -> {status, led, summary, tooltip, bridgeVersion, licence}+//! POST /command {command, args} -> verb envelope. Everything desktop-shaped goes to ab.+mod ab;+mod agent;+mod health;+mod verbs;++use std::time::Instant;+use tiny_http::{Header, Method, Response, Server};++pub const BRIDGE_VERSION: &str = "0.2.0-rs";++fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {+ let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());+ Response::from_data(data)+ .with_status_code(status)+ .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+}++fn main() {+ let args: Vec<String> = std::env::args().collect();+ let port: u16 = args+ .iter()+ .position(|a| a == "--port")+ .and_then(|i| args.get(i + 1))+ .and_then(|p| p.parse().ok())+ .unwrap_or(0);+ let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());+ let server = Server::http(format!("{host}:{port}")).expect("bind");+ let bound = server.server_addr();+ println!("[altium] bridge {BRIDGE_VERSION} listening on {bound}");+ for mut req in server.incoming_requests() {+ let t0 = Instant::now();+ let path = req.url().to_string();+ let method = req.method().clone();+ let resp = match (method, path.as_str()) {+ (Method::Get, "/health") | (Method::Get, "/status") => json_response(200, &health::report()),+ (Method::Get, "/verbs") => json_response(200, &serde_json::json!({"verbs": verbs::names(), "bridgeVersion": BRIDGE_VERSION})),+ (Method::Post, "/command") => {+ let mut body = String::new();+ let _ = req.as_reader().read_to_string(&mut body);+ let forwarded: Vec<(String, String)> = req+ .headers()+ .iter()+ .filter(|h| h.field.as_str().as_str().to_ascii_lowercase().starts_with("x-adom-caller"))+ .map(|h| (h.field.as_str().to_string(), h.value.as_str().to_string()))+ .collect();+ match serde_json::from_str::<serde_json::Value>(if body.is_empty() { "{}" } else { &body }) {+ Err(e) => json_response(400, &serde_json::json!({"success": false, "error": format!("Invalid JSON: {e}")})),+ Ok(v) => {+ let command = v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let vargs = v.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));+ let mut out = verbs::dispatch(&command, &vargs, &forwarded);+ if let Some(o) = out.as_object_mut() {+ o.insert("durationMs".into(), serde_json::json!(t0.elapsed().as_millis() as u64));+ o.entry("bridgeVersion").or_insert(serde_json::json!(BRIDGE_VERSION));+ }+ json_response(200, &out)+ }+ }+ }+ _ => json_response(404, &serde_json::json!({"error": "Not found"})),+ };+ let _ = req.respond(resp);+ }+}
bridge-rs/src/verbs.rsadded+117@@ -0,0 +1,117 @@+//! Verb dispatch. v0 = the Phase 1 introspection surface as thin views over the existing agent+//! commands (zero template changes), plus status/readiness. Every verb returns the envelope the+//! Python bridge returns, so the CLI and callers see no difference.+use crate::{agent, health};+use serde_json::{json, Value};+use std::time::Duration;++const KNOWN: &[&str] = &[+ "readiness", "status", "agent_status", "eval", "run_command", "open_document",+ "list_designators", "component_data", "component_pins", "list_nets", "net_connections",+ "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data",+];++pub fn names() -> Vec<String> {+ KNOWN.iter().map(|v| format!("altium_{v}")).collect()+}++fn s(args: &Value, k: &str) -> Option<String> {+ args.get(k).map(|v| match v {+ Value::String(x) => x.clone(),+ Value::Bool(b) => if *b { "1".into() } else { "0".into() },+ other => other.to_string(),+ })+}++fn need_licensed() -> Option<Value> {+ let lic = health::licence_state();+ if !lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false) {+ return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));+ }+ if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+ return Some(json!({"success": false, "errorCode": "not_licensed", "licenceState": lic.get("state").cloned(),+ "_hint": "Altium refuses scripts without a licence. altium_license '{}'."}));+ }+ None+}++fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value {+ if let Some(g) = need_licensed() { return g; }+ let mut r = agent::call(cmd, ¶ms, None, Duration::from_secs(timeout));+ let err = r.get("data").and_then(|d| d.get("error")).and_then(|e| e.as_str()).unwrap_or("");+ if err.contains("no current PCB board") {+ r["_hint"] = json!("Open a .PcbDoc first (altium_open_document / altium_show_2d_board), then retry.");+ } else if err.contains("no current schematic") {+ r["_hint"] = json!("Open a .SchDoc first (altium_open_document / altium_show_schematic), then retry.");+ }+ r+}++pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> Value {+ let cmd = command.strip_prefix("altium_").unwrap_or(command);+ let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);+ match cmd {+ "status" | "readiness" => {+ let lic = health::licence_state();+ json!({"success": true, "running": lic.get("running"), "licensed": lic.get("licensed"),+ "licenceState": lic.get("state"), "agentAlive": agent::is_alive(), "engine": "rust"})+ }+ "agent_status" => json!({"success": true, "alive": agent::is_alive(), "responsive": agent::is_alive(),+ "workspace": agent::workspace_dir().to_string_lossy(), "engine": "rust"}),+ "eval" => {+ if let Some(g) = need_licensed() { return g; }+ match s(args, "text").or_else(|| s(args, "code")) {+ None => json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}),+ Some(t) => agent::call("eval", &[], Some(&t), Duration::from_secs(timeout.max(30))),+ }+ }+ "run_command" => {+ if let Some(g) = need_licensed() { return g; }+ let c = s(args, "command").or_else(|| s(args, "process")).unwrap_or_default();+ if !c.contains(':') {+ return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom"});+ }+ let params = match args.get("params") {+ Some(Value::Object(m)) => m.iter().map(|(k, v)| format!("{k}={}", v.as_str().map(|x| x.to_string()).unwrap_or(v.to_string()))).collect::<Vec<_>>().join("|"),+ Some(Value::String(p)) => p.clone(),+ _ => String::new(),+ };+ agent::call("run_process", &[("process", c), ("params", params)], None, Duration::from_secs(timeout))+ }+ "open_document" => {+ if let Some(g) = need_licensed() { return g; }+ match s(args, "path").or_else(|| s(args, "filePath")) {+ None => json!({"success": false, "error": "path is required"}),+ Some(p) => agent::call("open", &[("path", p)], None, Duration::from_secs(timeout)),+ }+ }+ "list_designators" => board_read("list_designators", vec![], 90),+ "component_data" => board_read("component_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into()))], 90),+ "component_pins" => match s(args, "designator") {+ None => json!({"success": false, "error": "designator is required (e.g. U1)"}),+ Some(d) => board_read("component_pins", vec![("designator", d)], 90),+ },+ "list_nets" => board_read("list_nets", vec![], 90),+ "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),+ "layers" => board_read("layers", vec![], 90),+ "copper_layers" => board_read("copper_layers", vec![], 90),+ "read_rules" => board_read("read_rules", vec![], 90),+ "set_layer_visibility" => match s(args, "layerId") {+ None => json!({"success": false, "error": "layerId is required (from altium_layers)"}),+ Some(l) => {+ let shown = args.get("displayed").and_then(|b| b.as_bool()).unwrap_or(true);+ board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60)+ }+ },+ "footprint_primitives" => match s(args, "designator") {+ None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}),+ Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),+ },+ "schematic_data" => {+ let pins = if args.get("pins").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };+ board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into())], 120)+ }+ _ => json!({"success": false, "error": format!("Unknown command: {command}"),+ "_hint": format!("Rust bridge v0 serves: {}", names().join(", "))}),+ }+}
docs/lint-catalog.mdadded+779@@ -0,0 +1,779 @@+<!-- Phase 2 design-lint catalog. Drafted 2026-08-31 from eda-agent's 31 checks + 4 Adom checks; API references marked UNVERIFIED must be eval-probed before implementation. -->+# Adom Altium-Bridge Phase-2 Design-Lint Catalog++| Field | Value |+| --- | --- |+| Title | Adom altium-bridge Phase-2 DESIGN-LINT CATALOG |+| Document id | ALTIUM-LINT-SPEC-001 |+| Status | SPEC ONLY — no implementation, no scripts to ship |+| Modeled on | eda-agent `design_lint_report` (31 checks) plus 4 Adom-specific checks |+| Check count | 35 |+| Date | 2026-08-31 |++## Purpose++This catalog is the Phase-2 **read-only design-lint specification** for Adom's altium-bridge. It lists every check an agent is expected to run against an open Altium Designer project, including:++- What each check detects+- Severity (`error` / `warning` / `info`)+- Surface (`schematic sheet` / `PCB board` / `library` / `project`)+- Altium DelphiScript objects and properties to **read** (no mutating APIs except read-side rebuilds such as `Polygon.Rebuild` used only to inspect pour state)+- Detection logic in plain steps+- An example good finding message++The catalog maps 1:1 onto eda-agent's 31-check `design_lint_report` sweep (schematic hygiene, connectivity, PCB geometry, variant DNP, polygon validity) and adds **four Adom extras**:++1. `mouser_digikey_lcsc_params` — all three supplier PNs required+2. `pin1_symbol_footprint` — pin-1 / pad-1 disagreement+3. `unused_mechanical_layers` — mechanical layers with leftover content+4. `missing_power_testpoints` — power rails without a test point++Severity mapping vs eda-agent: eda-agent uses `critical` / `warning` / `info`. This catalog uses `error` / `warning` / `info` (`error` ≡ eda-agent `critical`).++## Out of scope++- **No implementation.** This file is not Pascal, Python, or MCP tool code.+- **Do not launch Altium.** Do not open `.PcbDoc` / `.SchDoc` / `.PrjPcb`.+- **Do not touch** any altium-bridge repo, Adom Bridge folders, or Altium install.+- **Do not run** adom-bridge / `altium_eval` against a live designer in this phase.+- **Do not write exploits, DRC mutators, or auto-fix scripts.** Lint is read-only. `pcb.run_drc` (if ever invoked later) mutates the `.DRC` report file and is optional, off by default.+- Library *authoring* (creating symbols/footprints) is out of scope; library *reads* (models, pin-1, 3D bodies) are in scope.++## How an AI would run these checks later (via `altium_eval`)++This is documentation of the **intended** execution model. It is not permission to run it now.++1. Ensure Altium Designer has the target `.PrjPcb` focused and compiled (`IProject.DM_Compile` / workspace `GetWorkspace`). Prefer SmartCompile; force recompile only if the netlist is stale.+2. For each check, emit a **read-only DelphiScript** snippet (no `AddPCBObject`, no `SchObjectFactory` create, no file writes except returning JSON through the bridge stdout/IPC channel).+3. Send the snippet through the existing altium-bridge `altium_eval` (or equivalent `audit.*` IPC command) against `SchServer` / `PCBServer`.+4. Typical entry points:+ - Schematic: `SchServer.GetCurrentSchDocument` → `ISch_Document` / `ISch_Sheet`; `SchIterator_Create`; `AddFilter_ObjectSet(MkSet(eSchPort, eNetLabel, ePowerPort, eSchComponent, …))`.+ - PCB: `Board := PCBServer.GetCurrentPCBBoard`; `Board.BoardIterator_Create`; `Iterator.AddFilter_ObjectSet(MkSet(eViaObject, ePadObject, eTrackObject, …))`; `Iterator.AddFilter_LayerSet(AllLayers)`; `Iterator.AddFilter_Method(eProcessAll)`.+ - Project / nets: `GetWorkspace` → `IWorkspace` → `IProject` → `IDocument` / flattened `DM_DocumentFlattened`; `INet.DM_PinCount`, `IComponent`.+5. Destroy iterators (`SchIterator_Destroy`, `BoardIterator_Destroy`) after each walk.+6. Aggregate `{checked, violations, items[]}` per check into a `design_lint_report`-shaped payload: `summary`, `sections`, `totals`, `_failed`.+7. Never highlight/select on canvas as the primary result; return structured findings with designator, net, sheet/board, and coordinates in mils.++### Shared API conventions (verified unless marked)++| Server | Object | Notes |+| --- | --- | --- |+| `SchServer` | `ISch_Sheet` / `ISch_Document` | Current schematic |+| `SchServer` | `ISch_Iterator` | `SchIterator_Create` / `FirstSchObject` / `NextSchObject` |+| | Object ids | `eSchPort`, `eNetLabel`, `ePowerPort` / `ePowerObject`, `eSchComponent`, `ePin`, `eWire`, `eBus`, `eSheetEntry`, `eImage` |+| | Interfaces | `ISch_Port`, `ISch_NetLabel`, `ISch_PowerPort` / `ISch_PowerObject`, `ISch_Component`, `ISch_Pin`, `ISch_Parameter`, `ISch_Wire` |+| `PCBServer` | `IPCB_Board` | `PCBServer.GetCurrentPCBBoard` |+| | Iterators | `BoardIterator_Create`, spatial iterator, group iterator |+| | Objects | `IPCB_Component`, `IPCB_Via`, `IPCB_Pad`, `IPCB_Track`, `IPCB_Arc`, `IPCB_Polygon`, `IPCB_BoardOutline`, `IPCB_LayerObject`, `IPCB_Text`, `IPCB_ComponentBody`, `IPCB_Net`, `IPCB_Region` |+| | Stack | `ILayerStack` |+| Workspace | `IWorkspace`, `IProject`, `IDocument`, `IComponent`, `INet` | Flattened netlist after compile |+| Units | `TCoord`; `MilsToCoord` / `CoordToMils` | 1 mil = 10000 internal units |++`ResetParameters` is a parametric-process helper (Client API), not an object walker; lint scripts should not need it unless invoking a process.++---++## Check catalog++Each check uses snake_case `id`. eda-agent names are noted where they differ.++---++### 1. `floating_ports`++- **name:** Floating / unconnected schematic ports+- **what it detects:** Ports whose electrical hotspot is not on a wire, pin, or sheet entry, or whose net does not appear on any pin / sheet entry on the same sheet.+- **severity:** warning+- **surface:** schematic sheet+- **eda-agent analog:** `find_floating_ports`+- **API to read:**+ - `ISch_Sheet` + `ISch_Iterator` filtered to `eSchPort` → `ISch_Port`+ - `ISch_Port.Name` / `Text` (net name), `Location` / `Location.X` / `Location.Y` **UNVERIFIED** exact location property name (`ISch_GraphicalObject.Location` is typical)+ - Spatial / small-area iterator at the port hotspot for `eWire`, `ePin`, `eSheetEntry`+ - Flattened `INet` membership as a second signal+- **detection logic:**+ 1. Compile the project.+ 2. For every schematic sheet, iterate `eSchPort`.+ 3. A port is floating if no `eWire` / pin / sheet entry occupies its hotspot **or** the flattened net for that port name has zero pins and zero sheet entries on that sheet.+ 4. Report net, sheet filename, location in mils.+- **example good finding message:** `Port "SPI_CLK" on sheet MCU.SchDoc at (1250, 800) mils is floating: hotspot is not on a wire, pin, or sheet entry.`++---++### 2. `unmatched_ports`++- **name:** Multi-driver and no-driver nets (port direction)+- **what it detects:** Flattened nets with more than one Output port (multi-driver), or Input ports with zero Output/Bidirectional ports (no driver).+- **severity:** error+- **surface:** project+- **eda-agent analog:** `find_unmatched_ports` (`multi_output` / `no_driver`)+- **API to read:**+ - `ISch_Port` direction: `ISch_Port.IOType` **UNVERIFIED** exact enum (`ePortIOInput` / `ePortIOOutput` / `ePortIOBidirectional` typical in Schematic API)+ - Flattened `INet.DM_NetName`, group ports by net+- **detection logic:**+ 1. Compile; walk all ports on all compiled documents.+ 2. Bucket by flattened net name; count Input / Output / Bidirectional.+ 3. Flag `output_count > 1` as multi-driver; flag `input_count > 0` and `output_count + bidir_count = 0` as no-driver.+- **example good finding message:** `Net "UART_TX" has 2 Output ports (multi-driver) across MCU.SchDoc and IO.SchDoc.`++---++### 3. `orphan_net_labels`++- **name:** Unconnected / orphan net labels+- **what it detects:** `ISch_NetLabel` objects whose location is not on a wire or bus (phantom nets).+- **severity:** error+- **surface:** schematic sheet+- **eda-agent analog:** `find_orphan_net_labels`+- **API to read:**+ - `ISch_NetLabel`, object id `eNetLabel`+ - `ISch_NetLabel.Text` (label string)+ - Location X/Y; spatial iterate 1-mil square for `eWire` / `eBus`+- **detection logic:**+ 1. Iterate every `eNetLabel` on every sheet.+ 2. If no wire/bus intersects the label location, flag as orphan.+- **example good finding message:** `Net label "nRESET" on PWR.SchDoc at (420, 210) mils is not on any wire or bus.`++---++### 4. `orphan_power_objects`++- **name:** Unconnected / orphan power ports+- **what it detects:** Power-port markers sitting off-wire (phantom GND/VCC).+- **severity:** error+- **surface:** schematic sheet+- **eda-agent analog:** `find_orphan_power_objects`+- **API to read:**+ - `ISch_PowerPort` / `ISch_PowerObject`; object id `ePowerPort` / `ePowerObject` (**UNVERIFIED** which id the running AD version exposes; both appear in docs)+ - `Text` / net name; `Style` (`ePowerGndPower`, `ePowerGndSignal`, `ePowerGndEarth`, `ePowerBar`, …)+ - Spatial 1-mil test for `eWire`+- **detection logic:**+ 1. Iterate power objects.+ 2. If no wire at the electrical hotspot, flag orphan.+- **example good finding message:** `Power port "GND" on MCU.SchDoc at (100, 50) mils is not on a wire.`++---++### 5. `single_pin_nets`++- **name:** Single-pin named nets+- **what it detects:** Flattened nets with exactly one pin **and** at least one net-label / port / power-object (broken named connection, not a silent NC).+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `find_single_pin_nets`+- **API to read:**+ - `INet.DM_PinCount` (workspace) — also cited as `Net.PinCount` in some PCB-side `IPCB_Net` samples+ - `INet.DM_NetName`; first pin designator + pin number+ - Presence of net identifiers on that net (**UNVERIFIED** exact `INet` child enumeration: `DM_NetItemCount` / schematic objects sharing the name)+- **detection logic:**+ 1. Compile; use `DM_DocumentFlattened`.+ 2. For each net with `DM_PinCount = 1`, check that a net label, port, or power object names that net.+ 3. Skip unnamed default nets and intentional NC pins if the lone pin name matches NC/DNC/RSVD.+- **example good finding message:** `Net "I2C_SDA" has exactly 1 pin (U3-12) plus a net label; the other end is missing.`++---++### 6. `power_port_orientation`++- **name:** Power-port orientation convention+- **what it detects:** Ground symbols not facing down; power bars not facing up.+- **severity:** warning+- **surface:** schematic sheet+- **eda-agent analog:** `power_port_orientation`+- **API to read:**+ - `ISch_PowerPort.Style`: `ePowerGndPower`, `ePowerGndSignal`, `ePowerGndEarth`, `ePowerBar`+ - `ISch_PowerPort.Orientation` / `GetState_Orientation` — 0 / 90 / 180 / 270 degrees+- **detection logic:**+ 1. Iterate power ports.+ 2. Ground styles must be 270° (down). Bar style must be 90° (up).+ 3. Other styles (circle, arrow, wave) are info-only / skip unless project standard says otherwise.+- **example good finding message:** `Ground power port "GND" on PWR.SchDoc has Orientation=90 (expected 270 / down).`++---++### 7. `duplicate_designators`++- **name:** Duplicate schematic designators+- **what it detects:** Two or more source-sheet components sharing the same designator (pre-flatten).+- **severity:** error+- **surface:** project+- **eda-agent analog:** `find_designator_collisions`+- **API to read:**+ - `ISch_Component.Designator` / `Designator.Text`+ - `ISch_Component.UniqueId`+ - Walk **source** sheets, not compiled flattened docs (flattening hides collisions with channel suffixes)+- **detection logic:**+ 1. Collect designator → list of (sheet, unique id).+ 2. Flag any designator with count > 1.+- **example good finding message:** `Designator R12 occurs 2 times (MCU.SchDoc, ANALOG.SchDoc).`++---++### 8. `component_param_visibility`++- **name:** Per-class visible component parameters+- **what it detects:** Caps without visible value+voltage, resistors without visible value, inductors without L+Isat, ICs without visible MPN.+- **severity:** info+- **surface:** schematic sheet+- **eda-agent analog:** `component_param_visibility` / `audit.validate_component_params`+- **API to read:**+ - `ISch_Component.Designator`, `Comment`+ - Child iterator `eParameter` → `ISch_Parameter.Name`, `Text`, `IsHidden`+- **detection logic:**+ 1. Classify by designator prefix (C/R/L/U).+ 2. Required visible (`IsHidden=False`) fields: C → value ending `F` and voltage ending `V`; R → value ending `R|k|M|m`; L → `H` and current `A`; U → parameter name starting `Manufacturer Part Number`.+ 3. Comment field may satisfy a requirement if visible and matching the pattern.+- **example good finding message:** `C17 (class capacitor) is missing visible voltage rating (need a parameter or Comment ending in V).`++---++### 9. `missing_datasheets`++- **name:** ICs missing datasheet URL+- **what it detects:** U* components with no `http(s)://` in HelpURL / Datasheet / DatasheetURL / ComponentLink1URL…ComponentLink4URL.+- **severity:** info+- **surface:** schematic sheet / library+- **eda-agent analog:** `find_missing_datasheets`+- **API to read:** `ISch_Parameter.Name` and `Text` on `ISch_Component`+- **detection logic:** Scan U* parameters; if none contain `http://` or `https://` in those slots, flag.+- **example good finding message:** `U4 (lib_ref STM32G031K8Tx, comment STM32G031K8T6) has no http(s) datasheet URL in HelpURL/Datasheet/ComponentLink*URL.`++---++### 10. `mpn_inconsistencies`++- **name:** Conflicting manufacturer part numbers+- **what it detects:** Same `(lib_ref, comment)` group of ICs with different MPNs.+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `find_mpn_inconsistencies`+- **API to read:** `ISch_Component.LibReference` **UNVERIFIED** (`LibraryPath` / `SymbolReference` also exist); `Comment`; parameter `Manufacturer Part Number`+- **detection logic:** Group U* by (lib_ref, comment); if unique MPN set size > 1, flag the group.+- **example good finding message:** `lib_ref=AP2112K-3.3TRG1 comment=3.3V LDO has MPNs AP2112K-3.3TRG1 and AP2112K-3.3TRG1-ND across U10,U11.`++---++### 11. `placeholder_values`++- **name:** Placeholder parameter strings+- **what it detects:** Component parameters equal to TBD/TBA/TODO/FIXME/XXX/?/??/???/PLACEHOLDER/FILLER/ASK/UNKNOWN/N/A/NA (trim, case-insensitive).+- **severity:** error+- **surface:** schematic sheet+- **eda-agent analog:** `find_placeholder_values`+- **API to read:** All `ISch_Parameter.Text` plus `Comment`+- **detection logic:** Whole-string match after trim; skip empty (other checks cover empty).+- **example good finding message:** `R9 parameter Comment="TBD" looks like a leftover placeholder.`++---++### 12. `visible_supplier_pn`++- **name:** Visible supplier part numbers on schematic+- **what it detects:** Parameters whose name starts with `supplier` and `IsHidden=False` (eda-agent BOM-hygiene: supplier SKUs should not appear on SCH PDF). **Note:** Adom extra `mouser_digikey_lcsc_params` *requires* those parameters to **exist**; this check only requires they are **hidden** on the canvas.+- **severity:** info+- **surface:** schematic sheet+- **eda-agent analog:** `find_visible_supplier_pn`+- **API to read:** `ISch_Parameter.Name`, `Text`, `IsHidden`+- **detection logic:** Name prefix `supplier` (case-insensitive) AND not hidden → violation.+- **example good finding message:** `U2 parameter "Supplier Part Number 1"="296-1234-1-ND" is visible on the schematic; hide it (keep MPN visible).`++---++### 13. `off_grid_components`++- **name:** Schematic components off snap grid+- **what it detects:** Placed symbols whose origin is not on the project grid (default 100 mil).+- **severity:** info+- **surface:** schematic sheet+- **eda-agent analog:** `find_off_grid_components`+- **API to read:** `ISch_Component.Location.X` / `Y` (TCoord); compare `Abs(CoordToMils(X)) mod grid_mils`+- **detection logic:** Default `grid_mils=100`. Flag if remainder ≠ 0. Report dx/dy.+- **example good finding message:** `U7 on MCU.SchDoc is 4 mil off the 100-mil grid at (1254, 800).`++---++### 14. `unconnected_ic_pins`++- **name:** Unconnected IC pins (excluding NC)+- **what it detects:** U* pins with empty / `?` net, skipping names matching NC / N.C. / DNC / N/C / NO CONNECT / RSVD / RESERVED.+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `find_unconnected_ic_pins` (BOM / flattened pins)+- **API to read:** Workspace `IComponent` pins + net name; or `ISch_Pin` + compiled net. `ISch_Pin.Designator`, `Name`, electrical type **UNVERIFIED** (`ISch_Pin.Electrical` / `eElectricInput` …)+- **detection logic:** For each U* pin, if net is nil/empty/`?` and name is not NC-like, flag.+- **example good finding message:** `U3 pin 12 (PA8) has no net (not named NC/DNC/RSVD).`++---++### 15. `pin_net_name_mismatches`++- **name:** Power/ground pin name vs net name+- **what it detects:** IC pins named VCC/VDD/GND/VSS (etc.) attached to a net that is not a power/ground rail.+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `find_pin_net_name_mismatches`+- **API to read:** Pin `Name` + connected `INet.DM_NetName`+- **detection logic:** Regex power pin names vs `_net_is_power`; ground pin names vs `_net_is_ground`. Flag mismatches.+- **example good finding message:** `U5 pin 8 name="VCC" is on net "GPIO_PA0" (power_pin_on_non_power_net).`++---++### 16. `missing_decoupling`++- **name:** IC power pins without a capacitor on the net+- **what it detects:** ICs whose recognised power/ground pins do not share a net with any C* component (`missing` or `partial`).+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `find_missing_decoupling`+- **API to read:** Flattened pin nets; designators starting with C+- **detection logic:** Build net → capacitors. For each U* power pin, require ≥1 C* on that net. Skip ICs with no recognised power pins.+- **example good finding message:** `U3 status=partial: pin 21 VDDA on net +3V3_A has no capacitor; other power pins are covered.`++---++### 17. `unplaced_components`++- **name:** Schematic components with no PCB placement+- **what it detects:** Fitted schematic components that have no corresponding `IPCB_Component` on the board (ECO not pushed, or component deleted from PCB).+- **severity:** error+- **surface:** project+- **eda-agent analog:** none (Adom/Phase-2; closest is design-difference snapshot)+- **API to read:**+ - Schematic: `ISch_Component.Designator.Text`+ - PCB: `IPCB_Component.Name` (refdes); `Board.GetPcbComponentByRefDes` **documented** on `IPCB_Board` as `PcbComponentByRefDes` / similar — **UNVERIFIED** exact method spelling (`GetPcbComponentByRefDes` vs `PcbComponentByRefDes`)+ - Skip DNP / not-fitted in current variant (see `variant_dnp_mismatch`)+- **detection logic:**+ 1. Collect fitted schematic designators.+ 2. Collect PCB `Component.Name` set.+ 3. SCH minus PCB → unplaced.+- **example good finding message:** `C42 is on the schematic (fitted) but has no IPCB_Component on the board.`++---++### 18. `components_outside_board_outline`++- **name:** Components off the board outline+- **what it detects:** Placed footprints whose origin (or body) sits outside `IPCB_BoardOutline`.+- **severity:** error+- **surface:** PCB board+- **eda-agent analog:** `find_components_outside_board_outline`+- **API to read:**+ - `Board.BoardOutline` / `Board.GetState_BoardOutline` → `IPCB_BoardOutline`+ - `IPCB_BoardOutline.PrimitiveInsidePoly` (used by eda-agent / SelectCMPInOutSideBOL.pas)+ - `IPCB_Component.X`, `Y`, `Layer`, `Name`+- **detection logic:** For each component, if outline `PrimitiveInsidePoly` is false for the origin (and optionally bounding box), flag.+- **example good finding message:** `J1 origin at (3200, -50) mils is outside the board outline (top layer).`++---++### 19. `pads_near_board_edge`++- **name:** Board-edge clearance violations+- **what it detects:** Pads/vias closer than clearance (default 25 mil) to the outline — depaneling damage.+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_pads_near_board_edge`+- **API to read:**+ - `Board.PrimPrimDistance(Board.BoardOutline, Prim)` — documented in eda-agent; **UNVERIFIED** against current AD if method is `PrimPrimDistance` vs `Board.PrimPrimDistance`+ - `IPCB_Pad`, `IPCB_Via`+- **detection logic:** For each pad/via, if distance < `clearance_mils` (default 25), flag kind, designator, distance, XY.+- **example good finding message:** `Pad U3-1 is 12 mil from board outline (min 25 mil) at (40, 800).`++---++### 20. `via_antennas`++- **name:** Via antennas / dangling vias+- **what it detects:** Vias electrically connected on only one layer (open stub).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_via_antennas`+- **API to read:**+ - `IPCB_Via.X`, `Y`, `LowLayer`, `HighLayer`, `Net`, `HoleSize`, `Size`+ - `Via.IsConnected` — **UNVERIFIED** (may not exist as a boolean; eda-agent counts layers via `Board.PrimPrimDistance = 0` to track/arc/pad/fill/region, plus `Via.IsConnectedToPlane` on plane layers)+ - `ILayerStack` to enumerate signal vs plane layers between Low and High+- **detection logic:**+ 1. Iterate `eViaObject`.+ 2. Count layers where a same-net primitive touches the via, or plane connection is true.+ 3. If connected-layer count = 1, flag antenna. If count = 0, flag dangling via (orphan).+- **example good finding message:** `Via on net "SPI_CLK" at (1250, 900) mils is connected on only 1 layer (antenna).`++---++### 21. `unrouted_connections`++- **name:** Unrouted net connections+- **what it detects:** Ratsnest / incomplete routing: nets with pads not fully connected by tracks/arcs/vias.+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `pcb.get_unrouted_nets` (review snapshot, not a numbered audit) — included here so the 31-check lint covers routing completeness+- **API to read:**+ - `IPCB_Net`; `Board.ConnectivelyValidateNets` **documented** on `IPCB_Board` — **UNVERIFIED** return shape+ - From-to / connection object id **UNVERIFIED** (`eConnectionObject` / ratsnest primitives)+ - Pad count vs connected copper graph+- **detection logic:**+ 1. Optional: `Board.ConnectivelyValidateNets`.+ 2. For each net with `PinCount`/`PadCount` ≥ 2, if any from-to remains or pads are in disjoint copper islands, flag net + incomplete node count.+ 3. Skip nets that are intentionally unrouted only if a rule/class says so (**UNVERIFIED** class name).+- **example good finding message:** `Net "+3V3" has 4 unrouted connections (14 pads, 2 copper islands).`++---++### 22. `polygon_validity`++- **name:** Polygon validity / islands+- **what it detects:** Polygons/regions with zero area, failed pour, or orphan copper islands disconnected from the assigned net.+- **severity:** error+- **surface:** PCB board+- **eda-agent analog:** `find_invalid_regions` (zero-area regions); Adom extends to pour islands+- **API to read:**+ - `IPCB_Polygon.Rebuild` (read-side rebuild to inspect pour; treat as inspection, not an ECO)+ - `IPCB_Polygon.Net`, `Layer`, `Name` / `PolygonOutline`+ - Region area; `IPCB_Region` not owned by component/dimension+ - Island enumeration **UNVERIFIED** (`GetState_Island`, pour result island count — mark unverified; fallback: after Rebuild, spatial-split regions whose net copper does not touch a same-net pad)+- **detection logic:**+ 1. Iterate polygons and free regions.+ 2. Flag area = 0 / unset.+ 3. After Rebuild, flag islands that do not touch any pad/via of the polygon net.+- **example good finding message:** `Polygon "GND_Top" on Top Layer has 2 islands; island at (1800, 400) mils touches no GND pad.`++---++### 23. `variant_dnp_mismatch`++- **name:** DNP / variant not-fitted mismatches+- **what it detects:** Components Not Fitted in the current variant that still look fitted on PCB (paste, no DNP marking), or fitted SCH parts marked DNP inconsistently.+- **severity:** warning+- **surface:** project+- **eda-agent analog:** `variant_not_fitted`+- **API to read:**+ - Project current variant description+ - Flattened component fitted flag — **UNVERIFIED** exact property (`IComponent.DM_Fitted` / `DM_NotFitted` / variant `GetState_Fitted`). eda-agent returns `{designator, comment, unique_id}` for NotFitted.+ - PCB: `IPCB_Component` still present (Altium keeps DNP footprints); paste-mask expansion / locked primitives as secondary signals **UNVERIFIED**+- **detection logic:**+ 1. If no variant selected, report info and skip mismatch logic.+ 2. List NotFitted designators.+ 3. Cross-check SCH parameters (`DNP`, `Fitted`) vs variant table; flag disagreement.+- **example good finding message:** `Variant "Build-A": R99 is Not Fitted in the variant table but schematic parameter Fitted=True.`++---++### 24. `missing_footprint_models`++- **name:** Missing footprint / PCB model on schematic component+- **what it detects:** Schematic components with empty `Footprint` / no `ISch_Implementation` of type PCB, or PCB components with empty `Pattern` / `SourceFootprintLibrary`.+- **severity:** error+- **surface:** library / schematic sheet / PCB board+- **eda-agent analog:** none (library hygiene)+- **API to read:**+ - `ISch_Component` implementations: `ISch_Implementation` (`ModelName`, `ModelType` = `PCBLIB` / `PCB`)+ - PCB: `IPCB_Component.Pattern`, `SourceFootprintLibrary`, `SourceLibReference`+- **detection logic:** Flag if no PCB implementation, or `Pattern` is empty, or source library path is empty and footprint cannot be resolved.+- **example good finding message:** `U12 has no PCB footprint implementation (Pattern empty; SourceFootprintLibrary blank).`++---++### 25. `missing_3d_bodies`++- **name:** Missing 3D bodies on footprints+- **what it detects:** Placed PCB components with no `IPCB_ComponentBody` child (STEP/extruded body).+- **severity:** info+- **surface:** library / PCB board+- **eda-agent analog:** none+- **API to read:**+ - `IPCB_Component` group iterator, `eComponentBodyObject` → `IPCB_ComponentBody`+ - `IPCB_Component.DefaultPCB3DModel` **documented**+ - Mechanical layer extruded bodies as fallback+- **detection logic:** Skip fiducials, mounting holes, logos (designator prefixes FID/MH/HOLE or `ComponentKind` mechanical **UNVERIFIED** enum). Flag electrical parts with zero bodies and empty `DefaultPCB3DModel`.+- **example good finding message:** `U3 (Pattern=QFN-48) has no IPCB_ComponentBody and DefaultPCB3DModel is empty.`++---++### 26. `silkscreen_over_pad`++- **name:** Silkscreen overlapping pads+- **what it detects:** Overlay text/tracks/arcs overlapping SMD/through-hole copper pads (fab ink on paste).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** none (DRC silk-to-solder-mask sometimes covers this)+- **API to read:**+ - `IPCB_Text`, `IPCB_Track`, `IPCB_Arc` on `eTopOverlay` / `eBottomOverlay`+ - `IPCB_Pad` on top/bottom signal layers+ - Overlap helper **UNVERIFIED**: `Board.PrimPrimDistance(silk, pad) = 0` or bounding-rect intersect then exact outline. No dedicated `SilkscreenOverPad` API documented.+- **detection logic:**+ 1. For each overlay primitive (including component silk children if primitive lock allows iteration), measure distance to pads on the same side.+ 2. Distance ≤ 0 (or < silk-to-pad rule clearance) → violation.+ 3. Ignore courtyard-only mechanical layers.+- **example good finding message:** `Silkscreen text "U3" on Top Overlay overlaps pad U3-1 (PrimPrimDistance=0 at (1010, 540) mils).`++---++### 27. `tented_via_ratio`++- **name:** Tented vs untented via surfaces+- **what it detects:** Fraction of outer via surfaces that are tented; reports ratio (consumer boards typically want ~1.0).+- **severity:** warning (if untented ratio exceeds project threshold; default report-only with warning when any outer surface is untented on a production board)+- **surface:** PCB board+- **eda-agent analog:** `tented_via_ratio`+- **API to read:**+ - `IPCB_Via` tenting: `GetState_IsTenting_Top` / `GetState_IsTenting_Bottom` (NEXUS/AD docs)+ - Query-language equivalents `SolderMaskTentingTop` / `SolderMaskTentingBottom`+ - `SolderMaskExpansion` on pad cache+- **detection logic:** Count top/bottom surfaces that actually reach outer layers. Buried/blind inner-only ignored. Emit `tented`, `untented`, `ratio`.+- **example good finding message:** `Via tenting ratio=0.72 (48 untented outer surfaces / 172); example untented via on net GND at (200, 200).`++---++### 28. `bad_connections`++- **name:** Near-miss track / arc endpoints+- **what it detects:** Track/arc endpoints within `tolerance_mils` (default 1.0) of another same-net primitive but not actually touching (photoplot open).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_bad_connections`+- **API to read:** `IPCB_Track` (`X1,Y1,X2,Y2`, `Layer`, `Net`), `IPCB_Arc`; spatial iterator; skip teardrops, in-component prims, no-net, full-circle arcs+- **detection logic:** For each signal-layer endpoint, if no same-net pad/via/track/arc at exact coord but one exists within tolerance, flag dangling/near-miss.+- **example good finding message:** `Track on net "nCS" layer TopLayer endpoint (812.4, 400.0) mils is 0.6 mil from via copper (tolerance 1.0); photoplot will not bridge.`++---++### 29. `signal_vias_without_return`++- **name:** Signal vias without nearby return via+- **what it detects:** Non-power/ground vias with no GND/power via within `radius_mils` (default 50).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_signal_vias_without_return`+- **API to read:** `IPCB_Via.Net.Name`; heuristic prefixes GND / V / + / −+- **detection logic:** For each signal via, spatial-search power/ground vias within radius. None → violation. Simplified proximity, not stackup-aware.+- **example good finding message:** `Signal via on net "USB_DP" at (1500, 700) mils has no GND/power via within 50 mils.`++---++### 30. `removed_pad_shapes`++- **name:** Removed unused pad / via copper+- **what it detects:** Pads/vias with annular ring stripped on layers that still need copper (`Tools → Remove Unused Pad Shapes` over-applied).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_removed_pad_shapes`+- **API to read:**+ - `Via.SizeOnLayer(L) <= Via.HoleSize`+ - `Pad.IsPadRemoved(L)` — used by community PadShapeRemoved.pas+- **detection logic:** For each layer in the via/pad stack, flag removed copper. Count per layer-instance.+- **example good finding message:** `Via on net "+3V3" at (600, 600) has SizeOnLayer(Mid1)=HoleSize (annular ring removed on Mid1).`++---++### 31. `unlocked_component_primitives`++- **name:** Unlocked footprint primitives+- **what it detects:** Placed components with primitive lock off (pads can be dragged off-center).+- **severity:** warning+- **surface:** PCB board+- **eda-agent analog:** `find_unlocked_component_primitives`+- **API to read:** `IPCB_Component` primitive lock — **UNVERIFIED** exact name (`PrimitiveLock` / `GetState_PrimitiveLock`). Community and eda-agent treat it as a boolean on the component.+- **detection logic:** Iterate components; flag unlocked. Skip if intentionally exploded for documentation.+- **example good finding message:** `Q2 has PrimitiveLock=False; a pad can be dragged off the footprint without warning.`++---++### 32. `mouser_digikey_lcsc_params` (Adom extra)++- **name:** Mouser, Digi-Key, and LCSC part-number parameters required+- **what it detects:** Every procurable (fitted, non-mechanical) schematic component missing **any** of the three required supplier parameters. **Adom rule: all three are required**, even if an MPN already exists.+- **severity:** error+- **surface:** schematic sheet / project+- **eda-agent analog:** none (opposite emphasis of `find_visible_supplier_pn`, which only hides them)+- **API to read:**+ - `ISch_Component` parameter iterator → `ISch_Parameter.Name`, `Text`+ - Accepted names (**normalize case/spaces/underscores**):+ - Mouser: `Mouser`, `Mouser Part Number`, `Mouser PN`, `Supplier Part Number n` where supplier name is Mouser+ - Digi-Key: `DigiKey`, `Digi-Key`, `DigiKey Part Number`, `DK PN`+ - LCSC: `LCSC`, `LCSC Part Number`, `LCSC PN`, `JLCPCB`+ - Altium Supplier link slots `Supplier n` + `Supplier Part Number n` — **UNVERIFIED** whether Adom libraries use native supplier links vs free parameters+- **detection logic:**+ 1. Skip logos, fiducials, mechanical-only, and current-variant DNP parts.+ 2. For each remaining component, require non-empty Text for all three families.+ 3. Missing one, two, or three all fail; list which keys are empty.+ 4. Do **not** require the parameters to be visible (`IsHidden` may be True). Existence is the Adom rule; visibility is check 12.+- **example good finding message:** `R4 is missing required supplier parameters: Mouser=ok, DigiKey=empty, LCSC=empty (Adom rule: all three required).`++---++### 33. `pin1_symbol_footprint` (Adom extra)++- **name:** Pin-1 disagreement between symbol and footprint+- **what it detects:** Symbol pin whose designator is `1` does not correspond to footprint pad `Name = '1'` in electrical mapping (wrong pin-swap, rotated QFN, pin-1 dot vs pad 1).+- **severity:** error+- **surface:** library / schematic sheet / PCB board+- **eda-agent analog:** none+- **API to read:**+ - SCH: `ISch_Pin.Designator` (pin number), `ISch_Pin.Name`, orientation / location relative to symbol+ - PCB: `IPCB_Pad.Name = '1'` (user requirement); `IPCB_Pad.Layer`; component `Rotation`+ - Pin-to-pad map: `ISch_Implementation` / pin mapping model — **UNVERIFIED** (`ISch_ModelDatafileLink`, `MappedPin` / `PinMapping`)+ - PCB `IPCB_Component.Getstate_PadByName('1')` **documented** as `Getstate_PadByName`+- **detection logic:**+ 1. For each component with a PCB implementation, fetch symbol pin `Designator='1'` and footprint pad `Name='1'`.+ 2. If either side is missing, flag.+ 3. If a pin-map exists and pin 1 is mapped to a pad whose name is not `'1'`, flag disagreement.+ 4. Optional geometric heuristic (**UNVERIFIED**): symbol pin-1 in the "upper-left" of unrotated symbol vs footprint pad-1 in the courtyard pin-1 marker; report as info only, not error.+- **example good finding message:** `U6 symbol pin Designator=1 (name=VSS) maps to footprint pad Name=12; footprint pad Name=1 is mapped to symbol pin 12 (QFN rotation/pin-1 mismatch).`++---++### 34. `unused_mechanical_layers` (Adom extra)++- **name:** Unused mechanical layers that still have content+- **what it detects:** Mechanical layers that are not part of the active layer stack / fabrication output set, yet still contain tracks, arcs, text, fills, or component bodies (leftover courtyard, fab notes on Mechanical 13–16, etc.).+- **severity:** info+- **surface:** PCB board+- **eda-agent analog:** none+- **API to read:**+ - `ILayerStack` / `Board.LayerStack` — **UNVERIFIED** exact property (`IPCB_MasterLayerStack`)+ - `IPCB_LayerObject`; mechanical layer ids `eMechanical1` … `eMechanical16` (AD extends beyond 16 in later versions — **UNVERIFIED** max)+ - `Board.LayerIsUsed` / `Board.MechanicalLayerEnabled` — **UNVERIFIED** names; fallback: iterate all primitives and bucket by `Prim.Layer`+ - Pairing: courtyard often Mechanical 15, 3D on Mechanical 13 — project convention, not API+- **detection logic:**+ 1. Build the set of mechanical layers that appear in the stack or are enabled for Gerber/ODB output (**UNVERIFIED** output-job API).+ 2. Iterate primitives (`eTrackObject`, `eArcObject`, `eTextObject`, `eFillObject`, `eRegionObject`, `eComponentBodyObject`) on mechanical layers.+ 3. If a mechanical layer has content but is disabled / not in stack / not in the fab output set, flag layer id + primitive count + sample coordinates.+ 4. Do not flag empty disabled layers.+- **example good finding message:** `Mechanical 14 is not in the active layer stack but contains 11 primitives (example text "OLD COURTYARD" at (0, 0) mils).`++---++### 35. `missing_power_testpoints` (Adom extra)++- **name:** Missing test points on power rails+- **what it detects:** Power/ground nets (VCC/VDD/+3V3/+5V/GND/…) with no dedicated test-point pad or test-point component (`TP*` / pad with testpoint property).+- **severity:** warning+- **surface:** PCB board / project+- **eda-agent analog:** none (DRC has `IPCB_TestPointUsage` / `IPCB_TestPointStyleRule` but that is rule-engine, not this lint)+- **API to read:**+ - `IPCB_Pad` testpoint flags — **UNVERIFIED** (`IsTestPoint` / `GetState_IsTestpointTop` / `TestpointTop` / `TestpointBottom`; query language has test-point fields)+ - `IPCB_Component.Name` matching `TP*` or comment/type Test Point+ - `IPCB_Net.Name` power heuristic (same as decoupling check)+ - Design rules `IPCB_TestPointUsage` only as optional cross-check, not required+- **detection logic:**+ 1. Identify power nets from names: `GND*`, `VCC*`, `VDD*`, `VSS*`, leading `+`, `VBAT`, `VBUS`, `VREF`, …+ 2. A net has a test point if any pad has the testpoint property or any `TP*` component pad is on that net.+ 3. Flag power nets with ≥1 connected pad and zero test points.+ 4. Optionally require both top-accessible TP for rails brought to connectors (**UNVERIFIED** policy).+- **example good finding message:** `Power net "+3V3" has 22 pads and 0 test points (no TP* component and no pad TestpointTop/Bottom).`++---++## Mapping: eda-agent 31 → this catalog++eda-agent `LINT_AUDIT_LIST` + BOM-side audits are covered as follows. Some eda-agent checks are folded or omitted to keep the catalog at 35 while absorbing Adom MUST items (unplaced, footprints, 3D, silk-over-pad, unrouted).++| eda-agent section | This catalog id | Notes |+| --- | --- | --- |+| `component_param_visibility` | `component_param_visibility` | |+| `power_port_orientation` | `power_port_orientation` | |+| `find_floating_ports` | `floating_ports` | |+| `find_unmatched_ports` | `unmatched_ports` | multi-driver / no-driver |+| `find_designator_collisions` | `duplicate_designators` | |+| `find_missing_datasheets` | `missing_datasheets` | |+| `find_mpn_inconsistencies` | `mpn_inconsistencies` | |+| `find_single_pin_nets` | `single_pin_nets` | |+| `find_non_embedded_images` | *(omitted from 35; style)* | Re-add as 36 if needed |+| `find_visible_supplier_pn` | `visible_supplier_pn` | Complementary to Adom extra 32 |+| `find_orphan_net_labels` | `orphan_net_labels` | |+| `find_orphan_power_objects` | `orphan_power_objects` | |+| `find_placeholder_values` | `placeholder_values` | |+| `find_off_grid_components` | `off_grid_components` | |+| `find_components_outside_board_outline` | `components_outside_board_outline` | |+| `find_pads_near_board_edge` | `pads_near_board_edge` | |+| `variant_not_fitted` | `variant_dnp_mismatch` | |+| `tented_via_ratio` | `tented_via_ratio` | |+| `find_bad_connections` | `bad_connections` | |+| `find_signal_vias_without_return` | `signal_vias_without_return` | |+| `find_via_antennas` | `via_antennas` | |+| `find_removed_pad_shapes` | `removed_pad_shapes` | |+| `find_pads_center_not_connected` | *(folded into `unrouted_connections` / via antennas)* | **UNVERIFIED** pad-center API |+| `find_invalid_regions` | `polygon_validity` | Extended with islands |+| `find_mixed_designator_rotation` | *(omitted from 35; silk style)* | |+| `find_unlocked_component_primitives` | `unlocked_component_primitives` | |+| `find_mirrored_pcb_text` | *(omitted from 35; silk style)* | |+| `find_acute_angles` | *(omitted from 35; DFM)* | |+| `find_inconsistent_track_widths` | *(omitted from 35; DFM)* | |+| `find_unconnected_ic_pins` | `unconnected_ic_pins` | BOM-side |+| `find_pin_net_name_mismatches` | `pin_net_name_mismatches` | BOM-side |+| `find_missing_decoupling` | `missing_decoupling` | BOM-side |+| — | `unplaced_components` | MUST |+| — | `missing_footprint_models` | MUST |+| — | `missing_3d_bodies` | MUST |+| — | `silkscreen_over_pad` | MUST |+| — | `unrouted_connections` | MUST |+| — | `mouser_digikey_lcsc_params` | Adom extra |+| — | `pin1_symbol_footprint` | Adom extra |+| — | `unused_mechanical_layers` | Adom extra |+| — | `missing_power_testpoints` | Adom extra |++Omitted eda-agent style/DFM checks may be reintroduced later as ids 36+ without changing 1–35.++---++## Finding message quality++A good finding (aligned with eda-agent review discipline, adapted for lint):++- Names the **exact** designator, pin, net, sheet or board coordinates in mils+- States the **observed API fact** (not a guess about device function)+- Is **actionable** (what is missing or wrong)+- Does **not** say “verify X” or relay a raw ERC category++Lint is allowed to list many similar items; the later review agent triages them.++---++## Summary table++| id | severity | surface |+| --- | --- | --- |+| floating_ports | warning | schematic sheet |+| unmatched_ports | error | project |+| orphan_net_labels | error | schematic sheet |+| orphan_power_objects | error | schematic sheet |+| single_pin_nets | warning | project |+| power_port_orientation | warning | schematic sheet |+| duplicate_designators | error | project |+| component_param_visibility | info | schematic sheet |+| missing_datasheets | info | schematic sheet / library |+| mpn_inconsistencies | warning | project |+| placeholder_values | error | schematic sheet |+| visible_supplier_pn | info | schematic sheet |+| off_grid_components | info | schematic sheet |+| unconnected_ic_pins | warning | project |+| pin_net_name_mismatches | warning | project |+| missing_decoupling | warning | project |+| unplaced_components | error | project |+| components_outside_board_outline | error | PCB board |+| pads_near_board_edge | warning | PCB board |+| via_antennas | warning | PCB board |+| unrouted_connections | warning | PCB board |+| polygon_validity | error | PCB board |+| variant_dnp_mismatch | warning | project |+| missing_footprint_models | error | library / schematic sheet / PCB board |+| missing_3d_bodies | info | library / PCB board |+| silkscreen_over_pad | warning | PCB board |+| tented_via_ratio | warning | PCB board |+| bad_connections | warning | PCB board |+| signal_vias_without_return | warning | PCB board |+| removed_pad_shapes | warning | PCB board |+| unlocked_component_primitives | warning | PCB board |+| mouser_digikey_lcsc_params | error | schematic sheet / project |+| pin1_symbol_footprint | error | library / schematic sheet / PCB board |+| unused_mechanical_layers | info | PCB board |+| missing_power_testpoints | warning | PCB board / project |++---++## Document control++- Nonce: `ALTIUM-LINT-SPEC-001`+- Artifact path (box): `/workspace/altium-lint-spec.md`+- Intended later copy: `C:\Users\john\adom\altium-lint-spec.md`+- This phase: spec only; no Altium launch; no altium-bridge tree edits