Skip to main content

agentstatedeveloper_cli/commands/
investigate.rs

1//! `asd investigate <query>` — broad feature archaeology in one pass.
2//!
3//! 1. FTS5 hybrid search to find entry points (falls back to in-memory).
4//! 2. For each top result: callers, callees, effects, invariants, hazards, notes.
5//! 3. Prints a structured JSON report.
6
7use anyhow::Result;
8use clap::Args;
9use serde_json::{Value, json};
10
11use agentstatedeveloper_core::{
12    AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Engine, FeedbackStore,
13    FtsFilters, IndexStore, LedgerStore, OwnershipSignal, apply_feedback_adjustments,
14    build_feedback_state_from_entries, classify_layer_sym, compute_trust_score,
15    compute_uncertainty, confidence_scores, detect_ambiguous_tokens, detect_possible_misses,
16    discover_symbol_ownership, estimate_tokens, explain_match, extract_summary, find_candidates,
17    gather_recency, git_dirty_files, intent_focus, intent_layer_order, load_layer_overrides,
18    parse_intent, parse_query, resolve_scope, result_bucket, stale_warning, symbol_tier,
19    trim_for_agent,
20};
21
22use crate::commands::context_for::assemble_symbol_context;
23use crate::config::Config;
24
25#[derive(Debug, Args)]
26pub struct InvestigateArgs {
27    /// Natural-language or keyword query. Scored across symbol name,
28    /// signature, doc comment, file path, and ledger entries.
29    /// Plan D t-002: multi-word queries are accepted unquoted —
30    /// `asd investigate failing test store` joins to "failing test store".
31    /// Quoting still works for queries containing flag-like tokens.
32    #[arg(required = true, num_args = 1..)]
33    pub query: Vec<String>,
34
35    /// Number of top entry-point symbols to fully expand (default: 10).
36    /// Alias: --limit (both accepted).
37    #[arg(long, default_value = "10")]
38    pub depth: usize,
39
40    /// Maximum results to expand. Overrides --depth when provided.
41    #[arg(long)]
42    pub limit: Option<usize>,
43
44    /// Filter by symbol kind: module, function, method, class, variable.
45    #[arg(long)]
46    pub kind: Option<String>,
47
48    /// Filter by language (e.g. "swift", "python", "typescript", "rust").
49    #[arg(long)]
50    pub language: Option<String>,
51
52    /// Include full source body of each symbol in output (can be large).
53    #[arg(long, default_value = "false")]
54    pub include_body: bool,
55
56    /// Include symbols from test files in entry-point candidates.
57    #[arg(long)]
58    pub include_tests: bool,
59
60    /// Suppress the stale-index warning.
61    #[arg(long)]
62    pub quiet: bool,
63
64    /// Return a flat `entry_points` array instead of the default `by_layer` grouped output.
65    #[arg(long)]
66    pub flat: bool,
67
68    /// Maximum entry points per layer in grouped output (default: unlimited).
69    #[arg(long)]
70    pub max_per_layer: Option<usize>,
71
72    /// Adjust output ordering and guidance for a specific intent.
73    /// Values: bugfix, feature, refactor, test, architecture, ui.
74    #[arg(long)]
75    pub intent: Option<String>,
76
77    /// Emit token-budgeted JSON for LLM consumption. Trims bodies,
78    /// collapses low-signal fields, adds token_estimate.
79    #[arg(long)]
80    pub agent: bool,
81
82    /// Token budget when --agent is set (default: 8000).
83    #[arg(long, default_value = "8000")]
84    pub agent_budget: usize,
85
86    /// Comma-separated terms to exclude. Also supports inline minus-prefix
87    /// syntax in the query, e.g. "drift playhead -sample -waveform".
88    #[arg(long)]
89    pub exclude: Option<String>,
90
91    /// Comma-separated glob patterns to restrict results to specific paths,
92    /// e.g. --paths "App/**/DriftPad*,Packages/SequencerCore/**".
93    #[arg(long)]
94    pub paths: Option<String>,
95
96    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
97    #[arg(long)]
98    pub scope: Option<String>,
99
100    /// Output is always JSON; this flag is accepted for CLI consistency.
101    #[arg(long)]
102    pub json: bool,
103}
104
105pub fn run(cfg: &Config, args: InvestigateArgs) -> Result<()> {
106    // Plan D t-002: join unquoted multi-word queries into a single string.
107    // `asd investigate failing test store` now works the same as
108    // `asd investigate "failing test store"`.
109    let query: String = args.query.join(" ");
110    if !args.quiet {
111        if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
112            eprintln!("{warn}");
113        }
114    }
115    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
116    if args.intent.is_some() && intent.is_empty() {
117        eprintln!(
118            "asd: unknown intent {:?} — valid values: bugfix, feature, refactor, test, architecture, ui",
119            args.intent.as_deref().unwrap_or("")
120        );
121    }
122    let layer_overrides = load_layer_overrides(&cfg.db_path);
123    let engine = Engine::open_sqlite(&cfg.db_path)?;
124    let index_store = AsgIndexStore::from_engine(&engine);
125    let ledger_store = AsgLedgerStore::from_engine(&engine);
126    let effect_store = AsgEffectStore::from_engine(&engine);
127    let id_map = index_store.build_id_map(&engine);
128
129    let (tokens, mut exclusions) = parse_query(&query);
130    if let Some(ref excl) = args.exclude {
131        for term in excl
132            .split(',')
133            .map(|t| t.trim().to_lowercase())
134            .filter(|t| !t.is_empty())
135        {
136            exclusions.push(term);
137        }
138    }
139    if tokens.is_empty() {
140        println!("{}", json!({ "query": query, "entry_points": [] }));
141        return Ok(());
142    }
143
144    let mut paths_filter: Vec<String> = Vec::new();
145    if let Some(ref scope) = args.scope {
146        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
147    }
148    if let Some(ref paths) = args.paths {
149        paths_filter.extend(
150            paths
151                .split(',')
152                .map(|p| p.trim().to_string())
153                .filter(|p| !p.is_empty()),
154        );
155    }
156    let filters = FtsFilters {
157        kind: args.kind.as_deref().map(|k| k.to_lowercase()),
158        language: args.language.as_deref().map(|l| l.to_lowercase()),
159        include_tests: args.include_tests,
160        tests_only: false,
161        exclude_terms: exclusions,
162        paths_filter,
163        exclude_paths: Vec::new(),
164        exclude_languages: Vec::new(),
165    };
166
167    // Each entry_point candidate: (combined_score, symbol_id, qname)
168    // We resolve full Symbol via index_store for context assembly.
169    // Returns (score, qname) pairs.
170    let limit = args.limit.unwrap_or(args.depth);
171    let mut candidates: Vec<(f64, String)> = find_candidates(
172        &engine,
173        &query,
174        &tokens,
175        &filters,
176        &ledger_store,
177        &index_store,
178        limit,
179    );
180
181    // Apply durable feedback adjustments (Useful/Noisy/WrongLayer verdicts).
182    // list_all() hoisted once — reused for build_feedback_state_from_entries below.
183    let feedback_store = AsgFeedbackStore::from_engine(&engine);
184    let all_fb = feedback_store
185        .list_all(&engine.ref_name)
186        .unwrap_or_default();
187    // Plan J t-016: tuple includes created_at for age-decay.
188    let feedback_verdicts: Vec<(
189        String,
190        String,
191        agentstatedeveloper_core::FeedbackVerdict,
192        chrono::DateTime<chrono::Utc>,
193    )> = all_fb
194        .iter()
195        .filter(|e| e.file_scope.is_none())
196        .map(|e| {
197            (
198                e.symbol_id.clone(),
199                e.query.clone(),
200                e.verdict,
201                e.created_at,
202            )
203        })
204        .collect();
205    let feedback_metrics = apply_feedback_adjustments(
206        &engine,
207        &index_store,
208        &query,
209        &mut candidates,
210        &feedback_verdicts,
211    );
212
213    // One git pass to gather recency for all candidate files (hot = 14 days).
214    let recency = gather_recency(200, 14.0);
215
216    // Per-file ownership cache: discover_symbol_ownership spawns git blame + git log.
217    // With up to 10 candidates, many may share files — computing once per file
218    // avoids up to N*2 redundant git subprocess spawns.
219    let mut ownership_cache: std::collections::HashMap<String, OwnershipSignal> =
220        std::collections::HashMap::new();
221
222    let mut entry_points: Vec<Value> = Vec::new();
223    for (score, qname) in &candidates {
224        let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
225            Ok(Some(s)) => s,
226            _ => continue,
227        };
228        let tier = symbol_tier(&sym.file);
229        let layer = classify_layer_sym(&sym.file, &sym.qname, tier, &layer_overrides);
230        let summary = extract_summary(sym.doc.as_deref(), sym.signature.as_deref());
231        let rec = recency.get(&sym.file);
232        let last_touched_days = rec.and_then(|r| r.last_touched_days);
233        let hot = rec.map(|r| r.hot).unwrap_or(false);
234        let ledger_entries = ledger_store
235            .list_entries(&engine.ref_name, &sym.symbol_id)
236            .unwrap_or_default();
237        let has_ledger = !ledger_entries.is_empty();
238        let match_reasons = explain_match(&sym, &tokens, &ledger_entries, hot);
239        // Compute ownership once per unique file; reuse for all symbols in the same file.
240        let ownership_hint = ownership_cache.entry(sym.file.clone()).or_insert_with(|| {
241            discover_symbol_ownership(&sym.file, sym.start.line, sym.end.line, sym.doc.as_deref())
242        });
243        let ctx = assemble_symbol_context(
244            &engine,
245            &index_store,
246            &effect_store,
247            &ledger_store,
248            &sym,
249            &id_map,
250            args.include_body,
251            engine.fts.as_ref(),
252            Some(ownership_hint),
253        )?;
254        let bucket = result_bucket(&sym.file, &match_reasons, has_ledger, hot);
255        let mut ep = json!({
256            "score": score,
257            "layer": layer,
258            "summary": summary,
259            "last_touched_days": last_touched_days,
260            "hot": hot,
261            "match_reasons": match_reasons,
262            "bucket": bucket,
263        });
264        if let (Some(obj), Some(ctx_obj)) = (ep.as_object_mut(), ctx.as_object()) {
265            for (k, v) in ctx_obj {
266                obj.insert(k.clone(), v.clone());
267            }
268        }
269        entry_points.push(ep);
270    }
271
272    // Aggregate invariants and hazards across all entry points into a single
273    // top-level section — the anti-footgun guard an agent should read first.
274    let mut all_invariants: Vec<Value> = Vec::new();
275    let mut all_hazards: Vec<Value> = Vec::new();
276    let mut seen_invariants: std::collections::HashSet<String> = std::collections::HashSet::new();
277
278    for ep in &entry_points {
279        let qname = ep
280            .get("symbol")
281            .and_then(|s| s.get("qname"))
282            .and_then(Value::as_str)
283            .unwrap_or("");
284
285        if let Some(invs) = ep.get("invariants").and_then(Value::as_array) {
286            for inv in invs {
287                let key = inv
288                    .get("summary")
289                    .and_then(Value::as_str)
290                    .unwrap_or("")
291                    .to_string();
292                if !key.is_empty() && seen_invariants.insert(key) {
293                    let mut v = inv.clone();
294                    if let Some(obj) = v.as_object_mut() {
295                        obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
296                    }
297                    all_invariants.push(v);
298                }
299            }
300        }
301        if let Some(hzs) = ep.get("hazards").and_then(Value::as_array) {
302            for hz in hzs {
303                let mut v = hz.clone();
304                if let Some(obj) = v.as_object_mut() {
305                    obj.insert("source_qname".to_string(), Value::String(qname.to_string()));
306                }
307                all_hazards.push(v);
308            }
309        }
310    }
311
312    // Build by_layer grouped view (layer → [entry_points]).
313    // Layer order is intent-aware; intent="" falls back to default order.
314    let layer_order = intent_layer_order(intent);
315    let mut by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
316    for layer_key in layer_order {
317        let mut members: Vec<Value> = entry_points
318            .iter()
319            .filter(|ep| ep.get("layer").and_then(Value::as_str) == Some(*layer_key))
320            .cloned()
321            .collect();
322        if let Some(max) = args.max_per_layer {
323            members.truncate(max);
324        }
325        if !members.is_empty() {
326            by_layer.insert(layer_key.to_string(), Value::Array(members));
327        }
328    }
329
330    let focus = intent_focus(intent);
331
332    // --- Uncertainty model -----------------------------------------------
333    let raw_scores: Vec<f64> = candidates.iter().map(|(s, _)| *s).collect();
334    let confidences = confidence_scores(&raw_scores);
335    // Attach confidence to each entry_point in order.
336    for (ep, conf) in entry_points.iter_mut().zip(confidences.iter()) {
337        if let Some(obj) = ep.as_object_mut() {
338            obj.insert("confidence".to_string(), json!(conf));
339        }
340    }
341    // --- Staleness warnings -----------------------------------------------
342    let dirty = git_dirty_files();
343    let stale_symbols: Vec<String> = entry_points
344        .iter()
345        .filter_map(|ep| {
346            ep.get("symbol")
347                .and_then(|s| s.get("file"))
348                .and_then(Value::as_str)
349        })
350        .filter(|f| dirty.contains(*f))
351        .map(|s| s.to_string())
352        .collect();
353
354    let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
355    let layers_present: std::collections::HashSet<&str> = entry_points
356        .iter()
357        .filter_map(|ep| ep.get("layer").and_then(Value::as_str))
358        .collect();
359    // t-001: suppress possible-miss warnings when scope is intentionally narrowed.
360    let scope_narrowed = !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
361    let possible_misses = if scope_narrowed {
362        vec![]
363    } else {
364        detect_possible_misses(&query, &layers_present, entry_points.len())
365    };
366
367    // Scoped suggestions (investigate doesn't compute these — pass empty slice).
368    let dq_state = compute_trust_score(&cfg.db_path).data_quality.state;
369    let uncertainty = compute_uncertainty(
370        &tokens,
371        &ambiguous_terms,
372        &possible_misses,
373        entry_points.len(),
374        &[],
375        engine.fts.as_ref(),
376        Some(dq_state.as_str()),
377    );
378
379    // Default: grouped by_layer output (compact, deduped by layer).
380    // --flat restores the legacy flat entry_points array.
381    // Invariants/hazards surfaced first so agents see constraints before call graphs.
382
383    let feedback_state =
384        build_feedback_state_from_entries(&all_fb, &query, feedback_metrics.entries_applied);
385    let coverage = if !feedback_state.available {
386        "none"
387    } else if feedback_state.query_matches == 0 {
388        "none"
389    } else if feedback_metrics.entries_applied > 0 {
390        "applied"
391    } else {
392        "partial"
393    };
394    let feedback_summary = json!({
395        "entries_applied": feedback_metrics.entries_applied,
396        "suppressed": feedback_metrics.suppressed,
397        "preserved_useful_siblings": feedback_metrics.preserved_useful_siblings,
398        "boosted": feedback_metrics.boosted,
399        "recurring_fp_suppressed": feedback_metrics.recurring_fp_suppressed,
400        "rules_applied": feedback_metrics.rules_applied,
401        "entries_total": feedback_state.entries_total,
402        "query_matches": feedback_state.query_matches,
403        "coverage": coverage,
404    });
405    let out = if args.flat {
406        json!({
407            "query": query,
408            "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
409            "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
410            "tokens": tokens,
411            "uncertainty": uncertainty.to_json(),
412            "ambiguous_terms": ambiguous_terms,
413            "possible_misses": possible_misses,
414            "scope_narrowed": scope_narrowed,
415            "stale_symbols": stale_symbols,
416            "feedback_state": feedback_state.to_json(),
417            "feedback_summary": feedback_summary,
418            "invariants": all_invariants,
419            "hazards": all_hazards,
420            "entry_points": entry_points,
421        })
422    } else {
423        json!({
424            "query": query,
425            "intent": if intent.is_empty() { Value::Null } else { Value::String(intent.to_string()) },
426            "focus": if focus.is_empty() { Value::Null } else { Value::String(focus.to_string()) },
427            "tokens": tokens,
428            "uncertainty": uncertainty.to_json(),
429            "ambiguous_terms": ambiguous_terms,
430            "possible_misses": possible_misses,
431            "scope_narrowed": scope_narrowed,
432            "stale_symbols": stale_symbols,
433            "feedback_state": feedback_state.to_json(),
434            "feedback_summary": feedback_summary,
435            "invariants": all_invariants,
436            "hazards": all_hazards,
437            "by_layer": by_layer,
438        })
439    };
440    // Token economy:
441    //   1.0.78/79: agent mode compact + drop input echo.
442    //   1.0.81: drop_empty_top_level applied unconditionally
443    //           (AcmeFlow case: agents using default JSON
444    //           path were still getting feedback_summary:{} etc.)
445    let out = agentstatedeveloper_core::drop_empty_top_level(out);
446
447    let (out, compact_for_agent) = if args.agent {
448        let max_list = (args.agent_budget / 500).max(3).min(20);
449        let mut trimmed = trim_for_agent(&out, max_list);
450        if let Some(obj) = trimmed.as_object_mut() {
451            obj.remove("query");
452        }
453        let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
454        let compact = serde_json::to_string(&trimmed)?;
455        let token_est = estimate_tokens(&compact);
456        let mut v = trimmed;
457        if let Some(obj) = v.as_object_mut() {
458            obj.insert("token_estimate".into(), json!(token_est));
459        }
460        (v, true)
461    } else {
462        (out, false)
463    };
464    let json_str = if compact_for_agent {
465        serde_json::to_string(&out)?
466    } else {
467        serde_json::to_string_pretty(&out)?
468    };
469    println!("{}", json_str);
470    Ok(())
471}