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