agentstatedeveloper_cli/commands/
since.rs1use 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 pub sha: String,
29
30 #[arg(long, default_value = "3")]
32 pub depth: usize,
33
34 #[arg(long, default_value = "10")]
36 pub git_depth: usize,
37
38 #[arg(long)]
40 pub quiet: bool,
41
42 #[arg(long)]
45 pub intent: Option<String>,
46
47 #[arg(long)]
49 pub agent: bool,
50
51 #[arg(long, default_value = "8000")]
53 pub agent_budget: usize,
54
55 #[arg(long)]
57 pub paths: Option<String>,
58
59 #[arg(long)]
61 pub scope: Option<String>,
62
63 #[arg(long)]
65 pub limit: Option<usize>,
66
67 #[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 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 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 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 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 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 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 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 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 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
292fn 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}