← Commit history

Invalidate netlist evidence when any schematic child sheet changes

John Lauer ·2784e00319 · ·parent d33b2fb
3 files changed +119−2
crates/adom-aiflow/src/main.rs+37−2
@@ -12,6 +12,7 @@ mod switchnode; mod thermalvias; mod outline; mod browser_access;+mod schematic_identity;  /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html");@@ -1919,14 +1920,17 @@ fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<che }  /// Why the run's schematic and current board are not proven equal, or Ok when they are: the-/// recorded `netlist check` passed on this schematic (same sha256), and its exported netlist still+/// recorded `netlist check` passed on this entire schematic hierarchy, and its exported netlist still /// matches the current board (placement, routing or an adopted board cannot change a pin's net /// unnoticed). fn netlist_gate(r: &Run) -> Result<String, String> {     let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };     let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;+    let identity = schematic_identity::capture(Path::new(sch)).map_err(|e| format!("netlist check (schematic hierarchy: {e})"))?;+    if o["schematicFiles"] != json!(identity) { return Err("netlist check (schematic hierarchy changed or older check lacks child-sheet hashes; run it again)".into()); }     if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }     let net = o["netlist"].as_str().unwrap_or("");+    if o["netlistSha256"].as_str() != Some(sha256_file(net).as_str()) { return Err("netlist check (exported netlist changed or lacks its recorded hash; run it again)".into()); }     let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;     let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);     if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }@@ -3422,6 +3426,7 @@ fn main() {             let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))                 .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));             if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }+            let schematic_files = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy: {e}"), &[]));             let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));             let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));             let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };@@ -3435,7 +3440,10 @@ fn main() {                 }             };             let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));+            let netlist_hash = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(text.as_bytes())) };             let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));+            let after_export = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy after export: {e}"), &[]));+            if schematic_files != after_export { err("schematic hierarchy changed during netlist export; rerun with stable inputs", &[]); }             let rep = checks::compare_netlists(&sn, &b);             let passed = rep["ok"] == true;             let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];@@ -3450,7 +3458,7 @@ fn main() {                 let report = dir.join("netlist.json");                 write_json(&report, &rep);                 if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }-                r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));+                r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "schematicFiles": schematic_files, "netlistSha256": netlist_hash, "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));                 r.save().unwrap_or_else(|e| err(&e, &[]));             }             let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };@@ -4226,6 +4234,33 @@ mod mpn_mark_tests {     } } +#[cfg(test)]+mod netlist_freshness_tests {+    use super::*;+    #[test]+    fn gate_rejects_child_only_edits_old_evidence_and_export_edits() {+        let dir=std::env::temp_dir().join(format!("aiflow-net-freshness-{}",std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let sch=dir.join("root.kicad_sch"); let child=dir.join("child.kicad_sch");+        let net=dir.join("export.net"); let board=dir.join("board.kicad_pcb");+        std::fs::write(&sch,"(kicad_sch (sheet (property \"Sheetfile\" \"child.kicad_sch\")))").unwrap();+        std::fs::write(&child,"(kicad_sch)").unwrap();+        std::fs::write(&net,"(export (components) (nets))").unwrap();+        std::fs::write(&board,"(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")))").unwrap();+        let proof=json!({"ok":true,"sha256":sha256_file(sch.to_str().unwrap()),"schematicFiles":schematic_identity::capture(&sch).unwrap(),"netlist":net,"netlistSha256":sha256_file(net.to_str().unwrap())});+        let mut run=Run{dir:dir.clone(),data:json!({"schematic":sch,"boards":[board],"outcomes":{"netlist":proof}})};+        assert!(netlist_gate(&run).is_ok());+        std::fs::write(&child,"(kicad_sch (changed yes))").unwrap();+        assert!(netlist_gate(&run).unwrap_err().contains("hierarchy changed"));+        std::fs::write(&child,"(kicad_sch)").unwrap();+        std::fs::write(&net,"(export (components) (nets))\n").unwrap();+        assert!(netlist_gate(&run).unwrap_err().contains("exported netlist changed"));+        run.data["outcomes"]["netlist"].as_object_mut().unwrap().remove("schematicFiles");+        assert!(netlist_gate(&run).unwrap_err().contains("older check"));+        std::fs::remove_dir_all(dir).unwrap();+    }+}+ #[cfg(test)] mod measurement_gate_tests {     use super::*;
crates/adom-aiflow/src/schematic_identity.rsadded+78
@@ -0,0 +1,78 @@+//! Bind a netlist check to every reachable native schematic sheet.+use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};+use aiflow_board::sx;+use sha2::{Digest, Sha256};++pub fn capture(root: &Path) -> Result<BTreeMap<String, String>, String> {+    let root = root.canonicalize().map_err(|e| format!("{}: {e}", root.display()))?;+    let project = root.parent().ok_or("schematic has no parent")?;+    let mut result = BTreeMap::new();+    visit(&root, project, &mut BTreeSet::new(), &mut result)?;+    Ok(result)+}++fn visit(path: &Path, project: &Path, active: &mut BTreeSet<PathBuf>, result: &mut BTreeMap<String, String>) -> Result<(), String> {+    let path = path.canonicalize().map_err(|e| format!("{}: {e}", path.display()))?;+    if active.contains(&path) { return Err(format!("cyclic schematic hierarchy: {}", path.display())); }+    let key = path.to_str().ok_or("non-UTF8 schematic path")?.to_string();+    if result.contains_key(&key) { return Ok(()); }+    active.insert(path.clone());+    let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;+    let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;+    let root = sx::parse(text)?;+    if root.tag != "kicad_sch" { return Err(format!("not a native schematic: {}", path.display())); }+    for sheet in root.find_all("sheet") {+        let files: Vec<_> = sheet.find_all("property").filter(|p| p.atom(1).is_some_and(|s| s.eq_ignore_ascii_case("Sheetfile"))).collect();+        if files.len() != 1 { return Err(format!("sheet needs one Sheetfile property: {}", path.display())); }+        let name = files[0].atom(2).filter(|s| !s.is_empty()).ok_or("empty Sheetfile property")?;+        let name = name.replace("${KIPRJMOD}", project.to_str().ok_or("non-UTF8 project path")?);+        if name.contains("${") || name.contains("$(") { return Err(format!("unresolved Sheetfile variable: {name}")); }+        let child = PathBuf::from(name.replace('\\', "/"));+        visit(&path.parent().ok_or("sheet has no parent")?.join(child), project, active, result)?;+    }+    active.remove(&path);+    result.insert(key, format!("{:x}", Sha256::digest(&bytes)));+    Ok(())+}++#[cfg(test)]+mod tests {+    use super::*;+    struct Fixture(PathBuf);+    impl Fixture {+        fn new() -> Self {+            static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);+            let p = std::env::temp_dir().join(format!("aiflow-sch-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));+            std::fs::create_dir_all(&p).unwrap(); Self(p)+        }+        fn write(&self, name: &str, text: &str) { let p=self.0.join(name); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p,text).unwrap(); }+        fn root(&self) -> PathBuf { self.0.join("root.kicad_sch") }+    }+    impl Drop for Fixture { fn drop(&mut self) { let _=std::fs::remove_dir_all(&self.0); } }+    #[test]+    fn child_only_edit_changes_identity_and_shared_sheet_is_allowed() {+        let f=Fixture::new();+        f.write("root.kicad_sch", "(kicad_sch (sheet (property \"Sheetfile\" \"sub/a.kicad_sch\")) (sheet (property \"Sheetfile\" \"sub/a.kicad_sch\")))");+        f.write("sub/a.kicad_sch", "(kicad_sch (sheet (property \"Sheetfile\" \"../b.kicad_sch\")))");+        f.write("b.kicad_sch", "(kicad_sch)");+        let before=capture(&f.root()).unwrap(); assert_eq!(before.len(),3);+        f.write("b.kicad_sch", "(kicad_sch (changed yes))");+        let after=capture(&f.root()).unwrap(); assert_ne!(before,after);+        let key=f.root().to_str().unwrap().to_string(); assert_eq!(before[&key],after[&key]);+    }+    #[test]+    fn missing_cyclic_malformed_and_unresolved_sheets_refuse() {+        let f=Fixture::new();+        for contents in ["(kicad_sch (sheet))", "(kicad_sch (sheet (property \"Sheetfile\" \"missing.kicad_sch\")))", "(kicad_sch (sheet (property \"Sheetfile\" \"root.kicad_sch\")))", "(kicad_sch (sheet (property \"Sheetfile\" \"${UNKNOWN}/a.kicad_sch\")))", "(not_schematic)"] {+            f.write("root.kicad_sch",contents); assert!(capture(&f.root()).is_err(),"{contents}");+        }+    }+    #[test]+    fn project_variable_resolves_at_root() {+        let f=Fixture::new();+        f.write("root.kicad_sch", "(kicad_sch (sheet (property \"Sheetfile\" \"sub/a.kicad_sch\")))");+        f.write("sub/a.kicad_sch", "(kicad_sch (sheet (property \"Sheetfile\" \"${KIPRJMOD}/b.kicad_sch\")))");+        f.write("b.kicad_sch", "(kicad_sch)"); assert_eq!(capture(&f.root()).unwrap().len(),3);+    }+}
skills/adom-aiflow/SKILL.md+4
@@ -363,6 +363,10 @@ Derive continuous heat cases from the actual voltage/current/power envelope and  When multiple physical lands share one logical pin number, use the reviewed Fields physical-pad selector (`at: [x_mm, y_mm]`, adom/adom-fields PR10 and dependencies). Declare each branch current and its assumption; never silently divide the load to obtain a pass. AIFlow resolves and injects current at each declared physical pad, then matches each reported boundary once, including its coordinates. Missing, ambiguous, duplicate or changed selectors refuse; integer and decimal coordinate spellings are equivalent. Recheck coordinates after moving a footprint. Preserve owner review and shared runtimes; source support is not a deployed release or package-resistance model. +## Schematic hierarchy freshness++`netlist check` records hashes for the root schematic and every reachable child sheet, plus the exported netlist. A missing, cyclic or unresolved sheet refuses the check. The hierarchy must remain unchanged across export. `finish` rejects child-sheet edits, altered exports and older checks without these hashes; rerun the native export and comparison. A manually supplied `--net` is still caller-provided evidence: hashing it does not prove how or when it was exported. Unsupported sheet path variables fail explicitly rather than silently omitting sheets.+ ## One live dashboard per run  The main timeline uses the app-creator SSE channel: CLI workflow commands update durable run files and the server pushes state to the existing page. `timeline present --json '{"caption":"Current passed; thermal open","image":"review.png"}' --run <run> --ai-thread <name>` sends presentation changes through the server's HTTP control endpoint; it never changes engineering gates. Images must already exist in `<run>/timeline/`. Reconnect replays current state. Static exports and older servers retain polling fallback.