Skip to main content

fallow_graph/graph/
cycles.rs

1//! Circular dependency detection via Tarjan's SCC algorithm + elementary cycle enumeration.
2
3use std::ops::Range;
4
5use fixedbitset::FixedBitSet;
6use rustc_hash::FxHashSet;
7
8use fallow_types::discover::FileId;
9
10use super::ModuleGraph;
11use super::types::ModuleNode;
12
13impl ModuleGraph {
14    /// Find all circular dependency cycles in the module graph.
15    ///
16    /// Uses an iterative implementation of Tarjan's strongly connected components
17    /// algorithm (O(V + E)) to find all SCCs with 2 or more nodes. Each such SCC
18    /// represents a set of files involved in a circular dependency.
19    ///
20    /// Returns cycles sorted by length (shortest first), with files within each
21    /// cycle sorted by path for deterministic output.
22    ///
23    /// # Panics
24    ///
25    /// Panics if the internal file-to-path lookup is inconsistent with the module list.
26    #[must_use]
27    #[expect(
28        clippy::excessive_nesting,
29        reason = "Tarjan's SCC requires deep nesting"
30    )]
31    #[expect(
32        clippy::cast_possible_truncation,
33        reason = "file count is bounded by project size, well under u32::MAX"
34    )]
35    #[expect(
36        clippy::expect_used,
37        reason = "Tarjan traversal only pops nodes that were pushed onto the SCC stack"
38    )]
39    pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
40        let n = self.modules.len();
41        if n == 0 {
42            return Vec::new();
43        }
44
45        let mut index_counter: u32 = 0;
46        let mut indices: Vec<u32> = vec![u32::MAX; n];
47        let mut lowlinks: Vec<u32> = vec![0; n];
48        let mut on_stack = FixedBitSet::with_capacity(n);
49        let mut stack: Vec<usize> = Vec::new();
50        let mut sccs: Vec<Vec<FileId>> = Vec::new();
51
52        struct Frame {
53            node: usize,
54            succ_pos: usize,
55            succ_end: usize,
56        }
57
58        let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
59        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
60        let mut seen_set = FxHashSet::default();
61        for module in &self.modules {
62            let start = all_succs.len();
63            seen_set.clear();
64            for edge in &self.edges[module.edge_range.clone()] {
65                if edge.symbols.iter().all(|s| s.is_type_only) {
66                    continue;
67                }
68                let target = edge.target.0 as usize;
69                if target < n && seen_set.insert(target) {
70                    all_succs.push(target);
71                }
72            }
73            let end = all_succs.len();
74            succ_ranges.push(start..end);
75        }
76
77        let mut dfs_stack: Vec<Frame> = Vec::new();
78
79        for start_node in 0..n {
80            if indices[start_node] != u32::MAX {
81                continue;
82            }
83
84            indices[start_node] = index_counter;
85            lowlinks[start_node] = index_counter;
86            index_counter += 1;
87            on_stack.insert(start_node);
88            stack.push(start_node);
89
90            let range = &succ_ranges[start_node];
91            dfs_stack.push(Frame {
92                node: start_node,
93                succ_pos: range.start,
94                succ_end: range.end,
95            });
96
97            while let Some(frame) = dfs_stack.last_mut() {
98                if frame.succ_pos < frame.succ_end {
99                    let w = all_succs[frame.succ_pos];
100                    frame.succ_pos += 1;
101
102                    if indices[w] == u32::MAX {
103                        indices[w] = index_counter;
104                        lowlinks[w] = index_counter;
105                        index_counter += 1;
106                        on_stack.insert(w);
107                        stack.push(w);
108
109                        let range = &succ_ranges[w];
110                        dfs_stack.push(Frame {
111                            node: w,
112                            succ_pos: range.start,
113                            succ_end: range.end,
114                        });
115                    } else if on_stack.contains(w) {
116                        let v = frame.node;
117                        lowlinks[v] = lowlinks[v].min(indices[w]);
118                    }
119                } else {
120                    let v = frame.node;
121                    let v_lowlink = lowlinks[v];
122                    let v_index = indices[v];
123                    dfs_stack.pop();
124
125                    if let Some(parent) = dfs_stack.last_mut() {
126                        lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
127                    }
128
129                    if v_lowlink == v_index {
130                        let mut scc = Vec::new();
131                        loop {
132                            let w = stack.pop().expect("SCC stack should not be empty");
133                            on_stack.set(w, false);
134                            scc.push(FileId(w as u32));
135                            if w == v {
136                                break;
137                            }
138                        }
139                        if scc.len() >= 2 {
140                            sccs.push(scc);
141                        }
142                    }
143                }
144            }
145        }
146
147        self.enumerate_cycles_from_sccs(&sccs, &all_succs, &succ_ranges)
148    }
149
150    /// Enumerate individual elementary cycles from SCCs and return sorted results.
151    #[expect(
152        clippy::cast_possible_truncation,
153        reason = "file count is bounded by project size, well under u32::MAX"
154    )]
155    fn enumerate_cycles_from_sccs(
156        &self,
157        sccs: &[Vec<FileId>],
158        all_succs: &[usize],
159        succ_ranges: &[Range<usize>],
160    ) -> Vec<Vec<FileId>> {
161        const MAX_CYCLES_PER_SCC: usize = 20;
162
163        let succs = SuccessorMap {
164            all_succs,
165            succ_ranges,
166            modules: &self.modules,
167        };
168
169        let mut result: Vec<Vec<FileId>> = Vec::new();
170        let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
171
172        for scc in sccs {
173            if scc.len() == 2 {
174                let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
175                if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
176                    cycle.swap(0, 1);
177                }
178                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
179                if seen_cycles.insert(key) {
180                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
181                }
182                continue;
183            }
184
185            let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
186            let elementary = enumerate_elementary_cycles(&scc_nodes, &succs, MAX_CYCLES_PER_SCC);
187
188            for cycle in elementary {
189                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
190                if seen_cycles.insert(key) {
191                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
192                }
193            }
194        }
195
196        result.sort_by(|a, b| {
197            a.len().cmp(&b.len()).then_with(|| {
198                self.modules[a[0].0 as usize]
199                    .path
200                    .cmp(&self.modules[b[0].0 as usize].path)
201            })
202        });
203
204        result
205    }
206}
207
208/// Rotate a cycle so the node with the smallest path is first (canonical form for dedup).
209fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
210    if cycle.is_empty() {
211        return Vec::new();
212    }
213    let min_pos = cycle
214        .iter()
215        .enumerate()
216        .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
217        .map_or(0, |(i, _)| i);
218    let mut result = cycle[min_pos..].to_vec();
219    result.extend_from_slice(&cycle[..min_pos]);
220    result
221}
222
223struct CycleFrame {
224    succ_pos: usize,
225    succ_end: usize,
226}
227
228struct SuccessorMap<'a> {
229    all_succs: &'a [usize],
230    succ_ranges: &'a [Range<usize>],
231    modules: &'a [ModuleNode],
232}
233
234#[expect(
235    clippy::cast_possible_truncation,
236    reason = "file count is bounded by project size, well under u32::MAX"
237)]
238fn try_record_cycle(
239    path: &[usize],
240    modules: &[ModuleNode],
241    seen: &mut FxHashSet<Vec<u32>>,
242    cycles: &mut Vec<Vec<usize>>,
243) {
244    let canonical = canonical_cycle(path, modules);
245    let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
246    if seen.insert(key) {
247        cycles.push(canonical);
248    }
249}
250
251/// Run a bounded DFS from `start`, looking for elementary cycles of exactly `depth_limit` nodes.
252///
253/// Appends any newly found cycles to `cycles` (deduped via `seen`).
254/// Stops early once `cycles.len() >= max_cycles`.
255struct DfsCycleInput<'a> {
256    start: usize,
257    depth_limit: usize,
258    scc_set: &'a FxHashSet<usize>,
259    succs: &'a SuccessorMap<'a>,
260    max_cycles: usize,
261    seen: &'a mut FxHashSet<Vec<u32>>,
262    cycles: &'a mut Vec<Vec<usize>>,
263}
264
265fn dfs_find_cycles_from(input: &mut DfsCycleInput<'_>) {
266    let mut path: Vec<usize> = vec![input.start];
267    let mut path_set = FixedBitSet::with_capacity(input.succs.modules.len());
268    path_set.insert(input.start);
269
270    let range = &input.succs.succ_ranges[input.start];
271    let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
272        succ_pos: range.start,
273        succ_end: range.end,
274    }];
275
276    while let Some(frame) = dfs.last_mut() {
277        if input.cycles.len() >= input.max_cycles {
278            return;
279        }
280
281        if frame.succ_pos >= frame.succ_end {
282            dfs.pop();
283            if path.len() > 1 {
284                let Some(removed) = path.pop() else {
285                    continue;
286                };
287                path_set.set(removed, false);
288            }
289            continue;
290        }
291
292        let w = input.succs.all_succs[frame.succ_pos];
293        frame.succ_pos += 1;
294
295        if !input.scc_set.contains(&w) {
296            continue;
297        }
298
299        if w == input.start && path.len() >= 2 && path.len() == input.depth_limit {
300            try_record_cycle(&path, input.succs.modules, input.seen, input.cycles);
301            continue;
302        }
303
304        if path_set.contains(w) || path.len() >= input.depth_limit {
305            continue;
306        }
307
308        path.push(w);
309        path_set.insert(w);
310
311        let range = &input.succs.succ_ranges[w];
312        dfs.push(CycleFrame {
313            succ_pos: range.start,
314            succ_end: range.end,
315        });
316    }
317}
318
319/// Enumerate individual elementary cycles within an SCC using depth-limited DFS.
320///
321/// Uses iterative deepening: first finds all 2-node cycles, then 3-node, etc.
322/// This ensures the shortest, most actionable cycles are always found first.
323/// Stops after `max_cycles` total cycles to bound work on dense SCCs.
324fn enumerate_elementary_cycles(
325    scc_nodes: &[usize],
326    succs: &SuccessorMap<'_>,
327    max_cycles: usize,
328) -> Vec<Vec<usize>> {
329    let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
330    let mut cycles: Vec<Vec<usize>> = Vec::new();
331    let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
332
333    let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
334    sorted_nodes.sort_by(|a, b| succs.modules[*a].path.cmp(&succs.modules[*b].path));
335
336    let max_depth = scc_nodes.len().min(12); // Cap depth to avoid very long cycles
337    for depth_limit in 2..=max_depth {
338        if cycles.len() >= max_cycles {
339            break;
340        }
341
342        for &start in &sorted_nodes {
343            if cycles.len() >= max_cycles {
344                break;
345            }
346
347            dfs_find_cycles_from(&mut DfsCycleInput {
348                start,
349                depth_limit,
350                scc_set: &scc_set,
351                succs,
352                max_cycles,
353                seen: &mut seen,
354                cycles: &mut cycles,
355            });
356        }
357    }
358
359    cycles
360}
361
362#[cfg(test)]
363mod tests {
364    use std::ops::Range;
365    use std::path::PathBuf;
366
367    use rustc_hash::FxHashSet;
368
369    use crate::graph::types::ModuleNode;
370    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
371    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
372    use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
373
374    use super::{
375        DfsCycleInput, ModuleGraph, SuccessorMap, canonical_cycle, dfs_find_cycles_from,
376        enumerate_elementary_cycles, try_record_cycle,
377    };
378
379    /// Helper: build a graph from files+edges, no entry points needed for cycle detection.
380    #[expect(
381        clippy::cast_possible_truncation,
382        reason = "test file counts are trivially small"
383    )]
384    fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
385        let files: Vec<DiscoveredFile> = (0..file_count)
386            .map(|i| DiscoveredFile {
387                id: FileId(i as u32),
388                path: PathBuf::from(format!("/project/file{i}.ts")),
389                size_bytes: 100,
390            })
391            .collect();
392
393        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
394            .map(|i| {
395                let imports: Vec<ResolvedImport> = edges_spec
396                    .iter()
397                    .filter(|(src, _)| *src == i as u32)
398                    .map(|(_, tgt)| ResolvedImport {
399                        info: ImportInfo {
400                            source: format!("./file{tgt}"),
401                            imported_name: ImportedName::Named("x".to_string()),
402                            local_name: "x".to_string(),
403                            is_type_only: false,
404                            from_style: false,
405                            span: oxc_span::Span::new(0, 10),
406                            source_span: oxc_span::Span::default(),
407                        },
408                        target: ResolveResult::InternalModule(FileId(*tgt)),
409                    })
410                    .collect();
411
412                ResolvedModule {
413                    file_id: FileId(i as u32),
414                    path: PathBuf::from(format!("/project/file{i}.ts")),
415                    exports: vec![fallow_types::extract::ExportInfo {
416                        name: ExportName::Named("x".to_string()),
417                        local_name: Some("x".to_string()),
418                        is_type_only: false,
419                        visibility: VisibilityTag::None,
420                        span: oxc_span::Span::new(0, 20),
421                        members: vec![],
422                        is_side_effect_used: false,
423                        super_class: None,
424                    }],
425                    re_exports: vec![],
426                    resolved_imports: imports,
427                    resolved_dynamic_imports: vec![],
428                    resolved_dynamic_patterns: vec![],
429                    member_accesses: vec![],
430                    whole_object_uses: vec![],
431                    has_cjs_exports: false,
432                    has_angular_component_template_url: false,
433                    unused_import_bindings: FxHashSet::default(),
434                    type_referenced_import_bindings: vec![],
435                    value_referenced_import_bindings: vec![],
436                    namespace_object_aliases: vec![],
437                }
438            })
439            .collect();
440
441        let entry_points = vec![EntryPoint {
442            path: PathBuf::from("/project/file0.ts"),
443            source: EntryPointSource::PackageJsonMain,
444        }];
445
446        ModuleGraph::build(&resolved_modules, &entry_points, &files)
447    }
448
449    fn dfs_find_cycles_from_for_test(
450        start: usize,
451        depth_limit: usize,
452        scc_set: &FxHashSet<usize>,
453        succs: &SuccessorMap<'_>,
454        max_cycles: usize,
455        seen: &mut FxHashSet<Vec<u32>>,
456        cycles: &mut Vec<Vec<usize>>,
457    ) {
458        dfs_find_cycles_from(&mut DfsCycleInput {
459            start,
460            depth_limit,
461            scc_set,
462            succs,
463            max_cycles,
464            seen,
465            cycles,
466        });
467    }
468
469    #[test]
470    fn find_cycles_empty_graph() {
471        let graph = ModuleGraph::build(&[], &[], &[]);
472        assert!(graph.find_cycles().is_empty());
473    }
474
475    #[test]
476    fn find_cycles_no_cycles() {
477        let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
478        assert!(graph.find_cycles().is_empty());
479    }
480
481    #[test]
482    fn find_cycles_simple_two_node_cycle() {
483        let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
484        let cycles = graph.find_cycles();
485        assert_eq!(cycles.len(), 1);
486        assert_eq!(cycles[0].len(), 2);
487    }
488
489    #[test]
490    fn find_cycles_three_node_cycle() {
491        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
492        let cycles = graph.find_cycles();
493        assert_eq!(cycles.len(), 1);
494        assert_eq!(cycles[0].len(), 3);
495    }
496
497    #[test]
498    fn find_cycles_self_import_ignored() {
499        let graph = build_cycle_graph(1, &[(0, 0)]);
500        let cycles = graph.find_cycles();
501        assert!(
502            cycles.is_empty(),
503            "self-imports should not be reported as cycles"
504        );
505    }
506
507    #[test]
508    fn find_cycles_multiple_independent_cycles() {
509        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
510        let cycles = graph.find_cycles();
511        assert_eq!(cycles.len(), 2);
512        assert!(cycles.iter().all(|c| c.len() == 2));
513    }
514
515    #[test]
516    fn find_cycles_linear_chain_with_back_edge() {
517        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
518        let cycles = graph.find_cycles();
519        assert_eq!(cycles.len(), 1);
520        assert_eq!(cycles[0].len(), 3);
521        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
522        assert!(ids.contains(&1));
523        assert!(ids.contains(&2));
524        assert!(ids.contains(&3));
525        assert!(!ids.contains(&0));
526    }
527
528    #[test]
529    fn find_cycles_overlapping_cycles_enumerated() {
530        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
531        let cycles = graph.find_cycles();
532        assert_eq!(
533            cycles.len(),
534            2,
535            "should find 2 elementary cycles, not 1 SCC"
536        );
537        assert!(
538            cycles.iter().all(|c| c.len() == 2),
539            "both cycles should have length 2"
540        );
541    }
542
543    #[test]
544    fn find_cycles_deterministic_ordering() {
545        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
546        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
547        let cycles1 = graph1.find_cycles();
548        let cycles2 = graph2.find_cycles();
549        assert_eq!(cycles1.len(), cycles2.len());
550        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
551            let paths1: Vec<&PathBuf> = c1
552                .iter()
553                .map(|f| &graph1.modules[f.0 as usize].path)
554                .collect();
555            let paths2: Vec<&PathBuf> = c2
556                .iter()
557                .map(|f| &graph2.modules[f.0 as usize].path)
558                .collect();
559            assert_eq!(paths1, paths2);
560        }
561    }
562
563    #[test]
564    fn find_cycles_sorted_by_length() {
565        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
566        let cycles = graph.find_cycles();
567        assert_eq!(cycles.len(), 2);
568        assert!(
569            cycles[0].len() <= cycles[1].len(),
570            "cycles should be sorted by length"
571        );
572    }
573
574    #[test]
575    fn find_cycles_large_cycle() {
576        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
577        let graph = build_cycle_graph(10, &edges);
578        let cycles = graph.find_cycles();
579        assert_eq!(cycles.len(), 1);
580        assert_eq!(cycles[0].len(), 10);
581    }
582
583    #[test]
584    fn find_cycles_complex_scc_multiple_elementary() {
585        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
586        let cycles = graph.find_cycles();
587        assert!(
588            cycles.len() >= 2,
589            "should find at least 2 elementary cycles, got {}",
590            cycles.len()
591        );
592        assert!(cycles.iter().all(|c| c.len() <= 4));
593    }
594
595    #[test]
596    fn find_cycles_no_duplicate_cycles() {
597        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
598        let cycles = graph.find_cycles();
599        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
600        assert_eq!(cycles[0].len(), 3);
601    }
602
603    /// Build lightweight `ModuleNode` stubs and successor data for unit tests.
604    ///
605    /// `edges_spec` is a list of (source, target) pairs (0-indexed).
606    /// Returns (modules, all_succs, succ_ranges) suitable for constructing a `SuccessorMap`.
607    #[expect(
608        clippy::cast_possible_truncation,
609        reason = "test file counts are trivially small"
610    )]
611    fn build_test_succs(
612        file_count: usize,
613        edges_spec: &[(usize, usize)],
614    ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
615        let modules: Vec<ModuleNode> = (0..file_count)
616            .map(|i| {
617                let mut node = ModuleNode {
618                    file_id: FileId(i as u32),
619                    path: PathBuf::from(format!("/project/file{i}.ts")),
620                    edge_range: 0..0,
621                    exports: vec![],
622                    re_exports: vec![],
623                    flags: ModuleNode::flags_from(i == 0, true, false),
624                };
625                node.set_reachable(true);
626                node
627            })
628            .collect();
629
630        let mut all_succs: Vec<usize> = Vec::new();
631        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
632        for src in 0..file_count {
633            let start = all_succs.len();
634            let mut seen = FxHashSet::default();
635            for &(s, t) in edges_spec {
636                if s == src && t < file_count && seen.insert(t) {
637                    all_succs.push(t);
638                }
639            }
640            let end = all_succs.len();
641            succ_ranges.push(start..end);
642        }
643
644        (modules, all_succs, succ_ranges)
645    }
646
647    #[test]
648    fn canonical_cycle_empty() {
649        let modules: Vec<ModuleNode> = vec![];
650        assert!(canonical_cycle(&[], &modules).is_empty());
651    }
652
653    #[test]
654    fn canonical_cycle_rotates_to_smallest_path() {
655        let (modules, _, _) = build_test_succs(3, &[]);
656        let result = canonical_cycle(&[2, 0, 1], &modules);
657        assert_eq!(result, vec![0, 1, 2]);
658    }
659
660    #[test]
661    fn canonical_cycle_already_canonical() {
662        let (modules, _, _) = build_test_succs(3, &[]);
663        let result = canonical_cycle(&[0, 1, 2], &modules);
664        assert_eq!(result, vec![0, 1, 2]);
665    }
666
667    #[test]
668    fn canonical_cycle_single_node() {
669        let (modules, _, _) = build_test_succs(1, &[]);
670        let result = canonical_cycle(&[0], &modules);
671        assert_eq!(result, vec![0]);
672    }
673
674    #[test]
675    fn try_record_cycle_inserts_new_cycle() {
676        let (modules, _, _) = build_test_succs(3, &[]);
677        let mut seen = FxHashSet::default();
678        let mut cycles = Vec::new();
679
680        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
681        assert_eq!(cycles.len(), 1);
682        assert_eq!(cycles[0], vec![0, 1, 2]);
683    }
684
685    #[test]
686    fn try_record_cycle_deduplicates_rotated_cycle() {
687        let (modules, _, _) = build_test_succs(3, &[]);
688        let mut seen = FxHashSet::default();
689        let mut cycles = Vec::new();
690
691        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
692        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
693        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
694
695        assert_eq!(
696            cycles.len(),
697            1,
698            "rotations of the same cycle should be deduped"
699        );
700    }
701
702    #[test]
703    fn try_record_cycle_single_node_self_loop() {
704        let (modules, _, _) = build_test_succs(1, &[]);
705        let mut seen = FxHashSet::default();
706        let mut cycles = Vec::new();
707
708        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
709        assert_eq!(cycles.len(), 1);
710        assert_eq!(cycles[0], vec![0]);
711    }
712
713    #[test]
714    fn try_record_cycle_distinct_cycles_both_recorded() {
715        let (modules, _, _) = build_test_succs(4, &[]);
716        let mut seen = FxHashSet::default();
717        let mut cycles = Vec::new();
718
719        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
720        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
721
722        assert_eq!(cycles.len(), 2);
723    }
724
725    #[test]
726    fn successor_map_empty_graph() {
727        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
728        let succs = SuccessorMap {
729            all_succs: &all_succs,
730            succ_ranges: &succ_ranges,
731            modules: &modules,
732        };
733        assert!(succs.all_succs.is_empty());
734        assert!(succs.succ_ranges.is_empty());
735    }
736
737    #[test]
738    fn successor_map_single_node_self_edge() {
739        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
740        let succs = SuccessorMap {
741            all_succs: &all_succs,
742            succ_ranges: &succ_ranges,
743            modules: &modules,
744        };
745        assert_eq!(succs.all_succs.len(), 1);
746        assert_eq!(succs.all_succs[0], 0);
747        assert_eq!(succs.succ_ranges[0], 0..1);
748    }
749
750    #[test]
751    fn successor_map_deduplicates_edges() {
752        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
753        let succs = SuccessorMap {
754            all_succs: &all_succs,
755            succ_ranges: &succ_ranges,
756            modules: &modules,
757        };
758        let range = &succs.succ_ranges[0];
759        assert_eq!(
760            range.end - range.start,
761            1,
762            "duplicate edges should be deduped"
763        );
764    }
765
766    #[test]
767    fn successor_map_multiple_successors() {
768        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
769        let succs = SuccessorMap {
770            all_succs: &all_succs,
771            succ_ranges: &succ_ranges,
772            modules: &modules,
773        };
774        let range = &succs.succ_ranges[0];
775        assert_eq!(range.end - range.start, 3);
776        for i in 1..4 {
777            let r = &succs.succ_ranges[i];
778            assert_eq!(r.end - r.start, 0);
779        }
780    }
781
782    #[test]
783    fn dfs_find_cycles_from_isolated_node() {
784        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
785        let succs = SuccessorMap {
786            all_succs: &all_succs,
787            succ_ranges: &succ_ranges,
788            modules: &modules,
789        };
790        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
791        let mut seen = FxHashSet::default();
792        let mut cycles = Vec::new();
793
794        dfs_find_cycles_from_for_test(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
795        assert!(cycles.is_empty(), "isolated node should have no cycles");
796    }
797
798    #[test]
799    fn dfs_find_cycles_from_simple_two_cycle() {
800        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
801        let succs = SuccessorMap {
802            all_succs: &all_succs,
803            succ_ranges: &succ_ranges,
804            modules: &modules,
805        };
806        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
807        let mut seen = FxHashSet::default();
808        let mut cycles = Vec::new();
809
810        dfs_find_cycles_from_for_test(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
811        assert_eq!(cycles.len(), 1);
812        assert_eq!(cycles[0].len(), 2);
813    }
814
815    #[test]
816    fn dfs_find_cycles_from_diamond_graph() {
817        let (modules, all_succs, succ_ranges) =
818            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
819        let succs = SuccessorMap {
820            all_succs: &all_succs,
821            succ_ranges: &succ_ranges,
822            modules: &modules,
823        };
824        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
825        let mut seen = FxHashSet::default();
826        let mut cycles = Vec::new();
827
828        dfs_find_cycles_from_for_test(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
829        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
830        assert!(cycles.iter().all(|c| c.len() == 3));
831    }
832
833    #[test]
834    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
835        let (modules, all_succs, succ_ranges) =
836            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
837        let succs = SuccessorMap {
838            all_succs: &all_succs,
839            succ_ranges: &succ_ranges,
840            modules: &modules,
841        };
842        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
843        let mut seen = FxHashSet::default();
844        let mut cycles = Vec::new();
845
846        dfs_find_cycles_from_for_test(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
847        assert!(
848            cycles.is_empty(),
849            "depth_limit=3 should prevent finding a 4-node cycle"
850        );
851    }
852
853    #[test]
854    fn dfs_find_cycles_from_depth_limit_exact_match() {
855        let (modules, all_succs, succ_ranges) =
856            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
857        let succs = SuccessorMap {
858            all_succs: &all_succs,
859            succ_ranges: &succ_ranges,
860            modules: &modules,
861        };
862        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
863        let mut seen = FxHashSet::default();
864        let mut cycles = Vec::new();
865
866        dfs_find_cycles_from_for_test(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
867        assert_eq!(
868            cycles.len(),
869            1,
870            "depth_limit=4 should find the 4-node cycle"
871        );
872        assert_eq!(cycles[0].len(), 4);
873    }
874
875    #[test]
876    fn dfs_find_cycles_from_respects_max_cycles() {
877        let edges: Vec<(usize, usize)> = (0..4)
878            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
879            .collect();
880        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
881        let succs = SuccessorMap {
882            all_succs: &all_succs,
883            succ_ranges: &succ_ranges,
884            modules: &modules,
885        };
886        let scc_set: FxHashSet<usize> = (0..4).collect();
887        let mut seen = FxHashSet::default();
888        let mut cycles = Vec::new();
889
890        dfs_find_cycles_from_for_test(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
891        assert!(
892            cycles.len() <= 2,
893            "should respect max_cycles limit, got {}",
894            cycles.len()
895        );
896    }
897
898    #[test]
899    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
900        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
901        let succs = SuccessorMap {
902            all_succs: &all_succs,
903            succ_ranges: &succ_ranges,
904            modules: &modules,
905        };
906        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
907        let mut seen = FxHashSet::default();
908        let mut cycles = Vec::new();
909
910        for depth in 2..=3 {
911            dfs_find_cycles_from_for_test(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
912        }
913        assert!(
914            cycles.is_empty(),
915            "should not find cycles through nodes outside the SCC set"
916        );
917    }
918
919    #[test]
920    fn enumerate_elementary_cycles_empty_scc() {
921        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
922        let succs = SuccessorMap {
923            all_succs: &all_succs,
924            succ_ranges: &succ_ranges,
925            modules: &modules,
926        };
927        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
928        assert!(cycles.is_empty());
929    }
930
931    #[test]
932    fn enumerate_elementary_cycles_max_cycles_limit() {
933        let edges: Vec<(usize, usize)> = (0..4)
934            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
935            .collect();
936        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
937        let succs = SuccessorMap {
938            all_succs: &all_succs,
939            succ_ranges: &succ_ranges,
940            modules: &modules,
941        };
942        let scc_nodes: Vec<usize> = (0..4).collect();
943
944        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
945        assert!(
946            cycles.len() <= 3,
947            "should respect max_cycles=3 limit, got {}",
948            cycles.len()
949        );
950    }
951
952    #[test]
953    fn enumerate_elementary_cycles_finds_all_in_triangle() {
954        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
955        let succs = SuccessorMap {
956            all_succs: &all_succs,
957            succ_ranges: &succ_ranges,
958            modules: &modules,
959        };
960        let scc_nodes: Vec<usize> = vec![0, 1, 2];
961
962        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
963        assert_eq!(cycles.len(), 1);
964        assert_eq!(cycles[0].len(), 3);
965    }
966
967    #[test]
968    fn enumerate_elementary_cycles_iterative_deepening_order() {
969        let (modules, all_succs, succ_ranges) =
970            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
971        let succs = SuccessorMap {
972            all_succs: &all_succs,
973            succ_ranges: &succ_ranges,
974            modules: &modules,
975        };
976        let scc_nodes: Vec<usize> = vec![0, 1, 2];
977
978        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
979        assert!(cycles.len() >= 2, "should find at least 2 cycles");
980        assert!(
981            cycles[0].len() <= cycles[cycles.len() - 1].len(),
982            "shorter cycles should be found before longer ones"
983        );
984    }
985
986    #[test]
987    fn find_cycles_max_cycles_per_scc_respected() {
988        let edges: Vec<(u32, u32)> = (0..5)
989            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
990            .collect();
991        let graph = build_cycle_graph(5, &edges);
992        let cycles = graph.find_cycles();
993        assert!(
994            cycles.len() <= 20,
995            "should cap at MAX_CYCLES_PER_SCC, got {}",
996            cycles.len()
997        );
998        assert!(
999            !cycles.is_empty(),
1000            "dense graph should still find some cycles"
1001        );
1002    }
1003
1004    #[test]
1005    fn find_cycles_graph_with_no_cycles_returns_empty() {
1006        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1007        assert!(graph.find_cycles().is_empty());
1008    }
1009
1010    #[test]
1011    fn find_cycles_diamond_no_cycle() {
1012        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1013        assert!(graph.find_cycles().is_empty());
1014    }
1015
1016    #[test]
1017    fn find_cycles_diamond_with_back_edge() {
1018        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1019        let cycles = graph.find_cycles();
1020        assert!(
1021            cycles.len() >= 2,
1022            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1023            cycles.len()
1024        );
1025        assert_eq!(cycles[0].len(), 3);
1026    }
1027
1028    #[test]
1029    fn canonical_cycle_non_sequential_indices() {
1030        let (modules, _, _) = build_test_succs(5, &[]);
1031        let result = canonical_cycle(&[3, 1, 4], &modules);
1032        assert_eq!(result, vec![1, 4, 3]);
1033    }
1034
1035    #[test]
1036    fn canonical_cycle_different_starting_points_same_result() {
1037        let (modules, _, _) = build_test_succs(4, &[]);
1038        let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1039        let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1040        let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1041        let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1042        assert_eq!(r1, r2);
1043        assert_eq!(r2, r3);
1044        assert_eq!(r3, r4);
1045        assert_eq!(r1, vec![0, 1, 2, 3]);
1046    }
1047
1048    #[test]
1049    fn canonical_cycle_two_node_both_rotations() {
1050        let (modules, _, _) = build_test_succs(2, &[]);
1051        assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1052        assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1053    }
1054
1055    #[test]
1056    fn dfs_find_cycles_from_self_loop_not_found() {
1057        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1058        let succs = SuccessorMap {
1059            all_succs: &all_succs,
1060            succ_ranges: &succ_ranges,
1061            modules: &modules,
1062        };
1063        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1064        let mut seen = FxHashSet::default();
1065        let mut cycles = Vec::new();
1066
1067        for depth in 1..=3 {
1068            dfs_find_cycles_from_for_test(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1069        }
1070        assert!(
1071            cycles.is_empty(),
1072            "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1073        );
1074    }
1075
1076    #[test]
1077    fn enumerate_elementary_cycles_self_loop_not_found() {
1078        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1079        let succs = SuccessorMap {
1080            all_succs: &all_succs,
1081            succ_ranges: &succ_ranges,
1082            modules: &modules,
1083        };
1084        let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1085        assert!(
1086            cycles.is_empty(),
1087            "self-loop should not produce elementary cycles"
1088        );
1089    }
1090
1091    #[test]
1092    fn find_cycles_two_cycles_sharing_edge() {
1093        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1094        let cycles = graph.find_cycles();
1095        assert_eq!(
1096            cycles.len(),
1097            2,
1098            "two cycles sharing edge A->B should both be found, got {}",
1099            cycles.len()
1100        );
1101        assert!(
1102            cycles.iter().all(|c| c.len() == 3),
1103            "both cycles should have length 3"
1104        );
1105    }
1106
1107    #[test]
1108    fn enumerate_elementary_cycles_shared_edge() {
1109        let (modules, all_succs, succ_ranges) =
1110            build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1111        let succs = SuccessorMap {
1112            all_succs: &all_succs,
1113            succ_ranges: &succ_ranges,
1114            modules: &modules,
1115        };
1116        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1117        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1118        assert_eq!(
1119            cycles.len(),
1120            2,
1121            "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1122            cycles.len()
1123        );
1124    }
1125
1126    #[test]
1127    fn enumerate_elementary_cycles_pentagon_with_chords() {
1128        let (modules, all_succs, succ_ranges) =
1129            build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1130        let succs = SuccessorMap {
1131            all_succs: &all_succs,
1132            succ_ranges: &succ_ranges,
1133            modules: &modules,
1134        };
1135        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1136        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1137
1138        assert!(
1139            cycles.len() >= 3,
1140            "pentagon with chords should have at least 3 elementary cycles, got {}",
1141            cycles.len()
1142        );
1143        let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1144        assert_eq!(
1145            unique.len(),
1146            cycles.len(),
1147            "all enumerated cycles should be unique"
1148        );
1149        assert_eq!(
1150            cycles[0].len(),
1151            3,
1152            "shortest cycle in pentagon with chords should be length 3"
1153        );
1154    }
1155
1156    #[test]
1157    fn find_cycles_large_scc_complete_graph_k6() {
1158        let edges: Vec<(u32, u32)> = (0..6)
1159            .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1160            .collect();
1161        let graph = build_cycle_graph(6, &edges);
1162        let cycles = graph.find_cycles();
1163
1164        assert!(
1165            cycles.len() <= 20,
1166            "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1167            cycles.len()
1168        );
1169        assert_eq!(
1170            cycles.len(),
1171            20,
1172            "K6 has far more than 20 elementary cycles, so we should hit the cap"
1173        );
1174        assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1175    }
1176
1177    #[test]
1178    fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1179        let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1180        let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1181        let succs = SuccessorMap {
1182            all_succs: &all_succs,
1183            succ_ranges: &succ_ranges,
1184            modules: &modules,
1185        };
1186        let scc_nodes: Vec<usize> = (0..15).collect();
1187        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1188
1189        assert!(
1190            cycles.is_empty(),
1191            "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1192            cycles.len()
1193        );
1194    }
1195
1196    #[test]
1197    fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1198        let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1199        let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1200        let succs = SuccessorMap {
1201            all_succs: &all_succs,
1202            succ_ranges: &succ_ranges,
1203            modules: &modules,
1204        };
1205        let scc_nodes: Vec<usize> = (0..12).collect();
1206        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1207
1208        assert_eq!(
1209            cycles.len(),
1210            1,
1211            "a pure 12-node cycle should be found at the depth cap boundary"
1212        );
1213        assert_eq!(cycles[0].len(), 12);
1214    }
1215
1216    #[test]
1217    fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1218        let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1219        let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1220        let succs = SuccessorMap {
1221            all_succs: &all_succs,
1222            succ_ranges: &succ_ranges,
1223            modules: &modules,
1224        };
1225        let scc_nodes: Vec<usize> = (0..13).collect();
1226        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1227
1228        assert!(
1229            cycles.is_empty(),
1230            "13-node pure cycle exceeds depth cap of 12"
1231        );
1232    }
1233
1234    #[test]
1235    fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1236        let edges: Vec<(u32, u32)> = (0..7)
1237            .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1238            .collect();
1239        let graph = build_cycle_graph(7, &edges);
1240        let cycles = graph.find_cycles();
1241
1242        assert!(
1243            cycles.len() <= 20,
1244            "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1245            cycles.len()
1246        );
1247        assert_eq!(
1248            cycles.len(),
1249            20,
1250            "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1251        );
1252    }
1253
1254    #[test]
1255    fn find_cycles_two_dense_sccs_each_capped() {
1256        let mut edges: Vec<(u32, u32)> = Vec::new();
1257        for i in 0..4 {
1258            for j in 0..4 {
1259                if i != j {
1260                    edges.push((i, j));
1261                }
1262            }
1263        }
1264        for i in 4..8 {
1265            for j in 4..8 {
1266                if i != j {
1267                    edges.push((i, j));
1268                }
1269            }
1270        }
1271        let graph = build_cycle_graph(8, &edges);
1272        let cycles = graph.find_cycles();
1273
1274        assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1275        assert!(
1276            cycles.len() > 2,
1277            "should find multiple cycles across both SCCs, got {}",
1278            cycles.len()
1279        );
1280    }
1281
1282    mod proptests {
1283        use super::*;
1284        use proptest::prelude::*;
1285
1286        proptest! {
1287            /// A DAG (directed acyclic graph) should always have zero cycles.
1288            /// We construct a DAG by only allowing edges from lower to higher node indices.
1289            #[test]
1290            fn dag_has_no_cycles(
1291                file_count in 2..20usize,
1292                edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1293            ) {
1294                let dag_edges: Vec<(u32, u32)> = edge_pairs
1295                    .into_iter()
1296                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1297                    .collect();
1298
1299                let graph = build_cycle_graph(file_count, &dag_edges);
1300                let cycles = graph.find_cycles();
1301                prop_assert!(
1302                    cycles.is_empty(),
1303                    "DAG should have no cycles, but found {}",
1304                    cycles.len()
1305                );
1306            }
1307
1308            /// Adding mutual edges A->B->A should always detect a cycle.
1309            #[test]
1310            fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1311                let file_count = 2 + extra_nodes;
1312                let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1313                let cycles = graph.find_cycles();
1314                prop_assert!(
1315                    !cycles.is_empty(),
1316                    "A->B->A should always produce at least one cycle"
1317                );
1318                let has_pair_cycle = cycles.iter().any(|c| {
1319                    c.contains(&FileId(0)) && c.contains(&FileId(1))
1320                });
1321                prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1322            }
1323
1324            /// All cycle members should be valid FileId indices.
1325            #[test]
1326            fn cycle_members_are_valid_indices(
1327                file_count in 2..15usize,
1328                edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1329            ) {
1330                let edges: Vec<(u32, u32)> = edge_pairs
1331                    .into_iter()
1332                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1333                    .collect();
1334
1335                let graph = build_cycle_graph(file_count, &edges);
1336                let cycles = graph.find_cycles();
1337                for cycle in &cycles {
1338                    prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1339                    for file_id in cycle {
1340                        prop_assert!(
1341                            (file_id.0 as usize) < file_count,
1342                            "FileId {} exceeds file count {}",
1343                            file_id.0, file_count
1344                        );
1345                    }
1346                }
1347            }
1348
1349            /// Cycles should be sorted by length (shortest first).
1350            #[test]
1351            fn cycles_sorted_by_length(
1352                file_count in 3..12usize,
1353                edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1354            ) {
1355                let edges: Vec<(u32, u32)> = edge_pairs
1356                    .into_iter()
1357                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1358                    .collect();
1359
1360                let graph = build_cycle_graph(file_count, &edges);
1361                let cycles = graph.find_cycles();
1362                for window in cycles.windows(2) {
1363                    prop_assert!(
1364                        window[0].len() <= window[1].len(),
1365                        "Cycles should be sorted by length: {} > {}",
1366                        window[0].len(), window[1].len()
1367                    );
1368                }
1369            }
1370        }
1371    }
1372
1373    /// Build a cycle graph where specific edges are type-only.
1374    fn build_cycle_graph_with_type_only(
1375        file_count: usize,
1376        edges_spec: &[(u32, u32, bool)], // (source, target, is_type_only)
1377    ) -> ModuleGraph {
1378        let files: Vec<DiscoveredFile> = (0..file_count)
1379            .map(|i| DiscoveredFile {
1380                id: FileId(i as u32),
1381                path: PathBuf::from(format!("/project/file{i}.ts")),
1382                size_bytes: 100,
1383            })
1384            .collect();
1385
1386        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
1387            .map(|i| {
1388                let imports: Vec<ResolvedImport> = edges_spec
1389                    .iter()
1390                    .filter(|(src, _, _)| *src == i as u32)
1391                    .map(|(_, tgt, type_only)| ResolvedImport {
1392                        info: ImportInfo {
1393                            source: format!("./file{tgt}"),
1394                            imported_name: ImportedName::Named("x".to_string()),
1395                            local_name: "x".to_string(),
1396                            is_type_only: *type_only,
1397                            from_style: false,
1398                            span: oxc_span::Span::new(0, 10),
1399                            source_span: oxc_span::Span::default(),
1400                        },
1401                        target: ResolveResult::InternalModule(FileId(*tgt)),
1402                    })
1403                    .collect();
1404
1405                ResolvedModule {
1406                    file_id: FileId(i as u32),
1407                    path: PathBuf::from(format!("/project/file{i}.ts")),
1408                    exports: vec![fallow_types::extract::ExportInfo {
1409                        name: ExportName::Named("x".to_string()),
1410                        local_name: Some("x".to_string()),
1411                        is_type_only: false,
1412                        visibility: VisibilityTag::None,
1413                        span: oxc_span::Span::new(0, 20),
1414                        members: vec![],
1415                        is_side_effect_used: false,
1416                        super_class: None,
1417                    }],
1418                    re_exports: vec![],
1419                    resolved_imports: imports,
1420                    resolved_dynamic_imports: vec![],
1421                    resolved_dynamic_patterns: vec![],
1422                    member_accesses: vec![],
1423                    whole_object_uses: vec![],
1424                    has_cjs_exports: false,
1425                    has_angular_component_template_url: false,
1426                    unused_import_bindings: FxHashSet::default(),
1427                    type_referenced_import_bindings: vec![],
1428                    value_referenced_import_bindings: vec![],
1429                    namespace_object_aliases: vec![],
1430                }
1431            })
1432            .collect();
1433
1434        let entry_points = vec![EntryPoint {
1435            path: PathBuf::from("/project/file0.ts"),
1436            source: EntryPointSource::PackageJsonMain,
1437        }];
1438
1439        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1440    }
1441
1442    #[test]
1443    fn type_only_bidirectional_import_not_a_cycle() {
1444        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, true), (1, 0, true)]);
1445        let cycles = graph.find_cycles();
1446        assert!(
1447            cycles.is_empty(),
1448            "type-only bidirectional imports should not be reported as cycles"
1449        );
1450    }
1451
1452    #[test]
1453    fn mixed_type_and_value_import_not_a_cycle() {
1454        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, true)]);
1455        let cycles = graph.find_cycles();
1456        assert!(
1457            cycles.is_empty(),
1458            "A->B (value) + B->A (type-only) is not a runtime cycle"
1459        );
1460    }
1461
1462    #[test]
1463    fn both_value_imports_with_one_type_still_a_cycle() {
1464        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1465        let cycles = graph.find_cycles();
1466        assert!(
1467            !cycles.is_empty(),
1468            "bidirectional value imports should be reported as a cycle"
1469        );
1470    }
1471
1472    #[test]
1473    fn all_value_imports_still_a_cycle() {
1474        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1475        let cycles = graph.find_cycles();
1476        assert_eq!(cycles.len(), 1);
1477    }
1478
1479    #[test]
1480    fn three_node_type_only_cycle_not_reported() {
1481        let graph =
1482            build_cycle_graph_with_type_only(3, &[(0, 1, true), (1, 2, true), (2, 0, true)]);
1483        let cycles = graph.find_cycles();
1484        assert!(
1485            cycles.is_empty(),
1486            "three-node type-only cycle should not be reported"
1487        );
1488    }
1489
1490    #[test]
1491    fn three_node_cycle_one_value_edge_still_reported() {
1492        let graph =
1493            build_cycle_graph_with_type_only(3, &[(0, 1, false), (1, 2, true), (2, 0, true)]);
1494        let cycles = graph.find_cycles();
1495        assert!(
1496            cycles.is_empty(),
1497            "cycle broken by type-only edge in the middle should not be reported"
1498        );
1499    }
1500}