← 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

John Lauer ·0be1503d75 ·1mo ago ·parent de7acef
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, &params, 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