app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Invalidate netlist evidence when any schematic child sheet changes
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.