agentstatedeveloper-cli 1.4.9

asd CLI — command-line interface for AgentStateDeveloper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! `asd impact <qname>` — blast-radius view before editing a symbol.
//!
//! Combines transitive callers, aggregated effects, invariants/hazards,
//! affected test symbols, and recent git touches in one pass.

use std::collections::{HashSet, VecDeque};
use std::process::Command as Proc;

use anyhow::Result;
use clap::Args;
use serde_json::{Value, json};

use agentstatedeveloper_core::{
    AsgEffectStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine, IndexStore, LedgerKind,
    LedgerStore, classify_layer_sym, endpoints_from_tree, estimate_tokens, git_dirty_files,
    glob_match, intent_focus, load_layer_overrides, match_edges, matches_any_path_glob,
    parse_intent, propose_test_path, resolve_scope, stale_warning, symbol_tier, trim_for_agent,
};

use crate::commands::graph::build_id_map;
use crate::config::Config;

#[derive(Debug, Args)]
pub struct ImpactArgs {
    /// Fully-qualified symbol name to analyse.
    pub qname: String,

    /// Caller-graph traversal depth (default: 3).
    #[arg(long, default_value = "3")]
    pub depth: usize,

    /// Number of recent git commits to look back per touched file (default: 20).
    #[arg(long, default_value = "20")]
    pub git_depth: usize,

    /// Suppress the stale-index warning.
    #[arg(long)]
    pub quiet: bool,

    /// Adjust output framing for a specific intent.
    /// Values: bugfix, feature, refactor, test, architecture, ui.
    #[arg(long)]
    pub intent: Option<String>,

    /// Emit token-budgeted JSON for LLM consumption. Trims bodies,
    /// collapses low-signal fields, adds token_estimate.
    #[arg(long)]
    pub agent: bool,

    /// Token budget when --agent is set (default: 8000).
    #[arg(long, default_value = "8000")]
    pub agent_budget: usize,

    /// Comma-separated terms to exclude from caller results.
    #[arg(long)]
    pub exclude: Option<String>,

    /// Comma-separated glob patterns to restrict callers to specific paths.
    #[arg(long)]
    pub paths: Option<String>,

    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
    #[arg(long)]
    pub scope: Option<String>,

    /// Maximum number of caller rows to include in the output (default: unlimited).
    #[arg(long)]
    pub limit: Option<usize>,

    /// Output is always JSON; this flag is accepted for CLI consistency.
    #[arg(long)]
    pub json: bool,
}

pub fn run(cfg: &Config, args: ImpactArgs) -> Result<()> {
    if !args.quiet {
        if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
            eprintln!("{warn}");
        }
    }
    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
    let layer_overrides = load_layer_overrides(&cfg.db_path);
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let index_store = AsgIndexStore::from_engine(&engine);
    let effect_store = AsgEffectStore::from_engine(&engine);
    let ledger_store = AsgLedgerStore::from_engine(&engine);
    let id_map = build_id_map(&engine);

    let symbol = index_store
        .get_symbol_by_qname(&engine.ref_name, &args.qname)?
        .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;

    let mut paths_filter: Vec<String> = Vec::new();
    if let Some(ref scope) = args.scope {
        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
    }
    if let Some(ref paths) = args.paths {
        paths_filter.extend(
            paths
                .split(',')
                .map(|p| p.trim().to_string())
                .filter(|p| !p.is_empty()),
        );
    }
    let exclusions: Vec<String> = args
        .exclude
        .as_deref()
        .map(|e| {
            e.split(',')
                .map(|t| t.trim().to_lowercase())
                .filter(|t| !t.is_empty())
                .collect()
        })
        .unwrap_or_default();

    let tier = symbol_tier(&symbol.file);
    let layer = classify_layer_sym(&symbol.file, &symbol.qname, tier, &layer_overrides);

    // --- Transitive callers (BFS) -----------------------------------------
    let mut visited: HashSet<String> = HashSet::new();
    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
    visited.insert(symbol.symbol_id.clone());
    queue.push_back((symbol.symbol_id.clone(), 0));

    let mut caller_rows: Vec<Value> = Vec::new();
    let mut affected_test_rows: Vec<Value> = Vec::new();
    // File set for recently_touched — start with the symbol's own file.
    let mut touched_files: IndexMap = IndexMap::new();
    touched_files.insert(symbol.file.clone(), 0usize);

    while let Some((sym_id, depth)) = queue.pop_front() {
        if depth >= args.depth {
            continue;
        }
        let neighbors = index_store
            .get_callers(&engine.ref_name, &sym_id)
            .unwrap_or_default();
        for nbr_id in neighbors {
            if visited.contains(&nbr_id) {
                continue;
            }
            visited.insert(nbr_id.clone());
            if let Some(s) = id_map.get(&nbr_id) {
                // Apply path and exclusion filters.
                if !paths_filter.is_empty() && !matches_any_path_glob(&paths_filter, &s.file) {
                    continue;
                }
                if !exclusions.is_empty() {
                    let lfile = s.file.to_lowercase();
                    let lqname = s.qname.to_lowercase();
                    if exclusions
                        .iter()
                        .any(|e| glob_match(e, &lfile) || lqname.contains(e.as_str()))
                    {
                        continue;
                    }
                }
                let t = symbol_tier(&s.file);
                let l = classify_layer_sym(&s.file, &s.qname, t, &layer_overrides);
                let row = json!({
                    "qname": s.qname,
                    "file": s.file,
                    "line": s.start.line,
                    "depth": depth + 1,
                    "layer": l,
                });
                if t == 2 {
                    // test tier
                    affected_test_rows.push(row);
                } else {
                    caller_rows.push(row.clone());
                }
                touched_files.entry(s.file.clone()).or_insert(depth + 1);
                if depth + 1 < args.depth {
                    queue.push_back((nbr_id, depth + 1));
                }
            }
        }
    }

    // --- Effects from target symbol ----------------------------------------
    let effects_raw = effect_store
        .get_effects(&engine.ref_name, &symbol.symbol_id)
        .unwrap_or_default();
    let effects_out: Value = serde_json::to_value(&effects_raw).unwrap_or(json!(null));

    // --- Invariants/hazards/validation_scenarios/known_bugs from target + callers ---
    //
    // Plan J t-013: ValidationScenario and KnownBug entries now
    // surface on `impact` so the agent can see "what tests this
    // hits" and "what's known broken" without a separate
    // context_for call. Invariants are deduped by summary because
    // the same invariant frequently appears on multiple callers;
    // hazards / scenarios / bugs are NOT deduped — callers usually
    // want every instance for blast-radius reasoning.
    let mut all_invariants: Vec<Value> = Vec::new();
    let mut all_hazards: Vec<Value> = Vec::new();
    let mut all_validation_scenarios: Vec<Value> = Vec::new();
    let mut all_known_bugs: Vec<Value> = Vec::new();
    let mut seen_inv: HashSet<String> = HashSet::new();

    // Pre-existing bug surfaced by Plan J t-013 tests: `visited`
    // (the BFS traversal set) includes the target symbol itself, so
    // chaining `once(target)` with `visited` walked the target's
    // ledger twice. Invariants didn't notice because they dedupe by
    // summary; hazards (and the newly-added validation_scenarios /
    // known_bugs) doubled up. Dedupe by symbol_id at the source.
    let mut seen_ids: HashSet<String> = HashSet::new();
    let all_sym_ids: Vec<String> = std::iter::once(symbol.symbol_id.clone())
        .chain(visited.iter().cloned())
        .filter(|id| seen_ids.insert(id.clone()))
        .collect();

    for sym_id in &all_sym_ids {
        let entries = ledger_store
            .list_entries(&engine.ref_name, sym_id)
            .unwrap_or_default();
        for entry in entries {
            let key = entry.summary.clone();
            match entry.kind {
                LedgerKind::Invariant => {
                    if seen_inv.insert(key) {
                        let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
                        if let Some(obj) = v.as_object_mut() {
                            obj.insert("source_symbol_id".to_string(), json!(sym_id));
                        }
                        all_invariants.push(v);
                    }
                }
                LedgerKind::Hazard => {
                    let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
                    if let Some(obj) = v.as_object_mut() {
                        obj.insert("source_symbol_id".to_string(), json!(sym_id));
                    }
                    all_hazards.push(v);
                }
                LedgerKind::ValidationScenario => {
                    let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
                    if let Some(obj) = v.as_object_mut() {
                        obj.insert("source_symbol_id".to_string(), json!(sym_id));
                    }
                    all_validation_scenarios.push(v);
                }
                LedgerKind::KnownBug => {
                    let mut v = serde_json::to_value(&entry).unwrap_or(json!(null));
                    if let Some(obj) = v.as_object_mut() {
                        obj.insert("source_symbol_id".to_string(), json!(sym_id));
                    }
                    all_known_bugs.push(v);
                }
                _ => {}
            }
        }
    }

    // --- Recent git touches -----------------------------------------------
    let recently_touched = git_recent_touches(&touched_files, args.git_depth);

    // --- Staleness warnings -----------------------------------------------
    let dirty = git_dirty_files();
    let stale_symbols: Vec<&str> = touched_files
        .keys()
        .filter(|f| dirty.contains(*f))
        .map(String::as_str)
        .collect();

    // --- Test-gap detection -----------------------------------------------
    let test_gap = affected_test_rows.is_empty() && tier != 2;
    let proposed_test_path = if test_gap {
        Some(propose_test_path(&symbol.file))
    } else {
        None
    };

    // --- Symbol summary ---------------------------------------------------
    let mut sym_val = serde_json::to_value(&symbol)?;
    if let Some(obj) = sym_val.as_object_mut() {
        obj.remove("body");
    }

    if let Some(lim) = args.limit {
        caller_rows.truncate(lim);
    }

    let focus = intent_focus(intent);
    // Cross-service edges (t-002): in-repo service-boundary peers of the target,
    // so blast radius includes HTTP/pub-sub consumers, not just call-graph
    // callers. Null when the target owns no endpoint. (In-repo only — cross-repo
    // is a Team-tier feature.)
    let cross_service = {
        let tree = engine
            .repo
            .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
            .unwrap_or(Value::Null);
        let all = endpoints_from_tree(&tree);
        if all.iter().any(|e| e.symbol_id == symbol.symbol_id) {
            let rows: Vec<Value> = match_edges(&all)
                .iter()
                .filter(|e| {
                    e.from.symbol_id == symbol.symbol_id || e.to.symbol_id == symbol.symbol_id
                })
                .map(|e| {
                    let is_handler = e.to.symbol_id == symbol.symbol_id;
                    let peer = if is_handler { &e.from } else { &e.to };
                    json!({
                        "role": if is_handler { "handler" } else { "consumer" },
                        "contract": e.contract,
                        "peer_qname": peer.qname,
                        "cross_repo": e.cross_repo,
                        "confidence": e.confidence,
                    })
                })
                .collect();
            Value::Array(rows)
        } else {
            Value::Null
        }
    };

    let out = json!({
        "symbol": sym_val,
        "layer": layer,
        "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
        "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
        "caller_count": caller_rows.len(),
        "test_count": affected_test_rows.len(),
        "test_gap": test_gap,
        "proposed_test_path": proposed_test_path,
        "stale_symbols": stale_symbols,
        "invariants": all_invariants,
        "hazards": all_hazards,
        "validation_scenarios": all_validation_scenarios,
        "known_bugs": all_known_bugs,
        "effects": effects_out,
        "callers": caller_rows,
        "cross_service": cross_service,
        "affected_tests": affected_test_rows,
        "recently_touched": recently_touched,
    });
    let out = if args.agent {
        let max_list = (args.agent_budget / 500).max(3).min(20);
        let trimmed = trim_for_agent(&out, max_list);
        let json_str = serde_json::to_string_pretty(&trimmed)?;
        let token_est = estimate_tokens(&json_str);
        let mut v = trimmed;
        if let Some(obj) = v.as_object_mut() {
            obj.insert("token_estimate".into(), json!(token_est));
        }
        v
    } else {
        out
    };
    println!("{}", serde_json::to_string_pretty(&out)?);
    Ok(())
}

// Simple ordered map using Vec to preserve insertion order.
type IndexMap = std::collections::HashMap<String, usize>;

/// Public wrapper for use in prepare_change — takes a slice directly.
pub(crate) fn git_recent_touches_pub(files: &[(String, usize)], git_depth: usize) -> Value {
    let map: IndexMap = files.iter().cloned().collect();
    git_recent_touches(&map, git_depth)
}

/// Run `git log --follow -n <depth> --pretty=format:'...' -- <file>` for each
/// touched file. Returns an array of `{file, commits:[{sha,author,date,msg}]}`.
fn git_recent_touches(files: &IndexMap, git_depth: usize) -> Value {
    let mut result: Vec<Value> = Vec::new();
    // Sort by depth (ascending) so the primary file comes first.
    let mut sorted: Vec<(&String, &usize)> = files.iter().collect();
    sorted.sort_by_key(|(_, d)| **d);

    for (file, _) in sorted {
        let output = Proc::new("git")
            .args([
                "log",
                "--follow",
                &format!("-n{git_depth}"),
                "--pretty=format:%H\x1f%an\x1f%ad\x1f%s",
                "--date=short",
                "--",
                file,
            ])
            .output();

        let commits: Vec<Value> = match output {
            Ok(out) if out.status.success() => {
                let text = String::from_utf8_lossy(&out.stdout);
                text.lines()
                    .filter(|l| !l.is_empty())
                    .filter_map(|line| {
                        let parts: Vec<&str> = line.splitn(4, '\x1f').collect();
                        if parts.len() == 4 {
                            Some(json!({
                                "sha": &parts[0][..8.min(parts[0].len())],
                                "author": parts[1],
                                "date": parts[2],
                                "message": parts[3],
                            }))
                        } else {
                            None
                        }
                    })
                    .collect()
            }
            _ => vec![],
        };

        if !commits.is_empty() {
            result.push(json!({ "file": file, "commits": commits }));
        }
    }
    json!(result)
}