Skip to main content

graphy_core/
diff.rs

1//! Graph diffing for CI/CD integration — Breaking Change Guardian.
2//!
3//! Compares two CodeGraphs and produces a structured diff including
4//! breaking changes, complexity changes, and new dead code.
5
6use std::collections::HashMap;
7
8use serde::Serialize;
9
10use crate::gir::{ComplexityMetrics, NodeKind, Visibility};
11use crate::graph::CodeGraph;
12use crate::symbol_id::SymbolId;
13
14/// A diff entry representing an added or removed symbol.
15#[derive(Debug, Clone, Serialize)]
16pub struct DiffEntry {
17    pub name: String,
18    pub kind: NodeKind,
19    pub file_path: String,
20    pub line: u32,
21    pub visibility: Visibility,
22}
23
24/// A symbol that exists in both graphs but has changed.
25#[derive(Debug, Clone, Serialize)]
26pub struct ChangedSymbol {
27    pub name: String,
28    pub kind: NodeKind,
29    pub file_path: String,
30    pub line: u32,
31    pub changes: Vec<ChangeDetail>,
32}
33
34/// What specifically changed about a symbol.
35#[derive(Debug, Clone, Serialize)]
36pub enum ChangeDetail {
37    SignatureChanged {
38        old: Option<String>,
39        new: Option<String>,
40    },
41    VisibilityChanged {
42        old: Visibility,
43        new: Visibility,
44    },
45    Moved {
46        old_file: String,
47        old_line: u32,
48        new_file: String,
49        new_line: u32,
50    },
51}
52
53/// A breaking change detected between two graph versions.
54#[derive(Debug, Clone, Serialize)]
55pub struct BreakingChange {
56    pub severity: Severity,
57    pub description: String,
58    pub symbol_name: String,
59    pub kind: NodeKind,
60    pub file_path: String,
61    pub line: u32,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65pub enum Severity {
66    Error,
67    Warning,
68}
69
70/// A change in complexity metrics between versions.
71#[derive(Debug, Clone, Serialize)]
72pub struct ComplexityChange {
73    pub name: String,
74    pub file_path: String,
75    pub line: u32,
76    pub old: ComplexityMetrics,
77    pub new: ComplexityMetrics,
78    pub cyclomatic_delta: i32,
79    pub cognitive_delta: i32,
80}
81
82/// The full diff between two graph versions.
83#[derive(Debug, Clone, Serialize)]
84pub struct GraphDiff {
85    pub removed_symbols: Vec<DiffEntry>,
86    pub added_symbols: Vec<DiffEntry>,
87    pub changed_symbols: Vec<ChangedSymbol>,
88    pub breaking_changes: Vec<BreakingChange>,
89    pub complexity_changes: Vec<ComplexityChange>,
90    pub new_dead_code: Vec<DiffEntry>,
91}
92
93/// Compare two CodeGraphs and produce a structured diff.
94pub fn diff_graphs(base: &CodeGraph, head: &CodeGraph) -> GraphDiff {
95    // Build lookup maps by SymbolId
96    let base_map: HashMap<SymbolId, _> = base.all_nodes().map(|n| (n.id, n)).collect();
97    let head_map: HashMap<SymbolId, _> = head.all_nodes().map(|n| (n.id, n)).collect();
98
99    // Secondary index for fuzzy matching moved/renamed symbols: (name, kind) -> node
100    let base_by_name_kind: HashMap<(&str, NodeKind), Vec<_>> = {
101        let mut m: HashMap<(&str, NodeKind), Vec<_>> = HashMap::new();
102        for n in base.all_nodes() {
103            m.entry((&n.name, n.kind)).or_default().push(n);
104        }
105        m
106    };
107
108    let mut removed_symbols = Vec::new();
109    let mut added_symbols = Vec::new();
110    let mut changed_symbols = Vec::new();
111    let mut breaking_changes = Vec::new();
112    let mut complexity_changes = Vec::new();
113
114    // Find removed symbols (in base but not in head)
115    for (id, node) in &base_map {
116        if !head_map.contains_key(id) {
117            // Check if it was moved/renamed (same name+kind exists in head)
118            let moved = head
119                .find_by_name(&node.name)
120                .iter()
121                .any(|h| h.kind == node.kind && !base_map.contains_key(&h.id));
122
123            if !moved {
124                removed_symbols.push(DiffEntry {
125                    name: node.name.clone(),
126                    kind: node.kind,
127                    file_path: node.file_path.to_string_lossy().into(),
128                    line: node.span.start_line,
129                    visibility: node.visibility,
130                });
131
132                // Check if it's a breaking change (public API removal)
133                if is_public_api(node.visibility) && is_api_relevant_kind(node.kind) {
134                    breaking_changes.push(BreakingChange {
135                        severity: Severity::Error,
136                        description: format!(
137                            "Removed public {:?} `{}`",
138                            node.kind, node.name
139                        ),
140                        symbol_name: node.name.clone(),
141                        kind: node.kind,
142                        file_path: node.file_path.to_string_lossy().into(),
143                        line: node.span.start_line,
144                    });
145                }
146            }
147        }
148    }
149
150    // Find added symbols (in head but not in base)
151    for (id, node) in &head_map {
152        if !base_map.contains_key(id) {
153            let moved = base_by_name_kind
154                .get(&(node.name.as_str(), node.kind))
155                .map_or(false, |base_nodes| {
156                    base_nodes.iter().any(|b| !head_map.contains_key(&b.id))
157                });
158
159            if !moved {
160                added_symbols.push(DiffEntry {
161                    name: node.name.clone(),
162                    kind: node.kind,
163                    file_path: node.file_path.to_string_lossy().into(),
164                    line: node.span.start_line,
165                    visibility: node.visibility,
166                });
167            }
168        }
169    }
170
171    // Find changed symbols (same SymbolId in both graphs)
172    for (id, base_node) in &base_map {
173        if let Some(head_node) = head_map.get(id) {
174            let mut changes = Vec::new();
175
176            // Check signature changes
177            if base_node.signature != head_node.signature {
178                changes.push(ChangeDetail::SignatureChanged {
179                    old: base_node.signature.clone(),
180                    new: head_node.signature.clone(),
181                });
182
183                if is_public_api(base_node.visibility) && is_api_relevant_kind(base_node.kind) {
184                    breaking_changes.push(BreakingChange {
185                        severity: Severity::Warning,
186                        description: format!(
187                            "Signature changed for public {:?} `{}`",
188                            base_node.kind, base_node.name
189                        ),
190                        symbol_name: base_node.name.clone(),
191                        kind: base_node.kind,
192                        file_path: head_node.file_path.to_string_lossy().into(),
193                        line: head_node.span.start_line,
194                    });
195                }
196            }
197
198            // Check visibility narrowing
199            if base_node.visibility != head_node.visibility {
200                changes.push(ChangeDetail::VisibilityChanged {
201                    old: base_node.visibility,
202                    new: head_node.visibility,
203                });
204
205                if is_public_api(base_node.visibility) && !is_public_api(head_node.visibility) {
206                    breaking_changes.push(BreakingChange {
207                        severity: Severity::Error,
208                        description: format!(
209                            "Visibility narrowed for `{}`: {:?} -> {:?}",
210                            base_node.name, base_node.visibility, head_node.visibility
211                        ),
212                        symbol_name: base_node.name.clone(),
213                        kind: base_node.kind,
214                        file_path: head_node.file_path.to_string_lossy().into(),
215                        line: head_node.span.start_line,
216                    });
217                }
218            }
219
220            // Check file/line moves
221            if base_node.file_path != head_node.file_path
222                || base_node.span.start_line != head_node.span.start_line
223            {
224                changes.push(ChangeDetail::Moved {
225                    old_file: base_node.file_path.to_string_lossy().into(),
226                    old_line: base_node.span.start_line,
227                    new_file: head_node.file_path.to_string_lossy().into(),
228                    new_line: head_node.span.start_line,
229                });
230            }
231
232            // Complexity changes
233            if let (Some(old_cx), Some(new_cx)) =
234                (&base_node.complexity, &head_node.complexity)
235            {
236                let cyc_delta = new_cx.cyclomatic as i32 - old_cx.cyclomatic as i32;
237                let cog_delta = new_cx.cognitive as i32 - old_cx.cognitive as i32;
238
239                if cyc_delta != 0 || cog_delta != 0 {
240                    complexity_changes.push(ComplexityChange {
241                        name: head_node.name.clone(),
242                        file_path: head_node.file_path.to_string_lossy().into(),
243                        line: head_node.span.start_line,
244                        old: *old_cx,
245                        new: *new_cx,
246                        cyclomatic_delta: cyc_delta,
247                        cognitive_delta: cog_delta,
248                    });
249                }
250            }
251
252            if !changes.is_empty() {
253                changed_symbols.push(ChangedSymbol {
254                    name: head_node.name.clone(),
255                    kind: head_node.kind,
256                    file_path: head_node.file_path.to_string_lossy().into(),
257                    line: head_node.span.start_line,
258                    changes,
259                });
260            }
261        }
262    }
263
264    // Detect new dead code in head (callable symbols with no callers that weren't in base)
265    let new_dead_code: Vec<DiffEntry> = head
266        .all_nodes()
267        .filter(|n| n.kind.is_callable())
268        .filter(|n| !base_map.contains_key(&n.id))
269        .filter(|n| {
270            head.callers(n.id).is_empty()
271                && n.name != "main"
272                && n.name != "__init__"
273                && !n.name.starts_with("test_")
274        })
275        .map(|n| DiffEntry {
276            name: n.name.clone(),
277            kind: n.kind,
278            file_path: n.file_path.to_string_lossy().into(),
279            line: n.span.start_line,
280            visibility: n.visibility,
281        })
282        .collect();
283
284    GraphDiff {
285        removed_symbols,
286        added_symbols,
287        changed_symbols,
288        breaking_changes,
289        complexity_changes,
290        new_dead_code,
291    }
292}
293
294/// Format the diff as human-readable text for CLI output.
295pub fn format_diff_text(diff: &GraphDiff) -> String {
296    let mut out = String::new();
297
298    // Breaking changes first (most important)
299    if !diff.breaking_changes.is_empty() {
300        out.push_str(&format!(
301            "BREAKING CHANGES ({}):\n",
302            diff.breaking_changes.len()
303        ));
304        for bc in &diff.breaking_changes {
305            let icon = match bc.severity {
306                Severity::Error => "ERROR",
307                Severity::Warning => "WARN ",
308            };
309            out.push_str(&format!(
310                "  [{}] {} ({}:{})\n",
311                icon, bc.description, bc.file_path, bc.line
312            ));
313        }
314        out.push('\n');
315    }
316
317    // Summary
318    out.push_str(&format!(
319        "Summary: +{} added, -{} removed, ~{} changed\n",
320        diff.added_symbols.len(),
321        diff.removed_symbols.len(),
322        diff.changed_symbols.len(),
323    ));
324
325    if !diff.added_symbols.is_empty() {
326        out.push_str(&format!("\nAdded ({}):\n", diff.added_symbols.len()));
327        for s in &diff.added_symbols {
328            out.push_str(&format!(
329                "  + {:?} {} ({}:{})\n",
330                s.kind, s.name, s.file_path, s.line
331            ));
332        }
333    }
334
335    if !diff.removed_symbols.is_empty() {
336        out.push_str(&format!("\nRemoved ({}):\n", diff.removed_symbols.len()));
337        for s in &diff.removed_symbols {
338            out.push_str(&format!(
339                "  - {:?} {} ({}:{})\n",
340                s.kind, s.name, s.file_path, s.line
341            ));
342        }
343    }
344
345    if !diff.changed_symbols.is_empty() {
346        out.push_str(&format!("\nChanged ({}):\n", diff.changed_symbols.len()));
347        for s in &diff.changed_symbols {
348            out.push_str(&format!("  ~ {:?} {} ({}:{})\n", s.kind, s.name, s.file_path, s.line));
349            for change in &s.changes {
350                match change {
351                    ChangeDetail::SignatureChanged { old, new } => {
352                        out.push_str(&format!(
353                            "      signature: {} -> {}\n",
354                            old.as_deref().unwrap_or("(none)"),
355                            new.as_deref().unwrap_or("(none)")
356                        ));
357                    }
358                    ChangeDetail::VisibilityChanged { old, new } => {
359                        out.push_str(&format!("      visibility: {:?} -> {:?}\n", old, new));
360                    }
361                    ChangeDetail::Moved {
362                        old_file,
363                        old_line,
364                        new_file,
365                        new_line,
366                    } => {
367                        out.push_str(&format!(
368                            "      moved: {}:{} -> {}:{}\n",
369                            old_file, old_line, new_file, new_line
370                        ));
371                    }
372                }
373            }
374        }
375    }
376
377    if !diff.complexity_changes.is_empty() {
378        out.push_str(&format!(
379            "\nComplexity changes ({}):\n",
380            diff.complexity_changes.len()
381        ));
382        for c in &diff.complexity_changes {
383            let cyc_arrow = if c.cyclomatic_delta > 0 { "+" } else { "" };
384            let cog_arrow = if c.cognitive_delta > 0 { "+" } else { "" };
385            out.push_str(&format!(
386                "  {} ({}:{}): cyclomatic {}{}, cognitive {}{}\n",
387                c.name, c.file_path, c.line, cyc_arrow, c.cyclomatic_delta, cog_arrow, c.cognitive_delta
388            ));
389        }
390    }
391
392    if !diff.new_dead_code.is_empty() {
393        out.push_str(&format!(
394            "\nNew dead code ({}):\n",
395            diff.new_dead_code.len()
396        ));
397        for s in &diff.new_dead_code {
398            out.push_str(&format!(
399                "  ! {:?} {} ({}:{})\n",
400                s.kind, s.name, s.file_path, s.line
401            ));
402        }
403    }
404
405    out
406}
407
408fn is_public_api(vis: Visibility) -> bool {
409    matches!(vis, Visibility::Public | Visibility::Exported)
410}
411
412fn is_api_relevant_kind(kind: NodeKind) -> bool {
413    matches!(
414        kind,
415        NodeKind::Function
416            | NodeKind::Method
417            | NodeKind::Class
418            | NodeKind::Struct
419            | NodeKind::Enum
420            | NodeKind::Interface
421            | NodeKind::Trait
422            | NodeKind::TypeAlias
423            | NodeKind::Constant
424            | NodeKind::Property
425            | NodeKind::Field
426    )
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::gir::{GirNode, Language, Span};
433    use std::path::PathBuf;
434
435    fn make_node(name: &str, kind: NodeKind, line: u32) -> GirNode {
436        GirNode::new(
437            name.to_string(),
438            kind,
439            PathBuf::from("test.py"),
440            Span::new(line, 0, line + 5, 0),
441            Language::Python,
442        )
443    }
444
445    fn make_public_node(name: &str, kind: NodeKind, line: u32) -> GirNode {
446        let mut n = make_node(name, kind, line);
447        n.visibility = Visibility::Public;
448        n
449    }
450
451    #[test]
452    fn detect_added_and_removed() {
453        let mut base = CodeGraph::new();
454        let mut head = CodeGraph::new();
455
456        base.add_node(make_node("old_func", NodeKind::Function, 1));
457        base.add_node(make_node("shared_func", NodeKind::Function, 10));
458
459        head.add_node(make_node("shared_func", NodeKind::Function, 10));
460        head.add_node(make_node("new_func", NodeKind::Function, 20));
461
462        let diff = diff_graphs(&base, &head);
463
464        assert_eq!(diff.removed_symbols.len(), 1);
465        assert_eq!(diff.removed_symbols[0].name, "old_func");
466        assert_eq!(diff.added_symbols.len(), 1);
467        assert_eq!(diff.added_symbols[0].name, "new_func");
468    }
469
470    #[test]
471    fn detect_breaking_change_removal() {
472        let mut base = CodeGraph::new();
473        let head = CodeGraph::new();
474
475        base.add_node(make_public_node("public_api", NodeKind::Function, 1));
476
477        let diff = diff_graphs(&base, &head);
478
479        assert_eq!(diff.breaking_changes.len(), 1);
480        assert_eq!(diff.breaking_changes[0].severity, Severity::Error);
481        assert!(diff.breaking_changes[0].description.contains("Removed"));
482    }
483
484    #[test]
485    fn detect_signature_change() {
486        let mut base = CodeGraph::new();
487        let mut head = CodeGraph::new();
488
489        let mut n1 = make_public_node("my_func", NodeKind::Function, 1);
490        n1.signature = Some("fn my_func(a: i32)".into());
491
492        let mut n2 = make_public_node("my_func", NodeKind::Function, 1);
493        n2.signature = Some("fn my_func(a: i32, b: i32)".into());
494
495        base.add_node(n1);
496        head.add_node(n2);
497
498        let diff = diff_graphs(&base, &head);
499
500        assert_eq!(diff.changed_symbols.len(), 1);
501        assert!(diff.breaking_changes.iter().any(|bc| bc.description.contains("Signature changed")));
502    }
503
504    #[test]
505    fn detect_new_dead_code() {
506        let mut base = CodeGraph::new();
507        let mut head = CodeGraph::new();
508
509        // Base has a function
510        base.add_node(make_node("existing", NodeKind::Function, 1));
511
512        // Head has existing + new uncalled function
513        head.add_node(make_node("existing", NodeKind::Function, 1));
514        head.add_node(make_node("unused_new", NodeKind::Function, 20));
515
516        let diff = diff_graphs(&base, &head);
517
518        assert_eq!(diff.new_dead_code.len(), 1);
519        assert_eq!(diff.new_dead_code[0].name, "unused_new");
520    }
521
522    #[test]
523    fn no_false_positive_for_test_functions() {
524        let mut base = CodeGraph::new();
525        let mut head = CodeGraph::new();
526
527        base.add_node(make_node("existing", NodeKind::Function, 1));
528        head.add_node(make_node("existing", NodeKind::Function, 1));
529        head.add_node(make_node("test_something", NodeKind::Function, 20));
530
531        let diff = diff_graphs(&base, &head);
532
533        // test_ functions should not be flagged as dead code
534        assert!(diff.new_dead_code.is_empty());
535    }
536
537    #[test]
538    fn both_graphs_empty() {
539        let base = CodeGraph::new();
540        let head = CodeGraph::new();
541        let diff = diff_graphs(&base, &head);
542        assert!(diff.removed_symbols.is_empty());
543        assert!(diff.added_symbols.is_empty());
544        assert!(diff.changed_symbols.is_empty());
545        assert!(diff.breaking_changes.is_empty());
546        assert!(diff.new_dead_code.is_empty());
547    }
548
549    #[test]
550    fn visibility_change_is_breaking() {
551        let mut base = CodeGraph::new();
552        let mut head = CodeGraph::new();
553
554        base.add_node(make_public_node("api_func", NodeKind::Function, 1));
555        // Same function but now private
556        let n = make_node("api_func", NodeKind::Function, 1);
557        head.add_node(n);
558
559        let diff = diff_graphs(&base, &head);
560        assert!(diff.breaking_changes.iter().any(|bc| bc.description.contains("Visibility")));
561    }
562}