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