Skip to main content

agentstatedeveloper_cli/commands/
impact.rs

1//! `asd impact <qname>` — blast-radius view before editing a symbol.
2//!
3//! Combines transitive callers, aggregated effects, invariants/hazards,
4//! affected test symbols, and recent git touches in one pass.
5
6use 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    /// Fully-qualified symbol name to analyse.
26    pub qname: String,
27
28    /// Caller-graph traversal depth (default: 3).
29    #[arg(long, default_value = "3")]
30    pub depth: usize,
31
32    /// Number of recent git commits to look back per touched file (default: 20).
33    #[arg(long, default_value = "20")]
34    pub git_depth: usize,
35
36    /// Suppress the stale-index warning.
37    #[arg(long)]
38    pub quiet: bool,
39
40    /// Adjust output framing for a specific intent.
41    /// Values: bugfix, feature, refactor, test, architecture, ui.
42    #[arg(long)]
43    pub intent: Option<String>,
44
45    /// Emit token-budgeted JSON for LLM consumption. Trims bodies,
46    /// collapses low-signal fields, adds token_estimate.
47    #[arg(long)]
48    pub agent: bool,
49
50    /// Token budget when --agent is set (default: 8000).
51    #[arg(long, default_value = "8000")]
52    pub agent_budget: usize,
53
54    /// Comma-separated terms to exclude from caller results.
55    #[arg(long)]
56    pub exclude: Option<String>,
57
58    /// Comma-separated glob patterns to restrict callers to specific paths.
59    #[arg(long)]
60    pub paths: Option<String>,
61
62    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
63    #[arg(long)]
64    pub scope: Option<String>,
65
66    /// Maximum number of caller rows to include in the output (default: unlimited).
67    #[arg(long)]
68    pub limit: Option<usize>,
69
70    /// Output is always JSON; this flag is accepted for CLI consistency.
71    #[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    // --- Transitive callers (BFS) -----------------------------------------
120    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    // File set for recently_touched — start with the symbol's own file.
128    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                // Apply path and exclusion filters.
145                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                    // test tier
169                    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    // --- Effects from target symbol ----------------------------------------
182    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    // --- Invariants/hazards/validation_scenarios/known_bugs from target + callers ---
188    //
189    // Plan J t-013: ValidationScenario and KnownBug entries now
190    // surface on `impact` so the agent can see "what tests this
191    // hits" and "what's known broken" without a separate
192    // context_for call. Invariants are deduped by summary because
193    // the same invariant frequently appears on multiple callers;
194    // hazards / scenarios / bugs are NOT deduped — callers usually
195    // want every instance for blast-radius reasoning.
196    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    // Pre-existing bug surfaced by Plan J t-013 tests: `visited`
203    // (the BFS traversal set) includes the target symbol itself, so
204    // chaining `once(target)` with `visited` walked the target's
205    // ledger twice. Invariants didn't notice because they dedupe by
206    // summary; hazards (and the newly-added validation_scenarios /
207    // known_bugs) doubled up. Dedupe by symbol_id at the source.
208    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    // --- Recent git touches -----------------------------------------------
257    let recently_touched = git_recent_touches(&touched_files, args.git_depth);
258
259    // --- Staleness warnings -----------------------------------------------
260    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    // --- Test-gap detection -----------------------------------------------
268    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    // --- Symbol summary ---------------------------------------------------
276    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    // Cross-service edges (t-002): in-repo service-boundary peers of the target,
287    // so blast radius includes HTTP/pub-sub consumers, not just call-graph
288    // callers. Null when the target owns no endpoint. (In-repo only — cross-repo
289    // is a Team-tier feature.)
290    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
357// Simple ordered map using Vec to preserve insertion order.
358type IndexMap = std::collections::HashMap<String, usize>;
359
360/// Public wrapper for use in prepare_change — takes a slice directly.
361pub(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
366/// Run `git log --follow -n <depth> --pretty=format:'...' -- <file>` for each
367/// touched file. Returns an array of `{file, commits:[{sha,author,date,msg}]}`.
368fn git_recent_touches(files: &IndexMap, git_depth: usize) -> Value {
369    let mut result: Vec<Value> = Vec::new();
370    // Sort by depth (ascending) so the primary file comes first.
371    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}