use std::collections::HashSet;
use agentstategraph::CommitOptions;
use agentstategraph_core::IntentCategory;
use serde_json::json;
use agentstatedeveloper_core::{
AsgEffectStore, AsgIndexStore, Effect, EffectCategory, EffectDecl, EffectStore, Engine, paths,
propagate_transitive,
};
fn put_callees(engine: &Engine, symbol_id: &str, callees: &[&str]) {
let path = paths::callees_path(symbol_id);
let value = json!({ "callees": callees });
let opts = CommitOptions::new(
"test-agent",
IntentCategory::Refine,
format!("test callees for {}", symbol_id),
);
engine
.repo
.set_json(&engine.ref_name, &path, &value, opts)
.expect("set callees");
}
fn put_simple_effect_decl(engine: &Engine, symbol_id: &str, declared: Vec<EffectCategory>) {
let store = AsgEffectStore::new(&engine.repo);
let decl = EffectDecl {
symbol_id: symbol_id.to_string(),
declared: declared
.into_iter()
.map(|c| Effect {
effect: c,
qualifiers: serde_json::Value::Null,
note: None,
..Default::default()
})
.collect(),
transitive: Vec::new(),
verification: None,
confidence: None,
runtime: None,
matched_policy: None,
};
store
.put_effects(&engine.ref_name, symbol_id, &decl, "test-agent")
.expect("put effects");
}
#[test]
fn propagates_transitive_effects_with_via_chains() {
let engine = Engine::open_in_memory().expect("open engine");
let index_store = AsgIndexStore::new(&engine.repo);
let effect_store = AsgEffectStore::new(&engine.repo);
put_simple_effect_decl(&engine, "A", vec![EffectCategory::IoFsRead]);
put_simple_effect_decl(&engine, "B", vec![EffectCategory::IoNetOut]);
put_simple_effect_decl(&engine, "C", vec![]);
put_callees(&engine, "A", &["B"]);
put_callees(&engine, "B", &[]);
put_callees(&engine, "C", &["A", "B"]);
let symbol_ids = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let updated = propagate_transitive(&index_store, &effect_store, &engine.ref_name, &symbol_ids)
.expect("propagate");
assert_eq!(
updated, 2,
"expected A and C to be updated, got {}",
updated
);
let a_decl = effect_store
.get_effects(&engine.ref_name, "A")
.expect("get A")
.expect("A exists");
assert_eq!(a_decl.transitive.len(), 1);
assert_eq!(a_decl.transitive[0].effect, EffectCategory::IoNetOut);
assert_eq!(a_decl.transitive[0].via, vec!["B".to_string()]);
let b_decl = effect_store
.get_effects(&engine.ref_name, "B")
.expect("get B")
.expect("B exists");
assert!(
b_decl.transitive.is_empty(),
"B has no callees, transitive must be empty"
);
let c_decl = effect_store
.get_effects(&engine.ref_name, "C")
.expect("get C")
.expect("C exists");
let cats: HashSet<EffectCategory> =
c_decl.transitive.iter().map(|t| t.effect.clone()).collect();
assert!(
cats.contains(&EffectCategory::IoFsRead),
"C should surface io.fs.read via A, got {:?}",
c_decl.transitive
);
assert!(
cats.contains(&EffectCategory::IoNetOut),
"C should surface io.net.out, got {:?}",
c_decl.transitive
);
let fs_entry = c_decl
.transitive
.iter()
.find(|t| t.effect == EffectCategory::IoFsRead)
.unwrap();
assert!(
fs_entry.via.contains(&"A".to_string()),
"io.fs.read should be reached via A, got via={:?}",
fs_entry.via
);
let net_entry = c_decl
.transitive
.iter()
.find(|t| t.effect == EffectCategory::IoNetOut)
.unwrap();
assert!(
net_entry.via.contains(&"B".to_string()),
"io.net.out should be reached via B, got via={:?}",
net_entry.via
);
let again = propagate_transitive(&index_store, &effect_store, &engine.ref_name, &symbol_ids)
.expect("second propagate");
assert_eq!(again, 0, "second run must not re-write unchanged decls");
}
#[test]
fn handles_cycles_without_infinite_loop() {
let engine = Engine::open_in_memory().expect("open engine");
let index_store = AsgIndexStore::new(&engine.repo);
let effect_store = AsgEffectStore::new(&engine.repo);
put_simple_effect_decl(&engine, "A", vec![EffectCategory::IoFsRead]);
put_simple_effect_decl(&engine, "B", vec![EffectCategory::IoNetOut]);
put_callees(&engine, "A", &["B"]);
put_callees(&engine, "B", &["A"]);
let symbol_ids = vec!["A".to_string(), "B".to_string()];
let updated = propagate_transitive(&index_store, &effect_store, &engine.ref_name, &symbol_ids)
.expect("propagate must terminate on a cycle");
assert_eq!(updated, 2);
let a_decl = effect_store
.get_effects(&engine.ref_name, "A")
.unwrap()
.unwrap();
let b_decl = effect_store
.get_effects(&engine.ref_name, "B")
.unwrap()
.unwrap();
assert!(
a_decl
.transitive
.iter()
.any(|t| t.effect == EffectCategory::IoNetOut),
"A should pick up io.net.out from B even with cycle, got {:?}",
a_decl.transitive
);
assert!(
b_decl
.transitive
.iter()
.any(|t| t.effect == EffectCategory::IoFsRead),
"B should pick up io.fs.read from A even with cycle, got {:?}",
b_decl.transitive
);
}