Skip to main content

agentstatedeveloper_core/
context.rs

1//! Per-symbol context assembly for `asd context-for` (CLI) and the
2//! `mcp__asd__context_for` MCP tool.
3//!
4//! Plan M t-001 (1.0.91): previously CLI exported a
5//! `pub(crate)` `assemble_symbol_context` while MCP reimplemented
6//! the same logic inline (~150 lines, byte-for-byte equivalent).
7//! Lifted to core so both surfaces share a single source of truth.
8//!
9//! The function returns the per-symbol JSON value the agent sees:
10//! invariants/hazards/known_bugs/concepts/ownership grouped from
11//! the ledger, ownership discovery from git blame, covering tests,
12//! effects + per-effect verification detail, callers, callees.
13
14use std::collections::HashMap;
15
16use serde_json::{Value, json};
17
18use crate::effects::{AsgEffectStore, EffectStore};
19use crate::engine::Engine;
20use crate::index::{AsgIndexStore, IndexStore};
21use crate::ledger::{AsgLedgerStore, LedgerStore};
22use crate::schema::{LedgerKind, Symbol, VerificationStatus};
23use crate::search_fts::SearchFtsDb;
24use crate::{OwnershipSignal, discover_symbol_ownership, find_covering_tests};
25
26/// Assemble the per-symbol context object — the building block both
27/// CLI `asd context-for` and MCP `context_for` emit per requested
28/// qname.
29///
30/// `ownership_hint`: when `Some`, skip the `discover_symbol_ownership`
31/// git blame/log calls. Pass this when processing multiple symbols
32/// to share per-file results.
33///
34/// `fts`: borrowed FTS connection from the Engine; used for
35/// covering-test lookup. `None` skips the test scan (tests,
36/// one-off calls).
37///
38/// `include_body`: when false, drops the symbol's `body` field
39/// from the output to keep context compact (file + line range
40/// tells the agent exactly where to look).
41pub fn assemble_symbol_context(
42    engine: &Engine,
43    index_store: &AsgIndexStore<'_>,
44    effect_store: &AsgEffectStore<'_>,
45    ledger_store: &AsgLedgerStore<'_>,
46    symbol: &Symbol,
47    id_map: &HashMap<String, Symbol>,
48    include_body: bool,
49    fts: Option<&SearchFtsDb>,
50    ownership_hint: Option<&OwnershipSignal>,
51) -> crate::error::Result<Value> {
52    // Callers and callees.
53    let callee_ids = index_store.get_callees(&engine.ref_name, &symbol.symbol_id)?;
54    let caller_ids = index_store.get_callers(&engine.ref_name, &symbol.symbol_id)?;
55
56    let resolve = |ids: &[String]| -> Vec<Value> {
57        ids.iter()
58            .map(|id| {
59                if let Some(s) = id_map.get(id) {
60                    json!({ "qname": s.qname, "file": s.file, "line": s.start.line })
61                } else {
62                    json!({ "symbol_id": id })
63                }
64            })
65            .collect()
66    };
67
68    // t-013: per-edge runtime confidence (callee_id → (confidence, static_known))
69    // for this symbol's outgoing edges, from the edge-evidence sidecar.
70    let edge_ev: HashMap<String, (f64, bool)> = {
71        let tree = engine
72            .repo
73            .get_tree(
74                &engine.ref_name,
75                &crate::paths::edge_evidence_from_prefix(&symbol.symbol_id),
76            )
77            .unwrap_or(Value::Null);
78        let mut m = HashMap::new();
79        if let Some(obj) = tree.as_object() {
80            for (callee_id, v) in obj {
81                if let Ok(ev) =
82                    serde_json::from_value::<crate::edge_confidence::EdgeEvidence>(v.clone())
83                {
84                    m.insert(callee_id.clone(), (ev.confidence(), ev.static_known));
85                }
86            }
87        }
88        m
89    };
90    let round2 = |x: f64| (x * 100.0).round() / 100.0;
91    // Callees, each annotated with edge_confidence when runtime evidence exists.
92    let callees_out: Vec<Value> = callee_ids
93        .iter()
94        .map(|id| {
95            let mut row = if let Some(s) = id_map.get(id) {
96                json!({ "qname": s.qname, "file": s.file, "line": s.start.line })
97            } else {
98                json!({ "symbol_id": id })
99            };
100            if let Some((conf, _)) = edge_ev.get(id) {
101                row["edge_confidence"] = json!(round2(*conf));
102            }
103            row
104        })
105        .collect();
106    // Runtime-only edges: calls observed at runtime but absent from the static
107    // graph (dynamic dispatch the walker missed).
108    let runtime_missed_callees: Vec<Value> = edge_ev
109        .iter()
110        .filter(|(_, (_, static_known))| !static_known)
111        .map(|(id, (conf, _))| {
112            let qname = id_map
113                .get(id)
114                .map(|s| s.qname.clone())
115                .unwrap_or_else(|| id.clone());
116            json!({ "qname": qname, "edge_confidence": round2(*conf) })
117        })
118        .collect();
119
120    // Effects.
121    let effects = effect_store.get_effects(&engine.ref_name, &symbol.symbol_id)?;
122
123    // Ledger — all entries, newest first.
124    let ledger = ledger_store.list_entries(&engine.ref_name, &symbol.symbol_id)?;
125
126    // Group ledger by kind for readability.
127    let mut invariants: Vec<Value> = Vec::new();
128    let mut hazards: Vec<Value> = Vec::new();
129    let mut ownership: Vec<Value> = Vec::new();
130    let mut proofs: Vec<Value> = Vec::new();
131    let mut validation_scenarios: Vec<Value> = Vec::new();
132    let mut known_bugs: Vec<Value> = Vec::new();
133    let mut concepts: Vec<Value> = Vec::new();
134    let mut other_ledger: Vec<Value> = Vec::new();
135
136    for entry in &ledger {
137        let v = serde_json::to_value(entry)?;
138        match entry.kind {
139            LedgerKind::Invariant => invariants.push(v),
140            LedgerKind::Hazard => hazards.push(v),
141            LedgerKind::Ownership => ownership.push(v),
142            LedgerKind::Proof => proofs.push(v),
143            LedgerKind::ValidationScenario => validation_scenarios.push(v),
144            LedgerKind::KnownBug => known_bugs.push(v),
145            LedgerKind::Concept => concepts.push(v),
146            _ => other_ledger.push(v),
147        }
148    }
149
150    let mut sym_val = serde_json::to_value(symbol)?;
151    if !include_body {
152        if let Some(obj) = sym_val.as_object_mut() {
153            obj.remove("body");
154        }
155    }
156
157    // Plan G t-003: ownership discovery from git blame + doc-comment
158    // annotations. Skip the git subprocess spawns when the caller
159    // passes a pre-computed hint.
160    let ownership_signal_owned;
161    let ownership_signal = if let Some(hint) = ownership_hint {
162        hint
163    } else {
164        ownership_signal_owned = discover_symbol_ownership(
165            &symbol.file,
166            symbol.start.line,
167            symbol.end.line,
168            symbol.doc.as_deref(),
169        );
170        &ownership_signal_owned
171    };
172    let mut discovered_ownership: serde_json::Map<String, Value> = serde_json::Map::new();
173    if let Some(ref author) = ownership_signal.primary_author {
174        discovered_ownership.insert("primary_author".into(), json!(author));
175    }
176    if let Some(ref doc_owner) = ownership_signal.doc_owner {
177        discovered_ownership.insert("doc_owner".into(), json!(doc_owner));
178    }
179    if !ownership_signal.recent_committers.is_empty() {
180        discovered_ownership.insert(
181            "recent_committers".into(),
182            json!(ownership_signal.recent_committers),
183        );
184    }
185    if !ownership_signal.annotated.is_empty() {
186        let annotated_val: Vec<Value> = ownership_signal
187            .annotated
188            .iter()
189            .map(|a| {
190                json!({
191                    "name": a.name,
192                    "source": serde_json::to_value(a.source).unwrap_or(json!("unknown")),
193                })
194            })
195            .collect();
196        discovered_ownership.insert("annotated".into(), json!(annotated_val));
197    }
198
199    // Plan G t-003: covering tests for this impl symbol.
200    let covering_tests: Vec<Value> = find_covering_tests(fts, &symbol.qname)
201        .into_iter()
202        .map(|ct| {
203            json!({
204                "qname": ct.qname,
205                "file": ct.file,
206                "line": ct.line,
207                "run_command": ct.run_command,
208            })
209        })
210        .collect();
211
212    // Per-effect verification detail — cross-reference declared
213    // effects against verification mismatches so agents see
214    // ok / mismatch / unverified per effect.
215    let effects_detail: Vec<Value> = if let Some(ref decl) = effects {
216        let mismatch_effects: std::collections::HashSet<String> = decl
217            .verification
218            .as_ref()
219            .map(|v| {
220                v.mismatches
221                    .iter()
222                    .map(|m| m.effect.as_str().to_string())
223                    .collect()
224            })
225            .unwrap_or_default();
226        let overall_ok = decl
227            .verification
228            .as_ref()
229            .map(|v| matches!(v.status, VerificationStatus::Ok))
230            .unwrap_or(false);
231        decl.declared
232            .iter()
233            .map(|e| {
234                let effect_str = e.effect.as_str();
235                let is_mismatched = mismatch_effects.contains(effect_str);
236                let status = if decl.verification.is_none() {
237                    "unverified"
238                } else if is_mismatched {
239                    "mismatch"
240                } else if overall_ok {
241                    "ok"
242                } else {
243                    "ok"
244                };
245                let mut obj = serde_json::Map::new();
246                obj.insert("effect".into(), json!(effect_str));
247                obj.insert("status".into(), json!(status));
248                if let Some(ref adapter) = e.adapter {
249                    obj.insert("adapter".into(), json!(adapter));
250                }
251                if let Some(ref pattern) = e.source_pattern {
252                    obj.insert("source_pattern".into(), json!(pattern));
253                }
254                if let Some(note) = &e.note {
255                    obj.insert("note".into(), json!(note));
256                }
257                Value::Object(obj)
258            })
259            .collect()
260    } else {
261        Vec::new()
262    };
263
264    // Cross-service edges (t-002): if this symbol owns any HTTP/pub-sub
265    // endpoint, surface its endpoints and the matched edges so blast radius
266    // crosses the service boundary. Omitted entirely when the symbol has none
267    // (token economy). In-repo matches only; cross-repo is a Team-tier feature.
268    let cross_service = {
269        let tree = engine
270            .repo
271            .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
272            .unwrap_or(Value::Null);
273        let all = crate::cross_service::endpoints_from_tree(&tree);
274        let mine: Vec<&crate::cross_service::ServiceEndpoint> = all
275            .iter()
276            .filter(|e| e.symbol_id == symbol.symbol_id)
277            .collect();
278        if mine.is_empty() {
279            None
280        } else {
281            let edges = crate::cross_service::match_edges(&all);
282            let touching: Vec<Value> = edges
283                .iter()
284                .filter(|e| {
285                    e.from.symbol_id == symbol.symbol_id || e.to.symbol_id == symbol.symbol_id
286                })
287                .map(|e| {
288                    let is_handler = e.to.symbol_id == symbol.symbol_id;
289                    let peer = if is_handler { &e.from } else { &e.to };
290                    json!({
291                        "role": if is_handler { "handler" } else { "consumer" },
292                        "contract": e.contract,
293                        "peer_qname": peer.qname,
294                        "peer_repo": peer.repo_id,
295                        "cross_repo": e.cross_repo,
296                        "confidence": e.confidence,
297                    })
298                })
299                .collect();
300            Some(json!({
301                "endpoints": mine.iter().map(|e| json!({
302                    "transport": e.transport,
303                    "direction": e.direction,
304                    "contract": e.contract,
305                    "confidence": e.confidence,
306                })).collect::<Vec<_>>(),
307                "edges": touching,
308            }))
309        }
310    };
311
312    // Invariants and hazards are anti-footgun guards — surface them
313    // first so agents see them before the call-graph details.
314    let mut out = json!({
315        "symbol": sym_val,
316        "invariants": invariants,
317        "hazards": hazards,
318        "known_bugs": known_bugs,
319        "concepts": concepts,
320        "ownership": ownership,
321        "ownership_discovery": discovered_ownership,
322        "covering_tests": covering_tests,
323        "validation_scenarios": validation_scenarios,
324        "callers": resolve(&caller_ids),
325        "callees": callees_out,
326        "effects": effects,
327        "effects_detail": effects_detail,
328        "proofs": proofs,
329        "decisions_and_notes": other_ledger,
330    });
331    if let Some(cs) = cross_service {
332        out["cross_service"] = cs;
333    }
334
335    // Data-flow (t-002 slice 4): values flowing into this symbol's params
336    // (incoming) and out of its call args (outgoing). Omitted when empty.
337    let dataflow = {
338        let tree = engine
339            .repo
340            .get_tree(&engine.ref_name, "/asd/v1/index/dataflow")
341            .unwrap_or(Value::Null);
342        let all = crate::dataflow::edges_from_tree(&tree);
343        let incoming: Vec<Value> = all
344            .iter()
345            .filter(|e| e.to_symbol_id == symbol.symbol_id)
346            .map(|e| json!({ "param": e.param, "from_arg": e.arg, "from_qname": e.from_qname }))
347            .collect();
348        let outgoing: Vec<Value> = all
349            .iter()
350            .filter(|e| e.from_symbol_id == symbol.symbol_id)
351            .map(|e| json!({ "arg": e.arg, "to_param": e.param, "to_qname": e.to_qname }))
352            .collect();
353        if incoming.is_empty() && outgoing.is_empty() {
354            None
355        } else {
356            Some(json!({ "incoming": incoming, "outgoing": outgoing }))
357        }
358    };
359    if let Some(df) = dataflow {
360        out["dataflow"] = df;
361    }
362    // t-013: runtime-observed calls the static graph missed (omit when none).
363    if !runtime_missed_callees.is_empty() {
364        out["runtime_missed_callees"] = json!(runtime_missed_callees);
365    }
366    Ok(out)
367}