Skip to main content

lean_ctx_server/tools/
ctx_callgraph.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::call_graph::{CallEdge, CallGraph, CallGraphInputs, RiskLevel};
3use lean_ctx::core::index_paths;
4
5const MAX_BFS_DEPTH: usize = 5;
6
7pub fn handle(
8    action: &str,
9    symbol: Option<&str>,
10    file: Option<&str>,
11    project_root: &str,
12    depth: usize,
13    from: Option<&str>,
14    to: Option<&str>,
15) -> String {
16    match action {
17        "callers" | "callees" => {
18            let Some(sym) = symbol else {
19                return "symbol is required for callers/callees action".to_string();
20            };
21            with_handle_hint(handle_direction(sym, file, project_root, action, depth))
22        }
23        "trace" => with_handle_hint(handle_trace(from, to, project_root)),
24        "risk" => {
25            let Some(sym) = symbol else {
26                return "symbol is required for risk action".to_string();
27            };
28            handle_risk(sym, project_root)
29        }
30        _ => format!("Unknown action '{action}'. Use: callers|callees|trace|risk"),
31    }
32}
33
34fn load_graph(project_root: &str) -> CallGraph {
35    load_graph_with_inputs(project_root).0
36}
37
38fn load_graph_with_inputs(project_root: &str) -> (CallGraph, CallGraphInputs) {
39    let inputs = CallGraphInputs::open(project_root);
40    let graph = CallGraph::load_or_build(project_root, &inputs);
41    let _ = graph.save();
42    (graph, inputs)
43}
44
45/// Where each listed call goes, with the evidence behind it:
46/// `⇒ src/repo.rs [verified]`. Structurally uncertain edges are escalated to
47/// a semantic backend on the spot (cached; `semantic_mode` rules apply).
48/// Unknown callees (std, dependencies) get no annotation.
49fn target_annotations(
50    edges: &[&CallEdge],
51    graph: &CallGraph,
52    inputs: &CallGraphInputs,
53    project_root: &str,
54) -> Vec<String> {
55    use lean_ctx::core::call_graph::{StructuralTarget, resolve_edge_callee_targets};
56    use lean_ctx::core::semantic::{EvidenceGrade, SemanticVerdict, escalate_calls};
57
58    let owned: Vec<CallEdge> = edges.iter().map(|e| (*e).clone()).collect();
59    let structural = resolve_edge_callee_targets(inputs, &owned);
60    let store = lean_ctx::core::property_graph::CodeGraph::open(project_root).ok();
61    let verdicts = match &store {
62        Some(store) => {
63            let mode = lean_ctx::core::config::SemanticMode::for_project(project_root);
64            escalate_calls(
65                store,
66                project_root,
67                inputs,
68                &owned,
69                &structural,
70                &graph.file_hashes,
71                mode,
72                lean_ctx::core::semantic::EscalationBudget::INTERACTIVE,
73            )
74            .verdicts
75        }
76        None => vec![None; owned.len()],
77    };
78    // A target file is named only while the graph serves it now (G04); a
79    // call into a withdrawn file gets no annotation, like an unknown callee.
80    let served = |file: &str| store.as_ref().is_none_or(|store| store.serves_file(file));
81    structural
82        .iter()
83        .zip(verdicts)
84        .map(|(target, verdict)| {
85            let named = match (&verdict, target) {
86                (Some(SemanticVerdict::Verified { file, .. }), _)
87                | (None, StructuralTarget::Resolved { file, .. }) => Some(file.as_str()),
88                _ => None,
89            };
90            if named.is_some_and(|file| !served(file)) {
91                return String::new();
92            }
93            match (verdict, target) {
94                (Some(SemanticVerdict::Verified { file, .. }), _) => {
95                    format!("  ⇒ {file} [verified]")
96                }
97                (Some(SemanticVerdict::NotInProject), _) => "  ⇒ external [verified]".to_string(),
98                (None, StructuralTarget::Resolved { file, via }) => {
99                    format!("  ⇒ {file} [{}]", EvidenceGrade::from_scope(*via).as_str())
100                }
101                (None, StructuralTarget::Ambiguous) => "  ⇒ ? [ambiguous]".to_string(),
102                (None, StructuralTarget::Unknown) => String::new(),
103            }
104        })
105        .collect()
106}
107
108fn handle_direction(
109    symbol: &str,
110    file: Option<&str>,
111    project_root: &str,
112    direction: &str,
113    depth: usize,
114) -> String {
115    let clamped_depth = depth.clamp(1, MAX_BFS_DEPTH);
116
117    if clamped_depth == 1 {
118        let (graph, inputs) = load_graph_with_inputs(project_root);
119        let filter = file.map(|f| graph_file_filter(f, project_root));
120        let annotate =
121            |edges: &[&CallEdge]| target_annotations(edges, &graph, &inputs, project_root);
122        match direction {
123            "callers" => format_callers(symbol, &graph, filter.as_deref(), annotate),
124            "callees" => format_callees(symbol, &graph, filter.as_deref(), annotate),
125            _ => unreachable!(),
126        }
127    } else {
128        let graph = load_graph(project_root);
129        let filter = file.map(|f| graph_file_filter(f, project_root));
130        match direction {
131            "callers" => format_bfs_callers(symbol, &graph, clamped_depth, filter.as_deref()),
132            "callees" => format_bfs_callees(symbol, &graph, clamped_depth, filter.as_deref()),
133            _ => unreachable!(),
134        }
135    }
136}
137
138fn handle_trace(from: Option<&str>, to: Option<&str>, project_root: &str) -> String {
139    let Some(from_sym) = from else {
140        return "'from' is required for trace action".to_string();
141    };
142    let Some(to_sym) = to else {
143        return "'to' is required for trace action".to_string();
144    };
145
146    let graph = load_graph(project_root);
147
148    match graph.find_call_path(from_sym, to_sym) {
149        Some(hops) => {
150            let mut out = format!("Call path ({} hop(s)):\n", hops.len() - 1);
151            for (i, hop) in hops.iter().enumerate() {
152                let loc = if hop.file.is_empty() {
153                    String::new()
154                } else {
155                    format!("  ({}:L{})", hop.file, hop.line)
156                };
157                if i == 0 {
158                    out.push_str(&format!("  {}{loc}\n", hop.symbol));
159                } else {
160                    out.push_str(&format!("  → {}{loc}\n", hop.symbol));
161                }
162            }
163            out
164        }
165        None => {
166            format!("No call path found from '{from_sym}' to '{to_sym}' (searched up to depth 10)")
167        }
168    }
169}
170
171fn handle_risk(symbol: &str, project_root: &str) -> String {
172    let graph = load_graph(project_root);
173    let count = graph.transitive_caller_count(symbol, MAX_BFS_DEPTH);
174    let level = RiskLevel::from_caller_count(count);
175    let direct = graph.callers_of(symbol).len();
176
177    let mut out = format!(
178        "Risk: {} — {} transitive caller(s) of '{}' (depth≤{}, {} direct)\n\
179         Thresholds: CRITICAL >10 | HIGH 5–10 | MEDIUM 2–4 | LOW 0–1",
180        level.label(),
181        count,
182        symbol,
183        MAX_BFS_DEPTH,
184        direct,
185    );
186
187    // Fold in the code-health dimension (#1084): a symbol that is both
188    // widely-called AND cognitively complex is the highest-leverage refactor.
189    // Sourced from the persisted health fabric (best-effort, no parsing).
190    if let Some(cc) = lean_ctx::core::code_health::fabric::hotspot_cc(project_root, symbol) {
191        out.push_str(&format!(
192            "\nComplexity: cc={cc} (over navigability threshold) — high blast-radius and hard to read."
193        ));
194    }
195    out
196}
197
198// ---------------------------------------------------------------------------
199// Single-hop formatters (existing behavior)
200// ---------------------------------------------------------------------------
201
202fn format_callers(
203    symbol: &str,
204    graph: &CallGraph,
205    filter: Option<&str>,
206    annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
207) -> String {
208    let mut callers = graph.callers_of(symbol);
209    if let Some(f) = filter {
210        callers.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
211    }
212    // Several calls from one function render as the same line; show it once.
213    let mut seen = std::collections::HashSet::new();
214    callers.retain(|e| seen.insert((&e.caller_file, &e.caller_symbol, e.caller_line)));
215
216    if callers.is_empty() {
217        return format!(
218            "No callers found for '{}' ({} edges in graph)",
219            symbol,
220            graph.edges.len()
221        );
222    }
223
224    let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
225    for (edge, target) in callers.iter().zip(annotate(&callers)) {
226        out.push_str(&format!(
227            "  {} → {}  (L{}){target}\n",
228            edge.caller_file, edge.caller_symbol, edge.caller_line
229        ));
230    }
231    out
232}
233
234fn format_callees(
235    symbol: &str,
236    graph: &CallGraph,
237    filter: Option<&str>,
238    annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
239) -> String {
240    let mut callees = graph.callees_of(symbol);
241    if let Some(f) = filter {
242        callees.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
243    }
244    let mut seen = std::collections::HashSet::new();
245    callees.retain(|e| seen.insert((&e.callee_name, &e.caller_file, e.caller_line)));
246
247    if callees.is_empty() {
248        return format!(
249            "No callees found for '{}' ({} edges in graph)",
250            symbol,
251            graph.edges.len()
252        );
253    }
254
255    let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
256    for (edge, target) in callees.iter().zip(annotate(&callees)) {
257        out.push_str(&format!(
258            "  → {}  ({}:L{}){target}\n",
259            edge.callee_name, edge.caller_file, edge.caller_line
260        ));
261    }
262    out
263}
264
265// ---------------------------------------------------------------------------
266// Multi-hop BFS formatters
267// ---------------------------------------------------------------------------
268
269fn format_bfs_callers(
270    symbol: &str,
271    graph: &CallGraph,
272    depth: usize,
273    filter: Option<&str>,
274) -> String {
275    let mut nodes = graph.bfs_callers(symbol, depth);
276    if let Some(f) = filter {
277        nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
278    }
279
280    if nodes.is_empty() {
281        return format!(
282            "No callers found for '{}' (depth≤{}, {} edges in graph)",
283            symbol,
284            depth,
285            graph.edges.len()
286        );
287    }
288
289    let mut out = format!(
290        "{} caller(s) of '{}' (depth≤{}):\n",
291        nodes.len(),
292        symbol,
293        depth
294    );
295    for node in &nodes {
296        let indent = "  ".repeat(node.depth);
297        out.push_str(&format!(
298            "{indent}{} ← {}  ({}:L{})\n",
299            node.from_symbol, node.symbol, node.file, node.line
300        ));
301    }
302    out
303}
304
305fn format_bfs_callees(
306    symbol: &str,
307    graph: &CallGraph,
308    depth: usize,
309    filter: Option<&str>,
310) -> String {
311    let mut nodes = graph.bfs_callees(symbol, depth);
312    if let Some(f) = filter {
313        nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
314    }
315
316    if nodes.is_empty() {
317        return format!(
318            "No callees found for '{}' (depth≤{}, {} edges in graph)",
319            symbol,
320            depth,
321            graph.edges.len()
322        );
323    }
324
325    let mut out = format!(
326        "{} callee(s) of '{}' (depth≤{}):\n",
327        nodes.len(),
328        symbol,
329        depth
330    );
331    for node in &nodes {
332        let indent = "  ".repeat(node.depth);
333        out.push_str(&format!(
334            "{indent}{} → {}  ({}:L{})\n",
335            node.from_symbol, node.symbol, node.file, node.line
336        ));
337    }
338    out
339}
340
341/// Append the stable-handle usage hint (#607) to a non-empty result so the
342/// agent can re-target any listed symbol via `ctx_search(action="symbol",
343/// handle=…)`. Skips error/empty messages (which start with "No ") so failures
344/// stay clean.
345fn with_handle_hint(out: String) -> String {
346    if out.starts_with("No ") || out.trim().is_empty() {
347        out
348    } else {
349        format!("{out}{}\n", lean_ctx::core::handle::USAGE_HINT)
350    }
351}
352
353fn graph_file_filter(file: &str, project_root: &str) -> String {
354    let rel = index_paths::graph_relative_key(file, project_root);
355    let rel_key = index_paths::graph_match_key(&rel);
356    if rel_key.is_empty() {
357        index_paths::graph_match_key(file)
358    } else {
359        rel_key
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::graph_file_filter;
366
367    #[test]
368    fn graph_file_filter_normalizes_windows_styles() {
369        let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
370        let expected = if cfg!(windows) {
371            "src/main/kotlin/Example.kt"
372        } else {
373            "C:/repo/src/main/kotlin/Example.kt"
374        };
375        assert_eq!(filter, expected);
376    }
377
378    #[test]
379    fn invalid_action_returns_helpful_error() {
380        let output = super::handle("unknown", Some("foo"), None, "/tmp", 1, None, None);
381        assert!(output.contains("Unknown action"));
382        assert!(output.contains("callers|callees|trace|risk"));
383    }
384
385    #[test]
386    fn callers_action_without_symbol_returns_error() {
387        let output = super::handle("callers", None, None, "/tmp", 1, None, None);
388        assert!(output.contains("symbol is required"));
389    }
390
391    #[test]
392    fn trace_without_from_returns_error() {
393        let output = super::handle("trace", None, None, "/tmp", 1, None, Some("b"));
394        assert!(output.contains("'from' is required"));
395    }
396
397    #[test]
398    fn trace_without_to_returns_error() {
399        let output = super::handle("trace", None, None, "/tmp", 1, Some("a"), None);
400        assert!(output.contains("'to' is required"));
401    }
402
403    #[test]
404    fn risk_without_symbol_returns_error() {
405        let output = super::handle("risk", None, None, "/tmp", 1, None, None);
406        assert!(output.contains("symbol is required"));
407    }
408
409    #[test]
410    fn two_calls_from_one_function_render_one_caller_line() {
411        use lean_ctx::core::call_graph::{CallEdge, CallGraph};
412        let mut graph = CallGraph::new("/p");
413        for _ in 0..2 {
414            graph.edges.push(CallEdge {
415                caller_file: "vat.py".to_string(),
416                caller_symbol: "gross_from_net".to_string(),
417                caller_line: 16,
418                callee_name: "to_amount".to_string(),
419                ..Default::default()
420            });
421        }
422        let out = super::format_callers("to_amount", &graph, None, |edges| {
423            vec![String::new(); edges.len()]
424        });
425        assert_eq!(out.matches("gross_from_net").count(), 1, "{out}");
426        assert!(out.starts_with("1 caller(s)"), "{out}");
427    }
428
429    #[test]
430    fn handle_hint_appended_to_results_only() {
431        use super::with_handle_hint;
432        let ok = with_handle_hint("1 caller(s) of 'x':\n  a → b  (L1)\n".to_string());
433        assert!(
434            ok.contains("handle=\""),
435            "success output gets the hint: {ok}"
436        );
437        let empty = with_handle_hint("No callers found for 'x' (0 edges in graph)".to_string());
438        assert!(
439            !empty.contains("handle=\""),
440            "empty result stays clean: {empty}"
441        );
442    }
443}