Skip to main content

meta_ast/graph/
scc.rs

1//! SCC (Strongly Connected Components) analysis for dependency graphs.
2//!
3//! This module implements Tarjan's SCC algorithm on the dependency subgraph
4//! (Import and Reference edges only; Ownership edges are excluded via
5//! `EdgeFiltered`).
6//!
7//! ## SCC as atomic deployment unit
8//!
9//! An SCC entirely within one language is never subdivided regardless of
10//! size. Cross-language SCCs may be split at the lowest-confidence edge
11//! (see `deploy::cut`), but same-language SCCs are always kept together.
12//! This guarantees that cycles - a known source of tight coupling - are
13//! preserved as a single deployment unit whenever possible.
14use petgraph::algo::tarjan_scc;
15use petgraph::graph::{DiGraph, NodeIndex};
16use petgraph::visit::EdgeFiltered;
17use std::collections::HashMap;
18
19use crate::graph::edge::{EdgeData, EdgeKind};
20use crate::graph::node::NodeData;
21
22/// A single strongly connected component.
23#[derive(Debug, Clone)]
24pub struct Scc {
25    /// Index of this component in topological order (dependencies first)
26    pub index: usize,
27    /// Node indices in this component
28    pub nodes: Vec<NodeIndex>,
29    /// Whether this component is cyclic (size > 1 or self-loop)
30    pub is_cyclic: bool,
31    /// Deployability recommendation
32    pub hint: DeployabilityHint,
33}
34
35/// Deployability classification for an SCC.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub enum DeployabilityHint {
39    /// Single node, no self-loop, no dependencies - can deploy independently
40    Independent,
41    /// Single node, no self-loop, but has dependencies
42    AcyclicDependency,
43    /// Part of a cycle (size > 1) - requires grouped deployment
44    CyclicCluster,
45    /// Single node with self-loop - deploy with caution
46    SelfLoop,
47}
48
49impl std::fmt::Display for DeployabilityHint {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            DeployabilityHint::Independent => write!(f, "independent"),
53            DeployabilityHint::AcyclicDependency => write!(f, "acyclic_dependency"),
54            DeployabilityHint::CyclicCluster => write!(f, "cyclic_cluster"),
55            DeployabilityHint::SelfLoop => write!(f, "self_loop"),
56        }
57    }
58}
59
60/// Complete SCC analysis results for a dependency graph.
61#[derive(Debug, Clone)]
62pub struct SccAnalysis {
63    /// SCCs in reverse topological order (dependencies before dependents)
64    pub components: Vec<Scc>,
65    /// Map from node index to its component index
66    pub node_to_component: HashMap<NodeIndex, usize>,
67}
68
69impl SccAnalysis {
70    /// Analyze a graph and compute SCCs on the dependency subgraph.
71    ///
72    /// Ownership edges are excluded from SCC computation per graph-model.md.
73    /// The dependency subgraph includes Import and Reference edge kinds.
74    ///
75    /// Uses an `EdgeFiltered` view instead of cloning the graph - zero-cost,
76    /// no allocation for the subgraph.
77    pub fn analyze(graph: &DiGraph<NodeData, EdgeData>) -> Self {
78        // Zero-cost view that excludes ownership edges - no graph cloning.
79        let dep_view = EdgeFiltered::from_fn(
80            graph,
81            |edge: petgraph::graph::EdgeReference<'_, EdgeData>| {
82                edge.weight().kind != EdgeKind::Ownership
83            },
84        );
85
86        // Run Tarjan SCC algorithm on the view.
87        // The returned NodeIndex values ARE the original graph's indices.
88        let scc_groups = tarjan_scc(&dep_view);
89
90        let mut components = Vec::with_capacity(scc_groups.len());
91        let mut node_to_component = HashMap::new();
92
93        for (index, nodes) in scc_groups.into_iter().enumerate() {
94            let has_self_loop = nodes.iter().any(|&node| {
95                graph
96                    .neighbors_directed(node, petgraph::Direction::Outgoing)
97                    .any(|neighbor| neighbor == node)
98            });
99
100            let is_cyclic = nodes.len() > 1 || has_self_loop;
101
102            let hint = if nodes.len() > 1 {
103                DeployabilityHint::CyclicCluster
104            } else if has_self_loop {
105                DeployabilityHint::SelfLoop
106            } else if nodes.len() == 1 {
107                DeployabilityHint::AcyclicDependency
108            } else {
109                DeployabilityHint::Independent
110            };
111
112            for &node in &nodes {
113                node_to_component.insert(node, index);
114            }
115
116            components.push(Scc {
117                index,
118                nodes,
119                is_cyclic,
120                hint,
121            });
122        }
123
124        Self::classify_independence(graph, &mut components, &node_to_component);
125
126        Self {
127            components,
128            node_to_component,
129        }
130    }
131
132    /// Classify components as Independent if they have no outgoing dependencies
133    /// to other components.
134    fn classify_independence(
135        graph: &DiGraph<NodeData, EdgeData>,
136        components: &mut [Scc],
137        node_to_component: &HashMap<NodeIndex, usize>,
138    ) {
139        let mut component_deps: HashMap<usize, Vec<usize>> = HashMap::new();
140
141        for edge_idx in graph.edge_indices() {
142            let Some(weight) = graph.edge_weight(edge_idx) else {
143                continue;
144            };
145            if weight.kind == EdgeKind::Ownership {
146                continue;
147            }
148            let Some((source, target)) = graph.edge_endpoints(edge_idx) else {
149                continue;
150            };
151            let source_comp = node_to_component.get(&source);
152            let target_comp = node_to_component.get(&target);
153
154            if let (Some(&s), Some(&t)) = (source_comp, target_comp)
155                && s != t
156            {
157                component_deps.entry(s).or_default().push(t);
158            }
159        }
160
161        // Update hints for components with no external dependencies
162        for comp in components.iter_mut() {
163            if comp.hint == DeployabilityHint::AcyclicDependency {
164                let has_external_deps = component_deps
165                    .get(&comp.index)
166                    .map(|deps| !deps.is_empty())
167                    .unwrap_or(false);
168
169                if !has_external_deps {
170                    comp.hint = DeployabilityHint::Independent;
171                }
172            }
173        }
174    }
175
176    /// Get the component index for a specific node.
177    pub fn component_of(&self, node: NodeIndex) -> Option<usize> {
178        self.node_to_component.get(&node).copied()
179    }
180
181    /// Check if two nodes are in the same SCC (mutually dependent).
182    pub fn mutually_dependent(&self, a: NodeIndex, b: NodeIndex) -> bool {
183        self.component_of(a) == self.component_of(b)
184    }
185
186    /// Returns true if any cycles exist in the graph.
187    pub fn has_cycles(&self) -> bool {
188        self.components.iter().any(|c| c.is_cyclic)
189    }
190
191    /// Get all cyclic components.
192    pub fn cyclic_components(&self) -> impl Iterator<Item = &Scc> {
193        self.components.iter().filter(|c| c.is_cyclic)
194    }
195
196    /// Get all acyclic (independent/dependency) components.
197    pub fn acyclic_components(&self) -> impl Iterator<Item = &Scc> {
198        self.components.iter().filter(|c| !c.is_cyclic)
199    }
200
201    /// Count of components by hint type.
202    pub fn hint_counts(&self) -> HashMap<DeployabilityHint, usize> {
203        let mut counts = HashMap::new();
204        for comp in &self.components {
205            *counts.entry(comp.hint).or_insert(0) += 1;
206        }
207        counts
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::graph::node::{FileNode, SymbolNode};
215    use crate::language::LangId;
216    use crate::model::{LineColumn, SourceRange, SymbolId, Visibility, ids::FileId};
217
218    fn make_source_range() -> SourceRange {
219        SourceRange {
220            byte_start: 0,
221            byte_end: 10,
222            start: LineColumn { line: 1, column: 0 },
223            end: LineColumn {
224                line: 1,
225                column: 10,
226            },
227        }
228    }
229
230    fn make_file_node(id: u32, path: &str) -> NodeData {
231        NodeData::File(FileNode {
232            id: FileId(id),
233            path: std::path::PathBuf::from(path),
234            language: LangId::Rust,
235            snapshot_id: crate::model::ids::SnapshotId(1),
236        })
237    }
238
239    fn make_symbol_node(id: u32, name: &str, file_id: u32) -> NodeData {
240        NodeData::Symbol(SymbolNode {
241            id: SymbolId(id),
242            name: name.to_string(),
243            kind: crate::model::SymbolKind::Function,
244            file_id: FileId(file_id),
245            visibility: Some(Visibility::Public),
246            source_range: make_source_range(),
247        })
248    }
249
250    fn make_edge(kind: EdgeKind) -> EdgeData {
251        EdgeData {
252            kind,
253            confidence: 1.0,
254        }
255    }
256
257    #[test]
258    fn scc_single_node_no_edges() {
259        let mut graph = DiGraph::new();
260        let node = graph.add_node(make_symbol_node(1, "foo", 0));
261
262        let analysis = SccAnalysis::analyze(&graph);
263
264        assert_eq!(analysis.components.len(), 1);
265        assert_eq!(analysis.components[0].nodes, vec![node]);
266        assert!(!analysis.components[0].is_cyclic);
267        assert_eq!(analysis.components[0].hint, DeployabilityHint::Independent);
268    }
269
270    #[test]
271    fn scc_linear_chain() {
272        let mut graph = DiGraph::new();
273        let a = graph.add_node(make_symbol_node(1, "a", 0));
274        let b = graph.add_node(make_symbol_node(2, "b", 0));
275        let c = graph.add_node(make_symbol_node(3, "c", 0));
276
277        // a -> b -> c (acyclic chain)
278        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
279        graph.add_edge(b, c, make_edge(EdgeKind::Reference));
280
281        let analysis = SccAnalysis::analyze(&graph);
282
283        assert_eq!(analysis.components.len(), 3);
284        assert!(!analysis.has_cycles());
285        assert!(analysis.cyclic_components().next().is_none());
286    }
287
288    #[test]
289    fn scc_simple_cycle() {
290        let mut graph = DiGraph::new();
291        let a = graph.add_node(make_symbol_node(1, "a", 0));
292        let b = graph.add_node(make_symbol_node(2, "b", 0));
293        let c = graph.add_node(make_symbol_node(3, "c", 0));
294
295        // a -> b -> c -> a (cycle)
296        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
297        graph.add_edge(b, c, make_edge(EdgeKind::Reference));
298        graph.add_edge(c, a, make_edge(EdgeKind::Reference));
299
300        let analysis = SccAnalysis::analyze(&graph);
301
302        assert!(analysis.has_cycles());
303        let cyclic: Vec<_> = analysis.cyclic_components().collect();
304        assert_eq!(cyclic.len(), 1);
305        assert_eq!(cyclic[0].nodes.len(), 3);
306        assert_eq!(cyclic[0].hint, DeployabilityHint::CyclicCluster);
307    }
308
309    #[test]
310    fn scc_ownership_edges_excluded() {
311        let mut graph = DiGraph::new();
312        let file = graph.add_node(make_file_node(0, "test.rs"));
313        let sym = graph.add_node(make_symbol_node(1, "func", 0));
314
315        // Ownership edge should not create cycle even if self-referential structure
316        graph.add_edge(file, sym, make_edge(EdgeKind::Ownership));
317
318        let analysis = SccAnalysis::analyze(&graph);
319
320        // Should have 2 components, not 1
321        assert_eq!(analysis.components.len(), 2);
322        assert!(!analysis.has_cycles());
323    }
324
325    #[test]
326    fn scc_self_loop_detected() {
327        let mut graph = DiGraph::new();
328        let a = graph.add_node(make_symbol_node(1, "a", 0));
329
330        // Self-loop via reference edge
331        graph.add_edge(a, a, make_edge(EdgeKind::Reference));
332
333        let analysis = SccAnalysis::analyze(&graph);
334
335        assert!(analysis.has_cycles());
336        let comp = &analysis.components[0];
337        assert!(comp.is_cyclic);
338        assert_eq!(comp.hint, DeployabilityHint::SelfLoop);
339    }
340
341    #[test]
342    fn scc_multiple_cycles() {
343        let mut graph = DiGraph::new();
344        let a = graph.add_node(make_symbol_node(1, "a", 0));
345        let b = graph.add_node(make_symbol_node(2, "b", 0));
346        let c = graph.add_node(make_symbol_node(3, "c", 0));
347        let d = graph.add_node(make_symbol_node(4, "d", 0));
348
349        // Cycle 1: a -> b -> a
350        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
351        graph.add_edge(b, a, make_edge(EdgeKind::Reference));
352
353        // Cycle 2: c -> d -> c (independent)
354        graph.add_edge(c, d, make_edge(EdgeKind::Reference));
355        graph.add_edge(d, c, make_edge(EdgeKind::Reference));
356
357        let analysis = SccAnalysis::analyze(&graph);
358
359        assert!(analysis.has_cycles());
360        let cyclic: Vec<_> = analysis.cyclic_components().collect();
361        assert_eq!(cyclic.len(), 2); // Two separate cycles
362
363        let counts = analysis.hint_counts();
364        assert_eq!(counts.get(&DeployabilityHint::CyclicCluster), Some(&2));
365    }
366
367    #[test]
368    fn scc_mutual_dependence_check() {
369        let mut graph = DiGraph::new();
370        let a = graph.add_node(make_symbol_node(1, "a", 0));
371        let b = graph.add_node(make_symbol_node(2, "b", 0));
372        let c = graph.add_node(make_symbol_node(3, "c", 0));
373
374        // a <-> b are mutually dependent, c is independent
375        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
376        graph.add_edge(b, a, make_edge(EdgeKind::Reference));
377
378        let analysis = SccAnalysis::analyze(&graph);
379
380        assert!(analysis.mutually_dependent(a, b));
381        assert!(!analysis.mutually_dependent(a, c));
382        assert!(!analysis.mutually_dependent(b, c));
383    }
384
385    #[test]
386    fn scc_component_lookup() {
387        let mut graph = DiGraph::new();
388        let a = graph.add_node(make_symbol_node(1, "a", 0));
389        let b = graph.add_node(make_symbol_node(2, "b", 0));
390
391        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
392
393        let analysis = SccAnalysis::analyze(&graph);
394
395        let comp_a = analysis.component_of(a);
396        let comp_b = analysis.component_of(b);
397        assert!(comp_a.is_some());
398        assert!(comp_b.is_some());
399    }
400
401    #[test]
402    fn scc_topological_order_dependencies_first() {
403        let mut graph = DiGraph::new();
404        let a = graph.add_node(make_symbol_node(1, "a", 0));
405        let b = graph.add_node(make_symbol_node(2, "b", 0));
406        let c = graph.add_node(make_symbol_node(3, "c", 0));
407
408        // c depends on b, b depends on a: a -> b -> c
409        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
410        graph.add_edge(b, c, make_edge(EdgeKind::Reference));
411
412        let analysis = SccAnalysis::analyze(&graph);
413
414        // Components should be in reverse topological order
415        // That is: c (dependent) comes before a (dependency)
416        // Or more accurately: dependencies should be processed first
417        let indices: Vec<_> = analysis
418            .components
419            .iter()
420            .map(|c| {
421                c.nodes
422                    .first()
423                    .map(|n| n.index())
424                    .expect("Component has nodes")
425            })
426            .collect();
427
428        // Just verify we have 3 components
429        assert_eq!(indices.len(), 3);
430    }
431
432    #[test]
433    fn scc_diamond_structure() {
434        let mut graph = DiGraph::new();
435        let a = graph.add_node(make_symbol_node(1, "a", 0));
436        let b = graph.add_node(make_symbol_node(2, "b", 0));
437        let c = graph.add_node(make_symbol_node(3, "c", 0));
438        let d = graph.add_node(make_symbol_node(4, "d", 0));
439
440        // Diamond: a -> b, a -> c, b -> d, c -> d
441        graph.add_edge(a, b, make_edge(EdgeKind::Reference));
442        graph.add_edge(a, c, make_edge(EdgeKind::Reference));
443        graph.add_edge(b, d, make_edge(EdgeKind::Reference));
444        graph.add_edge(c, d, make_edge(EdgeKind::Reference));
445
446        let analysis = SccAnalysis::analyze(&graph);
447
448        assert!(!analysis.has_cycles());
449        assert_eq!(analysis.components.len(), 4);
450    }
451
452    #[test]
453    fn deployability_hint_display() {
454        assert_eq!(format!("{}", DeployabilityHint::Independent), "independent");
455        assert_eq!(
456            format!("{}", DeployabilityHint::CyclicCluster),
457            "cyclic_cluster"
458        );
459        assert_eq!(format!("{}", DeployabilityHint::SelfLoop), "self_loop");
460        assert_eq!(
461            format!("{}", DeployabilityHint::AcyclicDependency),
462            "acyclic_dependency"
463        );
464    }
465}