Skip to main content

lean_ctx_server/tools/
ctx_graph_diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_graph action=diff` — what changed since a git ref, crossed with the
3//! dependency graph. For every changed file we report its **blast radius** (the
4//! transitive set of files that depend on it) and flag changes that touch
5//! god-nodes or bridges, so a reviewer immediately sees which commits are
6//! structurally risky. graphify-style "graph diff", grounded in real git data.
7
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::Path;
10use std::time::Duration;
11
12use lean_ctx::core::graph_analysis::dependency_edges;
13use lean_ctx::core::graph_index;
14use lean_ctx::core::graph_provider::{self, EdgeInfo};
15use lean_ctx::core::protocol::shorten_path;
16use lean_ctx::core::tokens::count_tokens;
17
18/// Reverse dependency index: `file → files that (transitively) depend on it`.
19struct BlastIndex {
20    rev: HashMap<String, Vec<String>>,
21}
22
23impl BlastIndex {
24    fn build(edges: &[EdgeInfo]) -> Self {
25        let mut rev: HashMap<String, Vec<String>> = HashMap::new();
26        for (from, to) in dependency_edges(edges) {
27            rev.entry(to.to_string())
28                .or_default()
29                .push(from.to_string());
30        }
31        Self { rev }
32    }
33
34    /// Number of files transitively depending on `start` (BFS, `start` excluded),
35    /// bounded by `cap` to stay cheap on pathological graphs.
36    fn transitive_dependents(&self, start: &str, cap: usize) -> usize {
37        let mut visited: HashSet<&str> = HashSet::new();
38        let mut queue: VecDeque<&str> = VecDeque::new();
39        visited.insert(start);
40        queue.push_back(start);
41        while let Some(cur) = queue.pop_front() {
42            if visited.len() > cap {
43                break;
44            }
45            if let Some(deps) = self.rev.get(cur) {
46                for d in deps {
47                    if visited.insert(d.as_str()) {
48                        queue.push_back(d.as_str());
49                    }
50                }
51            }
52        }
53        visited.len().saturating_sub(1)
54    }
55
56    fn direct_dependents(&self, file: &str) -> usize {
57        self.rev.get(file).map_or(0, Vec::len)
58    }
59}
60
61/// One changed file with its graph impact.
62struct DiffEntry {
63    status: char,
64    path: String,
65    in_graph: bool,
66    direct: usize,
67    blast: usize,
68    is_god: bool,
69    is_bridge: bool,
70}
71
72/// Parse `git diff --name-status` output into `(status_char, repo_path)` pairs.
73/// Renames/copies (`R…`, `C…`) are attributed to their destination path.
74fn parse_name_status(raw: &str) -> Vec<(char, String)> {
75    let mut out = Vec::new();
76    for line in raw.lines() {
77        let mut cols = line.split('\t');
78        let Some(status) = cols.next() else { continue };
79        let code = status.chars().next().unwrap_or('?');
80        let path = if matches!(code, 'R' | 'C') {
81            // "Rxxx\told\tnew" — take the new path.
82            cols.nth(1)
83        } else {
84            cols.next()
85        };
86        if let Some(p) = path.map(str::trim).filter(|p| !p.is_empty()) {
87            out.push((code, p.to_string()));
88        }
89    }
90    out
91}
92
93pub fn diff(since: Option<&str>, root: &str, format: Option<&str>) -> String {
94    let base = since
95        .map(str::trim)
96        .filter(|s| !s.is_empty())
97        .unwrap_or("HEAD~1");
98
99    if !lean_ctx::core::git::git_available() {
100        return "git is not available — cannot compute a graph diff.".to_string();
101    }
102    let root_path = Path::new(root);
103
104    let name_status = match lean_ctx::core::git::run_git(
105        &["diff", "--name-status", &format!("{base}..HEAD")],
106        root_path,
107        Duration::from_secs(30),
108        &[],
109    ) {
110        Ok(o) if o.success => o.stdout,
111        Ok(o) => {
112            let msg = o.stderr.trim();
113            return format!(
114                "git diff failed: {}\nIs '{base}' a valid commit/ref reachable from HEAD?",
115                if msg.is_empty() { "unknown error" } else { msg }
116            );
117        }
118        Err(e) => return format!("git diff failed: {e}"),
119    };
120
121    let changes = parse_name_status(&name_status);
122    if changes.is_empty() {
123        return format!("No file changes between {base} and HEAD.");
124    }
125
126    let Some(open) = graph_provider::open_or_build(root) else {
127        return "No graph index found. Run ctx_graph with action='build' first.".to_string();
128    };
129    let gp = &open.provider;
130    let edges = gp.edges();
131    let blast = BlastIndex::build(&edges);
132    let node_set: HashSet<String> = gp.file_paths().into_iter().collect();
133    let god: HashSet<String> = lean_ctx::core::graph_analysis::compute_god_nodes(&edges, 25)
134        .into_iter()
135        .map(|g| g.path)
136        .collect();
137    let bridge: HashSet<String> = lean_ctx::core::graph_analysis::compute_bridge_nodes(&edges, 25)
138        .into_iter()
139        .map(|b| b.path)
140        .collect();
141
142    let mut entries: Vec<DiffEntry> = changes
143        .into_iter()
144        .map(|(status, path)| {
145            let key = graph_index::graph_match_key(&path);
146            let in_graph = node_set.contains(&key);
147            let (direct, blast_n) = if in_graph {
148                (
149                    blast.direct_dependents(&key),
150                    blast.transitive_dependents(&key, 5000),
151                )
152            } else {
153                (0, 0)
154            };
155            DiffEntry {
156                status,
157                in_graph,
158                direct,
159                blast: blast_n,
160                is_god: god.contains(&key),
161                is_bridge: bridge.contains(&key),
162                path: key,
163            }
164        })
165        .collect();
166
167    entries.sort_by(|a, b| {
168        b.blast
169            .cmp(&a.blast)
170            .then_with(|| b.direct.cmp(&a.direct))
171            .then_with(|| a.path.cmp(&b.path))
172    });
173
174    if matches!(format, Some(f) if f.eq_ignore_ascii_case("json")) {
175        return render_json(base, &entries);
176    }
177    render_text(base, &entries)
178}
179
180fn counts(entries: &[DiffEntry]) -> (usize, usize, usize, usize, usize) {
181    let mut added = 0;
182    let mut modified = 0;
183    let mut deleted = 0;
184    let mut renamed = 0;
185    for e in entries {
186        match e.status {
187            'A' => added += 1,
188            'M' => modified += 1,
189            'D' => deleted += 1,
190            'R' | 'C' => renamed += 1,
191            _ => {}
192        }
193    }
194    let in_graph = entries.iter().filter(|e| e.in_graph).count();
195    (added, modified, deleted, renamed, in_graph)
196}
197
198fn render_text(base: &str, entries: &[DiffEntry]) -> String {
199    let (added, modified, deleted, renamed, in_graph) = counts(entries);
200    let mut out = format!("Graph diff: {base}..HEAD\n");
201    out.push_str(&format!(
202        "{} files changed (A:{added} M:{modified} D:{deleted} R:{renamed}) · {in_graph} in graph\n",
203        entries.len()
204    ));
205
206    let high: Vec<&DiffEntry> = entries
207        .iter()
208        .filter(|e| e.in_graph && (e.blast > 0 || e.is_god || e.is_bridge))
209        .collect();
210    if !high.is_empty() {
211        out.push_str("\nHigh-impact changes (by blast radius):\n");
212        for e in &high {
213            out.push_str(&format!(
214                "  [{}] {:<46} blast {:<5} direct {}{}\n",
215                e.status,
216                shorten_path(&e.path),
217                e.blast,
218                e.direct,
219                flags(e)
220            ));
221        }
222    }
223
224    let low: Vec<&DiffEntry> = entries
225        .iter()
226        .filter(|e| e.in_graph && e.blast == 0 && !e.is_god && !e.is_bridge)
227        .collect();
228    if !low.is_empty() {
229        out.push_str("\nOther changed files in graph (no known dependents):\n");
230        for e in low.iter().take(40) {
231            out.push_str(&format!("  [{}] {}\n", e.status, shorten_path(&e.path)));
232        }
233        if low.len() > 40 {
234            out.push_str(&format!("  … and {} more\n", low.len() - 40));
235        }
236    }
237
238    let off: Vec<&DiffEntry> = entries.iter().filter(|e| !e.in_graph).collect();
239    if !off.is_empty() {
240        out.push_str(&format!(
241            "\nChanged but not in graph ({}, e.g. docs/config/assets):\n",
242            off.len()
243        ));
244        for e in off.iter().take(20) {
245            out.push_str(&format!("  [{}] {}\n", e.status, shorten_path(&e.path)));
246        }
247        if off.len() > 20 {
248            out.push_str(&format!("  … and {} more\n", off.len() - 20));
249        }
250    }
251
252    let tokens = count_tokens(&out);
253    format!("{out}[ctx_graph diff: {tokens} tok]")
254}
255
256fn flags(e: &DiffEntry) -> String {
257    let mut tags = Vec::new();
258    if e.is_god {
259        tags.push("god-node");
260    }
261    if e.is_bridge {
262        tags.push("bridge");
263    }
264    if tags.is_empty() {
265        String::new()
266    } else {
267        format!("  ({})", tags.join(", "))
268    }
269}
270
271fn render_json(base: &str, entries: &[DiffEntry]) -> String {
272    let (added, modified, deleted, renamed, in_graph) = counts(entries);
273    let items: Vec<_> = entries
274        .iter()
275        .map(|e| {
276            serde_json::json!({
277                "status": e.status.to_string(),
278                "path": e.path,
279                "in_graph": e.in_graph,
280                "direct_dependents": e.direct,
281                "blast_radius": e.blast,
282                "is_god_node": e.is_god,
283                "is_bridge": e.is_bridge,
284            })
285        })
286        .collect();
287    let val = serde_json::json!({
288        "base": base,
289        "summary": {
290            "total": entries.len(),
291            "added": added,
292            "modified": modified,
293            "deleted": deleted,
294            "renamed": renamed,
295            "in_graph": in_graph,
296        },
297        "changes": items,
298    });
299    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn parses_simple_statuses() {
308        let raw = "M\tsrc/a.rs\nA\tsrc/b.rs\nD\tsrc/c.rs\n";
309        let parsed = parse_name_status(raw);
310        assert_eq!(
311            parsed,
312            vec![
313                ('M', "src/a.rs".to_string()),
314                ('A', "src/b.rs".to_string()),
315                ('D', "src/c.rs".to_string()),
316            ]
317        );
318    }
319
320    #[test]
321    fn rename_uses_destination_path() {
322        let raw = "R096\tsrc/old.rs\tsrc/new.rs\n";
323        let parsed = parse_name_status(raw);
324        assert_eq!(parsed, vec![('R', "src/new.rs".to_string())]);
325    }
326
327    #[test]
328    fn ignores_blank_lines() {
329        {
330            let found = &parse_name_status("\n\n");
331            assert!(found.is_empty(), "{found:?}");
332        }
333    }
334
335    #[test]
336    fn blast_radius_is_transitive() {
337        // a -> b -> c (a imports b, b imports c). c's dependents = {a, b}.
338        let edges = vec![
339            EdgeInfo {
340                from: "a.rs".into(),
341                to: "b.rs".into(),
342                kind: "import".into(),
343                weight: 1.0,
344            },
345            EdgeInfo {
346                from: "b.rs".into(),
347                to: "c.rs".into(),
348                kind: "import".into(),
349                weight: 1.0,
350            },
351        ];
352        let idx = BlastIndex::build(&edges);
353        assert_eq!(idx.transitive_dependents("c.rs", 100), 2);
354        assert_eq!(idx.direct_dependents("c.rs"), 1);
355        assert_eq!(idx.transitive_dependents("a.rs", 100), 0);
356    }
357
358    #[test]
359    fn blast_radius_ignores_heuristic_edges() {
360        // sibling is a co-location heuristic, not a dependency.
361        let edges = vec![EdgeInfo {
362            from: "a.rs".into(),
363            to: "b.rs".into(),
364            kind: "sibling".into(),
365            weight: 1.0,
366        }];
367        let idx = BlastIndex::build(&edges);
368        assert_eq!(idx.transitive_dependents("b.rs", 100), 0);
369    }
370}