Skip to main content

agentstatedeveloper_cli/commands/
since.rs

1//! `asd since <sha>` — symbols in files changed since a commit + blast radius.
2//!
3//! PR/review workflow: given the base SHA of a branch, surfaces every symbol
4//! that lives in a modified file, their combined transitive callers, affected
5//! tests, and any invariants/hazards that apply — without the caller needing
6//! to know any symbol names upfront.
7
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::process::Command as Proc;
10
11use anyhow::Result;
12use clap::Args;
13use serde_json::{Value, json};
14
15use agentstatedeveloper_core::{
16    AsgEffectStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine, IndexStore, LedgerKind,
17    LedgerStore, classify_layer_sym, estimate_tokens, git_dirty_files, intent_focus,
18    load_layer_overrides, matches_any_path_glob, parse_intent, propose_test_path, resolve_scope,
19    stale_warning, symbol_tier, trim_for_agent,
20};
21
22use crate::commands::{graph::build_id_map, impact::git_recent_touches_pub};
23use crate::config::Config;
24
25#[derive(Debug, Args)]
26pub struct SinceArgs {
27    /// Base commit SHA (or branch/tag) to diff against HEAD.
28    pub sha: String,
29
30    /// Caller-graph BFS depth for blast radius (default: 3).
31    #[arg(long, default_value = "3")]
32    pub depth: usize,
33
34    /// Number of recent git commits to scan per changed file (default: 10).
35    #[arg(long, default_value = "10")]
36    pub git_depth: usize,
37
38    /// Suppress the stale-index warning.
39    #[arg(long)]
40    pub quiet: bool,
41
42    /// Adjust output framing for a specific intent.
43    /// Values: bugfix, feature, refactor, test, architecture, ui.
44    #[arg(long)]
45    pub intent: Option<String>,
46
47    /// Emit token-budgeted JSON for LLM consumption.
48    #[arg(long)]
49    pub agent: bool,
50
51    /// Token budget when --agent is set (default: 8000).
52    #[arg(long, default_value = "8000")]
53    pub agent_budget: usize,
54
55    /// Comma-separated glob patterns to restrict touched symbols to specific paths.
56    #[arg(long)]
57    pub paths: Option<String>,
58
59    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
60    #[arg(long)]
61    pub scope: Option<String>,
62
63    /// Maximum number of seed symbols to include (default: unlimited).
64    #[arg(long)]
65    pub limit: Option<usize>,
66
67    /// Output is always JSON; this flag is accepted for CLI consistency.
68    #[arg(long)]
69    pub json: bool,
70}
71
72pub fn run(cfg: &Config, args: SinceArgs) -> Result<()> {
73    if !args.quiet {
74        if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
75            eprintln!("{warn}");
76        }
77    }
78    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
79    let layer_overrides = load_layer_overrides(&cfg.db_path);
80    let engine = Engine::open_sqlite(&cfg.db_path)?;
81    let index_store = AsgIndexStore::from_engine(&engine);
82    let effect_store = AsgEffectStore::from_engine(&engine);
83    let ledger_store = AsgLedgerStore::from_engine(&engine);
84    let id_map = build_id_map(&engine);
85
86    // --- Get changed files since <sha> ------------------------------------
87    let changed_files = git_changed_files(&args.sha);
88    if changed_files.is_empty() {
89        let out = json!({
90            "sha": args.sha,
91            "changed_files": [],
92            "touched_symbols": {},
93            "callers": [],
94            "affected_tests": [],
95            "invariants": [],
96            "hazards": [],
97            "effects": [],
98        });
99        println!("{}", serde_json::to_string_pretty(&out)?);
100        return Ok(());
101    }
102
103    let changed_set: HashSet<&str> = changed_files.iter().map(String::as_str).collect();
104
105    // Build optional path filter from --scope / --paths.
106    let mut paths_filter: Vec<String> = Vec::new();
107    if let Some(ref scope) = args.scope {
108        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
109    }
110    if let Some(ref paths) = args.paths {
111        paths_filter.extend(
112            paths
113                .split(',')
114                .map(|p| p.trim().to_string())
115                .filter(|p| !p.is_empty()),
116        );
117    }
118
119    // --- Find all indexed symbols in changed files ------------------------
120    let mut seed_symbols: Vec<&agentstatedeveloper_core::Symbol> = id_map
121        .values()
122        .filter(|s| changed_set.contains(s.file.as_str()))
123        .filter(|s| paths_filter.is_empty() || matches_any_path_glob(&paths_filter, &s.file))
124        .collect();
125    if let Some(lim) = args.limit {
126        seed_symbols.truncate(lim);
127    }
128
129    // Group seeds by layer for the touched_symbols output.
130    let mut by_layer: HashMap<String, Vec<Value>> = HashMap::new();
131    for sym in &seed_symbols {
132        let tier = symbol_tier(&sym.file);
133        let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
134        let entry = json!({
135            "qname": sym.qname,
136            "file": sym.file,
137            "line": sym.start.line,
138            "layer": layer,
139        });
140        by_layer.entry(layer.to_string()).or_default().push(entry);
141    }
142
143    // --- BFS blast radius from all seeds ----------------------------------
144    let mut visited: HashSet<String> = seed_symbols.iter().map(|s| s.symbol_id.clone()).collect();
145    let mut queue: VecDeque<(String, usize)> = seed_symbols
146        .iter()
147        .map(|s| (s.symbol_id.clone(), 0))
148        .collect();
149
150    let mut caller_rows: Vec<Value> = Vec::new();
151    let mut affected_test_rows: Vec<Value> = Vec::new();
152    let mut touched_files: Vec<(String, usize)> =
153        changed_files.iter().map(|f| (f.clone(), 0)).collect();
154    let mut seen_files: HashSet<String> = changed_files.iter().cloned().collect();
155
156    while let Some((sym_id, depth)) = queue.pop_front() {
157        if depth >= args.depth {
158            continue;
159        }
160        let neighbors = index_store
161            .get_callers(&engine.ref_name, &sym_id)
162            .unwrap_or_default();
163        for nbr_id in neighbors {
164            if visited.contains(&nbr_id) {
165                continue;
166            }
167            visited.insert(nbr_id.clone());
168            if let Some(s) = id_map.get(&nbr_id) {
169                let tier = symbol_tier(&s.file);
170                let layer = classify_layer_sym(&s.file, &s.qname, tier, &layer_overrides);
171                let row = json!({
172                    "qname": s.qname,
173                    "file": s.file,
174                    "line": s.start.line,
175                    "depth": depth + 1,
176                    "layer": layer,
177                });
178                if tier == 2 {
179                    affected_test_rows.push(row);
180                } else {
181                    caller_rows.push(row);
182                }
183                if seen_files.insert(s.file.clone()) {
184                    touched_files.push((s.file.clone(), depth + 1));
185                }
186                if depth + 1 < args.depth {
187                    queue.push_back((nbr_id, depth + 1));
188                }
189            }
190        }
191    }
192
193    // --- Aggregate effects/invariants/hazards from seeds ------------------
194    let all_sym_ids: Vec<String> = seed_symbols.iter().map(|s| s.symbol_id.clone()).collect();
195
196    let mut all_invariants: Vec<Value> = Vec::new();
197    let mut all_hazards: Vec<Value> = Vec::new();
198    let mut all_effects: Vec<Value> = Vec::new();
199    let mut seen_inv: HashSet<String> = HashSet::new();
200
201    for sym_id in &all_sym_ids {
202        let entries = ledger_store
203            .list_entries(&engine.ref_name, sym_id)
204            .unwrap_or_default();
205        for entry in entries {
206            let key = entry.summary.clone();
207            let sym_qname = id_map.get(sym_id).map(|s| s.qname.as_str()).unwrap_or("");
208            match entry.kind {
209                LedgerKind::Invariant => {
210                    if seen_inv.insert(key) {
211                        all_invariants.push(json!({
212                            "summary": entry.summary,
213                            "source": sym_qname,
214                        }));
215                    }
216                }
217                LedgerKind::Hazard => {
218                    all_hazards.push(json!({
219                        "summary": entry.summary,
220                        "source": sym_qname,
221                    }));
222                }
223                _ => {}
224            }
225        }
226        if let Ok(Some(decl)) = effect_store.get_effects(&engine.ref_name, sym_id) {
227            for eff in &decl.declared {
228                let cat = format!("{:?}", eff.effect);
229                let sym_qname = id_map
230                    .get(sym_id)
231                    .map(|s| s.qname.clone())
232                    .unwrap_or_default();
233                all_effects.push(json!({ "category": cat, "source": sym_qname }));
234            }
235        }
236    }
237
238    // --- Recent git touches for changed files (up to 5) ------------------
239    let top_files: Vec<(String, usize)> = touched_files.iter().take(5).cloned().collect();
240    let recently_touched = git_recent_touches_pub(&top_files, args.git_depth);
241
242    // --- Staleness warnings -----------------------------------------------
243    let dirty = git_dirty_files();
244    let stale_symbols: Vec<&str> = changed_files
245        .iter()
246        .filter(|f| dirty.contains(f.as_str()))
247        .map(String::as_str)
248        .collect();
249
250    // --- Test-gap detection -----------------------------------------------
251    let test_gap = affected_test_rows.is_empty();
252    let proposed_test_path = test_gap
253        .then(|| changed_files.first().map(|f| propose_test_path(f)))
254        .flatten();
255
256    let focus = intent_focus(intent);
257    let out = json!({
258        "sha": args.sha,
259        "intent": if intent.is_empty() { Value::Null } else { json!(intent) },
260        "focus": if focus.is_empty() { Value::Null } else { json!(focus) },
261        "changed_files": changed_files,
262        "touched_symbols": by_layer,
263        "caller_count": caller_rows.len(),
264        "test_count": affected_test_rows.len(),
265        "test_gap": test_gap,
266        "proposed_test_path": proposed_test_path,
267        "stale_symbols": stale_symbols,
268        "callers": caller_rows,
269        "affected_tests": affected_test_rows,
270        "invariants": all_invariants,
271        "hazards": all_hazards,
272        "effects": all_effects,
273        "recently_touched": recently_touched,
274    });
275    let out = if args.agent {
276        let max_list = (args.agent_budget / 500).max(3).min(20);
277        let trimmed = trim_for_agent(&out, max_list);
278        let json_str = serde_json::to_string_pretty(&trimmed)?;
279        let token_est = estimate_tokens(&json_str);
280        let mut v = trimmed;
281        if let Some(obj) = v.as_object_mut() {
282            obj.insert("token_estimate".into(), json!(token_est));
283        }
284        v
285    } else {
286        out
287    };
288    println!("{}", serde_json::to_string_pretty(&out)?);
289    Ok(())
290}
291
292/// Run `git diff --name-only <sha>..HEAD` and return the list of changed paths.
293fn git_changed_files(sha: &str) -> Vec<String> {
294    let output = Proc::new("git")
295        .args(["diff", "--name-only", &format!("{sha}..HEAD")])
296        .output();
297    match output {
298        Ok(out) if out.status.success() => {
299            let text = String::from_utf8_lossy(&out.stdout);
300            text.lines()
301                .filter(|l| !l.is_empty())
302                .map(|l| l.to_string())
303                .collect()
304        }
305        _ => vec![],
306    }
307}