Skip to main content

lean_ctx/tools/
ctx_graph_diff.rs

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