agentstatedeveloper_cli/commands/
impact.rs1use std::collections::{HashSet, VecDeque};
7use std::process::Command as Proc;
8
9use anyhow::Result;
10use clap::Args;
11use serde_json::{Value, json};
12
13use agentstatedeveloper_core::{
14 AsgEffectStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine, IndexStore, LedgerKind,
15 LedgerStore, classify_layer_sym, endpoints_from_tree, estimate_tokens, git_dirty_files,
16 glob_match, intent_focus, load_layer_overrides, match_edges, matches_any_path_glob,
17 parse_intent, propose_test_path, resolve_scope, stale_warning, symbol_tier, trim_for_agent,
18};
19
20use crate::commands::graph::build_id_map;
21use crate::config::Config;
22
23#[derive(Debug, Args)]
24pub struct ImpactArgs {
25 pub qname: String,
27
28 #[arg(long, default_value = "3")]
30 pub depth: usize,
31
32 #[arg(long, default_value = "20")]
34 pub git_depth: usize,
35
36 #[arg(long)]
38 pub quiet: bool,
39
40 #[arg(long)]
43 pub intent: Option<String>,
44
45 #[arg(long)]
48 pub agent: bool,
49
50 #[arg(long, default_value = "8000")]
52 pub agent_budget: usize,
53
54 #[arg(long)]
56 pub exclude: Option<String>,
57
58 #[arg(long)]
60 pub paths: Option<String>,
61
62 #[arg(long)]
64 pub scope: Option<String>,
65
66 #[arg(long)]
68 pub limit: Option<usize>,
69
70 #[arg(long)]
72 pub json: bool,
73}
74
75pub fn run(cfg: &Config, args: ImpactArgs) -> Result<()> {
76 if !args.quiet {
77 if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
78 eprintln!("{warn}");
79 }
80 }
81 let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
82 let layer_overrides = load_layer_overrides(&cfg.db_path);
83 let engine = Engine::open_sqlite(&cfg.db_path)?;
84 let index_store = AsgIndexStore::from_engine(&engine);
85 let effect_store = AsgEffectStore::from_engine(&engine);
86 let ledger_store = AsgLedgerStore::from_engine(&engine);
87 let id_map = build_id_map(&engine);
88
89 let symbol = index_store
90 .get_symbol_by_qname(&engine.ref_name, &args.qname)?
91 .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
92
93 let mut paths_filter: Vec<String> = Vec::new();
94 if let Some(ref scope) = args.scope {
95 paths_filter.extend(resolve_scope(scope, &cfg.db_path));
96 }
97 if let Some(ref paths) = args.paths {
98 paths_filter.extend(
99 paths
100 .split(',')
101 .map(|p| p.trim().to_string())
102 .filter(|p| !p.is_empty()),
103 );
104 }
105 let exclusions: Vec<String> = args
106 .exclude
107 .as_deref()
108 .map(|e| {
109 e.split(',')
110 .map(|t| t.trim().to_lowercase())
111 .filter(|t| !t.is_empty())
112 .collect()
113 })
114 .unwrap_or_default();
115
116 let tier = symbol_tier(&symbol.file);
117 let layer = classify_layer_sym(&symbol.file, &symbol.qname, tier, &layer_overrides);
118
119 let mut visited: HashSet<String> = HashSet::new();
121 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
122 visited.insert(symbol.symbol_id.clone());
123 queue.push_back((symbol.symbol_id.clone(), 0));
124
125 let mut caller_rows: Vec<Value> = Vec::new();
126 let mut affected_test_rows: Vec<Value> = Vec::new();
127 let mut touched_files: IndexMap = IndexMap::new();
129 touched_files.insert(symbol.file.clone(), 0usize);
130
131 while let Some((sym_id, depth)) = queue.pop_front() {
132 if depth >= args.depth {
133 continue;
134 }
135 let neighbors = index_store
136 .get_callers(&engine.ref_name, &sym_id)
137 .unwrap_or_default();
138 for nbr_id in neighbors {
139 if visited.contains(&nbr_id) {
140 continue;
141 }
142 visited.insert(nbr_id.clone());
143 if let Some(s) = id_map.get(&nbr_id) {
144 if !paths_filter.is_empty() && !matches_any_path_glob(&paths_filter, &s.file) {
146 continue;
147 }
148 if !exclusions.is_empty() {
149 let lfile = s.file.to_lowercase();
150 let lqname = s.qname.to_lowercase();
151 if exclusions
152 .iter()
153 .any(|e| glob_match(e, &lfile) || lqname.contains(e.as_str()))
154 {
155 continue;
156 }
157 }
158 let t = symbol_tier(&s.file);
159 let l = classify_layer_sym(&s.file, &s.qname, t, &layer_overrides);
160 let row = json!({
161 "qname": s.qname,
162 "file": s.file,
163 "line": s.start.line,
164 "depth": depth + 1,
165 "layer": l,
166 });
167 if t == 2 {
168 affected_test_rows.push(row);
170 } else {
171 caller_rows.push(row.clone());
172 }
173 touched_files.entry(s.file.clone()).or_insert(depth + 1);
174 if depth + 1 < args.depth {
175 queue.push_back((nbr_id, depth + 1));
176 }
177 }
178 }
179 }
180
181 let effects_raw = effect_store
183 .get_effects(&engine.ref_name, &symbol.symbol_id)
184 .unwrap_or_default();
185 let effects_out: Value = serde_json::to_value(&effects_raw).unwrap_or(json!(null));
186
187 let mut all_invariants: Vec<Value> = Vec::new();
197 let mut all_hazards: Vec<Value> = Vec::new();
198 let mut all_validation_scenarios: Vec<Value> = Vec::new();
199 let mut all_known_bugs: Vec<Value> = Vec::new();
200 let mut seen_inv: HashSet<String> = HashSet::new();
201
202 let mut seen_ids: HashSet<String> = HashSet::new();
209 let all_sym_ids: Vec<String> = std::iter::once(symbol.symbol_id.clone())
210 .chain(visited.iter().cloned())
211 .filter(|id| seen_ids.insert(id.clone()))
212 .collect();
213
214 for sym_id in &all_sym_ids {
215 let entries = ledger_store
216 .list_entries(&engine.ref_name, sym_id)
217 .unwrap_or_default();
218 for entry in entries {
219 let key = entry.summary.clone();
220 match entry.kind {
221 LedgerKind::Invariant => {
222 if seen_inv.insert(key) {
223 let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
224 if let Some(obj) = v.as_object_mut() {
225 obj.insert("source_symbol_id".to_string(), json!(sym_id));
226 }
227 all_invariants.push(v);
228 }
229 }
230 LedgerKind::Hazard => {
231 let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
232 if let Some(obj) = v.as_object_mut() {
233 obj.insert("source_symbol_id".to_string(), json!(sym_id));
234 }
235 all_hazards.push(v);
236 }
237 LedgerKind::ValidationScenario => {
238 let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
239 if let Some(obj) = v.as_object_mut() {
240 obj.insert("source_symbol_id".to_string(), json!(sym_id));
241 }
242 all_validation_scenarios.push(v);
243 }
244 LedgerKind::KnownBug => {
245 let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
246 if let Some(obj) = v.as_object_mut() {
247 obj.insert("source_symbol_id".to_string(), json!(sym_id));
248 }
249 all_known_bugs.push(v);
250 }
251 _ => {}
252 }
253 }
254 }
255
256 let recently_touched = git_recent_touches(&touched_files, args.git_depth);
258
259 let dirty = git_dirty_files();
261 let stale_symbols: Vec<&str> = touched_files
262 .keys()
263 .filter(|f| dirty.contains(*f))
264 .map(String::as_str)
265 .collect();
266
267 let test_gap = affected_test_rows.is_empty() && tier != 2;
269 let proposed_test_path = if test_gap {
270 Some(propose_test_path(&symbol.file))
271 } else {
272 None
273 };
274
275 let mut sym_val = serde_json::to_value(&symbol)?;
277 if let Some(obj) = sym_val.as_object_mut() {
278 obj.remove("body");
279 }
280
281 if let Some(lim) = args.limit {
282 caller_rows.truncate(lim);
283 }
284
285 let focus = intent_focus(intent);
286 let cross_service = {
291 let tree = engine
292 .repo
293 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
294 .unwrap_or(Value::Null);
295 let all = endpoints_from_tree(&tree);
296 if all.iter().any(|e| e.symbol_id == symbol.symbol_id) {
297 let rows: Vec<Value> = match_edges(&all)
298 .iter()
299 .filter(|e| {
300 e.from.symbol_id == symbol.symbol_id || e.to.symbol_id == symbol.symbol_id
301 })
302 .map(|e| {
303 let is_handler = e.to.symbol_id == symbol.symbol_id;
304 let peer = if is_handler { &e.from } else { &e.to };
305 json!({
306 "role": if is_handler { "handler" } else { "consumer" },
307 "contract": e.contract,
308 "peer_qname": peer.qname,
309 "cross_repo": e.cross_repo,
310 "confidence": e.confidence,
311 })
312 })
313 .collect();
314 Value::Array(rows)
315 } else {
316 Value::Null
317 }
318 };
319
320 let out = json!({
321 "symbol": sym_val,
322 "layer": layer,
323 "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
324 "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
325 "caller_count": caller_rows.len(),
326 "test_count": affected_test_rows.len(),
327 "test_gap": test_gap,
328 "proposed_test_path": proposed_test_path,
329 "stale_symbols": stale_symbols,
330 "invariants": all_invariants,
331 "hazards": all_hazards,
332 "validation_scenarios": all_validation_scenarios,
333 "known_bugs": all_known_bugs,
334 "effects": effects_out,
335 "callers": caller_rows,
336 "cross_service": cross_service,
337 "affected_tests": affected_test_rows,
338 "recently_touched": recently_touched,
339 });
340 let out = if args.agent {
341 let max_list = (args.agent_budget / 500).max(3).min(20);
342 let trimmed = trim_for_agent(&out, max_list);
343 let json_str = serde_json::to_string_pretty(&trimmed)?;
344 let token_est = estimate_tokens(&json_str);
345 let mut v = trimmed;
346 if let Some(obj) = v.as_object_mut() {
347 obj.insert("token_estimate".into(), json!(token_est));
348 }
349 v
350 } else {
351 out
352 };
353 println!("{}", serde_json::to_string_pretty(&out)?);
354 Ok(())
355}
356
357type IndexMap = std::collections::HashMap<String, usize>;
359
360pub(crate) fn git_recent_touches_pub(files: &[(String, usize)], git_depth: usize) -> Value {
362 let map: IndexMap = files.iter().cloned().collect();
363 git_recent_touches(&map, git_depth)
364}
365
366fn git_recent_touches(files: &IndexMap, git_depth: usize) -> Value {
369 let mut result: Vec<Value> = Vec::new();
370 let mut sorted: Vec<(&String, &usize)> = files.iter().collect();
372 sorted.sort_by_key(|(_, d)| **d);
373
374 for (file, _) in sorted {
375 let output = Proc::new("git")
376 .args([
377 "log",
378 "--follow",
379 &format!("-n{git_depth}"),
380 "--pretty=format:%H\x1f%an\x1f%ad\x1f%s",
381 "--date=short",
382 "--",
383 file,
384 ])
385 .output();
386
387 let commits: Vec<Value> = match output {
388 Ok(out) if out.status.success() => {
389 let text = String::from_utf8_lossy(&out.stdout);
390 text.lines()
391 .filter(|l| !l.is_empty())
392 .filter_map(|line| {
393 let parts: Vec<&str> = line.splitn(4, '\x1f').collect();
394 if parts.len() == 4 {
395 Some(json!({
396 "sha": &parts[0][..8.min(parts[0].len())],
397 "author": parts[1],
398 "date": parts[2],
399 "message": parts[3],
400 }))
401 } else {
402 None
403 }
404 })
405 .collect()
406 }
407 _ => vec![],
408 };
409
410 if !commits.is_empty() {
411 result.push(json!({ "file": file, "commits": commits }));
412 }
413 }
414 json!(result)
415}