Skip to main content

code_split_core/
cycles.rs

1use crate::graph::{CycleGroup, CycleKind, EdgeKind, Graph};
2use crate::snapshot::PluginGraphs;
3use std::collections::HashMap;
4
5/// Detect SCCs in the file graph and annotate nodes + the graph's `cycles`
6/// field in-place.
7pub fn annotate_all_cycles(graphs: &mut PluginGraphs) {
8    annotate_graph_cycles(&mut graphs.files);
9}
10
11fn annotate_graph_cycles(graph: &mut Graph) {
12    let n = graph.nodes.len();
13    if n == 0 {
14        return;
15    }
16
17    // Build index map NodeId → usize.
18    let id_to_idx: HashMap<&str, usize> = graph
19        .nodes
20        .iter()
21        .enumerate()
22        .map(|(i, node)| (node.id.as_str(), i))
23        .collect();
24
25    // Adjacency over information-flow edges (`uses` / `reexports`). `Contains`
26    // edges (a Rust `mod foo;` declaration, parent → child) are EXCLUDED: a
27    // parent module declaring a child while the child imports the parent's
28    // types is a language idiom, not an architectural cycle. Including them
29    // would flag every such parent/child pair as a false mutual cycle.
30    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
31    for edge in &graph.edges {
32        if edge.kind == EdgeKind::Contains {
33            continue;
34        }
35        if let (Some(&fi), Some(&ti)) = (
36            id_to_idx.get(edge.from.as_str()),
37            id_to_idx.get(edge.to.as_str()),
38        ) && fi != ti
39        {
40            adj[fi].push(ti);
41        }
42    }
43
44    let sccs = kosaraju_sccs(n, &adj);
45
46    let mut node_kind: Vec<Option<CycleKind>> = vec![None; n];
47    let mut cycle_groups: Vec<CycleGroup> = Vec::new();
48
49    for scc in &sccs {
50        if scc.len() < 2 {
51            continue;
52        }
53        let kind = classify_scc(scc, graph);
54        for &idx in scc {
55            node_kind[idx] = Some(kind);
56        }
57        cycle_groups.push(CycleGroup {
58            kind,
59            nodes: scc.iter().map(|&i| graph.nodes[i].id.clone()).collect(),
60        });
61    }
62
63    for (i, node) in graph.nodes.iter_mut().enumerate() {
64        node.cycle_kind = node_kind[i];
65    }
66    graph.cycles = cycle_groups;
67}
68
69fn classify_scc(scc: &[usize], graph: &Graph) -> CycleKind {
70    if scc.iter().any(|&i| is_test_node(graph, i)) {
71        return CycleKind::TestEmbed;
72    }
73    if scc.len() == 2 {
74        CycleKind::Mutual
75    } else {
76        CycleKind::Chain
77    }
78}
79
80fn is_test_node(graph: &Graph, idx: usize) -> bool {
81    let node = &graph.nodes[idx];
82    let mut name = node.name.to_ascii_lowercase();
83    // Strip a source-file extension so `foo_test.rs` / `test_x.py` match too.
84    for ext in [".rs", ".py", ".ts", ".tsx", ".js", ".jsx"] {
85        if let Some(stem) = name.strip_suffix(ext) {
86            name = stem.to_string();
87            break;
88        }
89    }
90    // Common test file / module names
91    if matches!(name.as_str(), "tests" | "test" | "benches" | "bench") {
92        return true;
93    }
94    if name.ends_with("_tests")
95        || name.ends_with("_test")
96        || name.ends_with("_bench")
97        || name.starts_with("test_")
98    {
99        return true;
100    }
101    // ID path segments — catches `::tests`, `::test::`, `/tests/`, etc.
102    let id = &node.id;
103    id.contains("::tests")
104        || id.contains("::test::")
105        || id.ends_with("::test")
106        || id.contains("/tests/")
107        || id.contains("/__tests__/")
108}
109
110// ---------------------------------------------------------------------------
111// Kosaraju's SCC (iterative, O(V+E))
112// ---------------------------------------------------------------------------
113
114fn kosaraju_sccs(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
115    // Pass 1: DFS on the original graph, collect finish order.
116    let mut visited = vec![false; n];
117    let mut finish_order = Vec::with_capacity(n);
118    for i in 0..n {
119        if !visited[i] {
120            dfs_finish(i, adj, &mut visited, &mut finish_order);
121        }
122    }
123
124    // Build transposed adjacency list.
125    let mut radj: Vec<Vec<usize>> = vec![Vec::new(); n];
126    for (u, neighbors) in adj.iter().enumerate() {
127        for &v in neighbors {
128            radj[v].push(u);
129        }
130    }
131
132    // Pass 2: DFS on the transposed graph in reverse finish order.
133    let mut visited2 = vec![false; n];
134    let mut sccs: Vec<Vec<usize>> = Vec::new();
135    for &start in finish_order.iter().rev() {
136        if !visited2[start] {
137            let mut scc = Vec::new();
138            dfs_collect(start, &radj, &mut visited2, &mut scc);
139            sccs.push(scc);
140        }
141    }
142    sccs
143}
144
145fn dfs_finish(start: usize, adj: &[Vec<usize>], visited: &mut [bool], order: &mut Vec<usize>) {
146    // Iterative DFS; (node, next_neighbor_index) on the call stack.
147    let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
148    visited[start] = true;
149    while let Some((u, ni)) = stack.last_mut() {
150        let u = *u;
151        if *ni < adj[u].len() {
152            let v = adj[u][*ni];
153            *ni += 1;
154            if !visited[v] {
155                visited[v] = true;
156                stack.push((v, 0));
157            }
158        } else {
159            stack.pop();
160            order.push(u);
161        }
162    }
163}
164
165fn dfs_collect(start: usize, adj: &[Vec<usize>], visited: &mut [bool], scc: &mut Vec<usize>) {
166    let mut stack = vec![start];
167    visited[start] = true;
168    while let Some(u) = stack.pop() {
169        scc.push(u);
170        for &v in &adj[u] {
171            if !visited[v] {
172                visited[v] = true;
173                stack.push(v);
174            }
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::graph::{Edge, EdgeKind, Node, NodeKind};
183
184    fn node(id: &str, name: &str, kind: NodeKind) -> Node {
185        Node {
186            id: id.into(),
187            kind,
188            name: name.into(),
189            path: String::new(),
190            parent: None,
191            external: None,
192            version: None,
193            visibility: None,
194            loc: None,
195            line: None,
196            item_count: None,
197            method_count: None,
198            complexity: None,
199            cycle_kind: None,
200        }
201    }
202
203    /// A plain module node whose `id` doubles as its `name` (the common,
204    /// non-test case).
205    fn mod_node(id: &str) -> Node {
206        node(id, id, NodeKind::Module)
207    }
208
209    fn edge(from: &str, to: &str, kind: EdgeKind) -> Edge {
210        Edge {
211            from: from.into(),
212            to: to.into(),
213            kind,
214            unresolved: None,
215            external: None,
216            visibility: None,
217        }
218    }
219
220    fn graph_of(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
221        Graph {
222            nodes,
223            edges,
224            cycles: Vec::new(),
225            stats: None,
226        }
227    }
228
229    fn kind_of(g: &Graph, id: &str) -> Option<CycleKind> {
230        g.nodes.iter().find(|n| n.id == id).unwrap().cycle_kind
231    }
232
233    #[test]
234    fn dag_has_no_cycles() {
235        // a → b → c with no back edge: no SCC of size ≥ 2.
236        let mut g = graph_of(
237            vec![mod_node("a"), mod_node("b"), mod_node("c")],
238            vec![
239                edge("a", "b", EdgeKind::Uses),
240                edge("b", "c", EdgeKind::Uses),
241            ],
242        );
243        annotate_graph_cycles(&mut g);
244        assert!(g.cycles.is_empty(), "a DAG has no cycle groups");
245        assert!(
246            g.nodes.iter().all(|n| n.cycle_kind.is_none()),
247            "no node in a DAG is annotated"
248        );
249    }
250
251    #[test]
252    fn two_node_cycle_is_mutual() {
253        // a ⇄ b, no test node → Mutual.
254        let mut g = graph_of(
255            vec![mod_node("a"), mod_node("b")],
256            vec![
257                edge("a", "b", EdgeKind::Uses),
258                edge("b", "a", EdgeKind::Uses),
259            ],
260        );
261        annotate_graph_cycles(&mut g);
262        assert_eq!(g.cycles.len(), 1, "one cycle group");
263        assert_eq!(g.cycles[0].kind, CycleKind::Mutual);
264        assert_eq!(g.cycles[0].nodes.len(), 2);
265        assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
266        assert_eq!(kind_of(&g, "b"), Some(CycleKind::Mutual));
267    }
268
269    #[test]
270    fn three_node_cycle_is_chain() {
271        // a → b → c → a, no test node → Chain.
272        let mut g = graph_of(
273            vec![mod_node("a"), mod_node("b"), mod_node("c")],
274            vec![
275                edge("a", "b", EdgeKind::Uses),
276                edge("b", "c", EdgeKind::Uses),
277                edge("c", "a", EdgeKind::Uses),
278            ],
279        );
280        annotate_graph_cycles(&mut g);
281        assert_eq!(g.cycles.len(), 1);
282        assert_eq!(g.cycles[0].kind, CycleKind::Chain);
283        assert_eq!(g.cycles[0].nodes.len(), 3);
284        for id in ["a", "b", "c"] {
285            assert_eq!(kind_of(&g, id), Some(CycleKind::Chain), "node {id}");
286        }
287    }
288
289    #[test]
290    fn contains_edge_does_not_form_a_cycle() {
291        // parent --contains--> child  +  child --uses--> parent: a `mod foo;`
292        // declaration combined with the child importing the parent's types is a
293        // Rust idiom, NOT an architectural cycle. `Contains` is excluded from
294        // cycle detection, so no cycle is reported.
295        let mut g = graph_of(
296            vec![
297                node("m", "m", NodeKind::Module),
298                node("m::child", "child", NodeKind::Module),
299            ],
300            vec![
301                edge("m", "m::child", EdgeKind::Contains),
302                edge("m::child", "m", EdgeKind::Uses),
303            ],
304        );
305        annotate_graph_cycles(&mut g);
306        assert!(
307            g.cycles.is_empty(),
308            "a contains+use parent/child pair is not a cycle"
309        );
310    }
311
312    #[test]
313    fn test_node_detected_by_name_suffix_overrides_chain() {
314        // 3-node cycle that would be Chain, but one node's name ends in
315        // `_tests` → TestEmbed.
316        let mut g = graph_of(
317            vec![
318                node("a", "a", NodeKind::Module),
319                node("b", "b", NodeKind::Module),
320                node("c", "foo_tests", NodeKind::Module),
321            ],
322            vec![
323                edge("a", "b", EdgeKind::Uses),
324                edge("b", "c", EdgeKind::Uses),
325                edge("c", "a", EdgeKind::Uses),
326            ],
327        );
328        annotate_graph_cycles(&mut g);
329        assert_eq!(g.cycles[0].kind, CycleKind::TestEmbed);
330    }
331
332    #[test]
333    fn self_loop_is_not_a_cycle() {
334        // a → a is dropped (fi == ti), so the SCC stays size 1.
335        let mut g = graph_of(vec![mod_node("a")], vec![edge("a", "a", EdgeKind::Uses)]);
336        annotate_graph_cycles(&mut g);
337        assert!(g.cycles.is_empty(), "a self-loop is not a structural cycle");
338        assert_eq!(kind_of(&g, "a"), None);
339    }
340
341    #[test]
342    fn node_outside_the_cycle_stays_unannotated() {
343        // a ⇄ b is a cycle; d hangs off a but is in no SCC of size ≥ 2.
344        let mut g = graph_of(
345            vec![mod_node("a"), mod_node("b"), mod_node("d")],
346            vec![
347                edge("a", "b", EdgeKind::Uses),
348                edge("b", "a", EdgeKind::Uses),
349                edge("a", "d", EdgeKind::Uses),
350            ],
351        );
352        annotate_graph_cycles(&mut g);
353        assert_eq!(g.cycles.len(), 1, "only the a⇄b SCC is a cycle");
354        assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
355        assert_eq!(kind_of(&g, "b"), Some(CycleKind::Mutual));
356        assert_eq!(
357            kind_of(&g, "d"),
358            None,
359            "the dangling node is not part of a cycle"
360        );
361    }
362
363    #[test]
364    fn disjoint_cycles_get_independent_groups() {
365        // a ⇄ b (Mutual) and c → d → e → c (Chain): two separate SCCs.
366        let mut g = graph_of(
367            vec![
368                mod_node("a"),
369                mod_node("b"),
370                mod_node("c"),
371                mod_node("d"),
372                mod_node("e"),
373            ],
374            vec![
375                edge("a", "b", EdgeKind::Uses),
376                edge("b", "a", EdgeKind::Uses),
377                edge("c", "d", EdgeKind::Uses),
378                edge("d", "e", EdgeKind::Uses),
379                edge("e", "c", EdgeKind::Uses),
380            ],
381        );
382        annotate_graph_cycles(&mut g);
383        assert_eq!(g.cycles.len(), 2, "two independent cycle groups");
384        let kinds: Vec<CycleKind> = g.cycles.iter().map(|c| c.kind).collect();
385        assert!(kinds.contains(&CycleKind::Mutual), "got {kinds:?}");
386        assert!(kinds.contains(&CycleKind::Chain), "got {kinds:?}");
387        assert_eq!(kind_of(&g, "a"), Some(CycleKind::Mutual));
388        assert_eq!(kind_of(&g, "d"), Some(CycleKind::Chain));
389    }
390
391    #[test]
392    fn empty_graph_is_a_noop() {
393        let mut g = Graph::new();
394        annotate_graph_cycles(&mut g);
395        assert!(g.cycles.is_empty());
396    }
397
398    #[test]
399    fn annotate_all_cycles_annotates_the_file_graph() {
400        // A mutual cycle in the single file graph is detected and dispatched.
401        let mut graphs = PluginGraphs {
402            files: graph_of(
403                vec![mod_node("a"), mod_node("b")],
404                vec![
405                    edge("a", "b", EdgeKind::Uses),
406                    edge("b", "a", EdgeKind::Uses),
407                ],
408            ),
409        };
410        annotate_all_cycles(&mut graphs);
411        assert_eq!(graphs.files.cycles.len(), 1);
412        assert_eq!(graphs.files.cycles[0].kind, CycleKind::Mutual);
413    }
414}