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                    missing_export_targets: vec![],
520                }
521            })
522            .collect();
523
524        let entry_points = vec![EntryPoint {
525            path: PathBuf::from("/project/file0.ts"),
526            source: EntryPointSource::PackageJsonMain,
527        }];
528
529        ModuleGraph::build(&resolved_modules, &entry_points, &files)
530    }
531
532    fn dfs_find_cycles_from_for_test(mut input: DfsCycleInput<'_>) {
533        dfs_find_cycles_from(&mut input);
534    }
535
536    #[test]
537    fn find_cycles_empty_graph() {
538        let graph = ModuleGraph::build(&[], &[], &[]);
539        assert!(graph.find_cycles().is_empty());
540    }
541
542    #[test]
543    fn find_cycles_no_cycles() {
544        let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
545        assert!(graph.find_cycles().is_empty());
546    }
547
548    #[test]
549    fn find_cycles_simple_two_node_cycle() {
550        let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
551        let cycles = graph.find_cycles();
552        assert_eq!(cycles.len(), 1);
553        assert_eq!(cycles[0].len(), 2);
554    }
555
556    #[test]
557    fn find_cycles_three_node_cycle() {
558        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
559        let cycles = graph.find_cycles();
560        assert_eq!(cycles.len(), 1);
561        assert_eq!(cycles[0].len(), 3);
562    }
563
564    #[test]
565    fn find_cycles_self_import_ignored() {
566        let graph = build_cycle_graph(1, &[(0, 0)]);
567        let cycles = graph.find_cycles();
568        assert!(
569            cycles.is_empty(),
570            "self-imports should not be reported as cycles"
571        );
572    }
573
574    #[test]
575    fn find_cycles_multiple_independent_cycles() {
576        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
577        let cycles = graph.find_cycles();
578        assert_eq!(cycles.len(), 2);
579        assert!(cycles.iter().all(|c| c.len() == 2));
580    }
581
582    #[test]
583    fn find_cycles_linear_chain_with_back_edge() {
584        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
585        let cycles = graph.find_cycles();
586        assert_eq!(cycles.len(), 1);
587        assert_eq!(cycles[0].len(), 3);
588        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
589        assert!(ids.contains(&1));
590        assert!(ids.contains(&2));
591        assert!(ids.contains(&3));
592        assert!(!ids.contains(&0));
593    }
594
595    #[test]
596    fn find_cycles_overlapping_cycles_enumerated() {
597        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
598        let cycles = graph.find_cycles();
599        assert_eq!(
600            cycles.len(),
601            2,
602            "should find 2 elementary cycles, not 1 SCC"
603        );
604        assert!(
605            cycles.iter().all(|c| c.len() == 2),
606            "both cycles should have length 2"
607        );
608    }
609
610    #[test]
611    fn find_cycles_deterministic_ordering() {
612        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
613        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
614        let cycles1 = graph1.find_cycles();
615        let cycles2 = graph2.find_cycles();
616        assert_eq!(cycles1.len(), cycles2.len());
617        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
618            let paths1: Vec<&PathBuf> = c1
619                .iter()
620                .map(|f| &graph1.modules[f.0 as usize].path)
621                .collect();
622            let paths2: Vec<&PathBuf> = c2
623                .iter()
624                .map(|f| &graph2.modules[f.0 as usize].path)
625                .collect();
626            assert_eq!(paths1, paths2);
627        }
628    }
629
630    #[test]
631    fn find_cycles_sorted_by_length() {
632        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
633        let cycles = graph.find_cycles();
634        assert_eq!(cycles.len(), 2);
635        assert!(
636            cycles[0].len() <= cycles[1].len(),
637            "cycles should be sorted by length"
638        );
639    }
640
641    #[test]
642    fn find_cycles_large_cycle() {
643        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
644        let graph = build_cycle_graph(10, &edges);
645        let cycles = graph.find_cycles();
646        assert_eq!(cycles.len(), 1);
647        assert_eq!(cycles[0].len(), 10);
648    }
649
650    #[test]
651    fn find_cycles_complex_scc_multiple_elementary() {
652        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
653        let cycles = graph.find_cycles();
654        assert!(
655            cycles.len() >= 2,
656            "should find at least 2 elementary cycles, got {}",
657            cycles.len()
658        );
659        assert!(cycles.iter().all(|c| c.len() <= 4));
660    }
661
662    #[test]
663    fn find_cycles_no_duplicate_cycles() {
664        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
665        let cycles = graph.find_cycles();
666        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
667        assert_eq!(cycles[0].len(), 3);
668    }
669
670    /// Build lightweight `ModuleNode` stubs and successor data for unit tests.
671    ///
672    /// `edges_spec` is a list of (source, target) pairs (0-indexed).
673    /// Returns (modules, all_succs, succ_ranges) suitable for constructing a `SuccessorMap`.
674    #[expect(
675        clippy::cast_possible_truncation,
676        reason = "test file counts are trivially small"
677    )]
678    fn build_test_succs(
679        file_count: usize,
680        edges_spec: &[(usize, usize)],
681    ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
682        let modules: Vec<ModuleNode> = (0..file_count)
683            .map(|i| {
684                let mut node = ModuleNode {
685                    file_id: FileId(i as u32),
686                    path: PathBuf::from(format!("/project/file{i}.ts")),
687                    edge_range: 0..0,
688                    exports: vec![],
689                    re_exports: vec![],
690                    flags: ModuleNode::flags_from(i == 0, true, false),
691                };
692                node.set_reachable(true);
693                node
694            })
695            .collect();
696
697        let mut all_succs: Vec<usize> = Vec::new();
698        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
699        for src in 0..file_count {
700            let start = all_succs.len();
701            let mut seen = FxHashSet::default();
702            for &(s, t) in edges_spec {
703                if s == src && t < file_count && seen.insert(t) {
704                    all_succs.push(t);
705                }
706            }
707            let end = all_succs.len();
708            succ_ranges.push(start..end);
709        }
710
711        (modules, all_succs, succ_ranges)
712    }
713
714    #[test]
715    fn canonical_cycle_empty() {
716        let modules: Vec<ModuleNode> = vec![];
717        assert!(canonical_cycle(&[], &modules).is_empty());
718    }
719
720    #[test]
721    fn canonical_cycle_rotates_to_smallest_path() {
722        let (modules, _, _) = build_test_succs(3, &[]);
723        let result = canonical_cycle(&[2, 0, 1], &modules);
724        assert_eq!(result, vec![0, 1, 2]);
725    }
726
727    #[test]
728    fn canonical_cycle_already_canonical() {
729        let (modules, _, _) = build_test_succs(3, &[]);
730        let result = canonical_cycle(&[0, 1, 2], &modules);
731        assert_eq!(result, vec![0, 1, 2]);
732    }
733
734    #[test]
735    fn canonical_cycle_single_node() {
736        let (modules, _, _) = build_test_succs(1, &[]);
737        let result = canonical_cycle(&[0], &modules);
738        assert_eq!(result, vec![0]);
739    }
740
741    #[test]
742    fn try_record_cycle_inserts_new_cycle() {
743        let (modules, _, _) = build_test_succs(3, &[]);
744        let mut seen = FxHashSet::default();
745        let mut cycles = Vec::new();
746
747        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
748        assert_eq!(cycles.len(), 1);
749        assert_eq!(cycles[0], vec![0, 1, 2]);
750    }
751
752    #[test]
753    fn try_record_cycle_deduplicates_rotated_cycle() {
754        let (modules, _, _) = build_test_succs(3, &[]);
755        let mut seen = FxHashSet::default();
756        let mut cycles = Vec::new();
757
758        try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
759        try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
760        try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
761
762        assert_eq!(
763            cycles.len(),
764            1,
765            "rotations of the same cycle should be deduped"
766        );
767    }
768
769    #[test]
770    fn try_record_cycle_single_node_self_loop() {
771        let (modules, _, _) = build_test_succs(1, &[]);
772        let mut seen = FxHashSet::default();
773        let mut cycles = Vec::new();
774
775        try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
776        assert_eq!(cycles.len(), 1);
777        assert_eq!(cycles[0], vec![0]);
778    }
779
780    #[test]
781    fn try_record_cycle_distinct_cycles_both_recorded() {
782        let (modules, _, _) = build_test_succs(4, &[]);
783        let mut seen = FxHashSet::default();
784        let mut cycles = Vec::new();
785
786        try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
787        try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
788
789        assert_eq!(cycles.len(), 2);
790    }
791
792    #[test]
793    fn successor_map_empty_graph() {
794        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
795        let succs = SuccessorMap {
796            all_succs: &all_succs,
797            succ_ranges: &succ_ranges,
798            modules: &modules,
799        };
800        assert!(succs.all_succs.is_empty());
801        assert!(succs.succ_ranges.is_empty());
802    }
803
804    #[test]
805    fn successor_map_single_node_self_edge() {
806        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
807        let succs = SuccessorMap {
808            all_succs: &all_succs,
809            succ_ranges: &succ_ranges,
810            modules: &modules,
811        };
812        assert_eq!(succs.all_succs.len(), 1);
813        assert_eq!(succs.all_succs[0], 0);
814        assert_eq!(succs.succ_ranges[0], 0..1);
815    }
816
817    #[test]
818    fn successor_map_deduplicates_edges() {
819        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
820        let succs = SuccessorMap {
821            all_succs: &all_succs,
822            succ_ranges: &succ_ranges,
823            modules: &modules,
824        };
825        let range = &succs.succ_ranges[0];
826        assert_eq!(
827            range.end - range.start,
828            1,
829            "duplicate edges should be deduped"
830        );
831    }
832
833    #[test]
834    fn successor_map_multiple_successors() {
835        let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
836        let succs = SuccessorMap {
837            all_succs: &all_succs,
838            succ_ranges: &succ_ranges,
839            modules: &modules,
840        };
841        let range = &succs.succ_ranges[0];
842        assert_eq!(range.end - range.start, 3);
843        for i in 1..4 {
844            let r = &succs.succ_ranges[i];
845            assert_eq!(r.end - r.start, 0);
846        }
847    }
848
849    #[test]
850    fn dfs_find_cycles_from_isolated_node() {
851        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
852        let succs = SuccessorMap {
853            all_succs: &all_succs,
854            succ_ranges: &succ_ranges,
855            modules: &modules,
856        };
857        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
858        let mut seen = FxHashSet::default();
859        let mut cycles = Vec::new();
860
861        dfs_find_cycles_from_for_test(DfsCycleInput {
862            start: 0,
863            depth_limit: 2,
864            scc_set: &scc_set,
865            succs: &succs,
866            max_cycles: 10,
867            seen: &mut seen,
868            cycles: &mut cycles,
869        });
870        assert!(cycles.is_empty(), "isolated node should have no cycles");
871    }
872
873    #[test]
874    fn dfs_find_cycles_from_simple_two_cycle() {
875        let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
876        let succs = SuccessorMap {
877            all_succs: &all_succs,
878            succ_ranges: &succ_ranges,
879            modules: &modules,
880        };
881        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
882        let mut seen = FxHashSet::default();
883        let mut cycles = Vec::new();
884
885        dfs_find_cycles_from_for_test(DfsCycleInput {
886            start: 0,
887            depth_limit: 2,
888            scc_set: &scc_set,
889            succs: &succs,
890            max_cycles: 10,
891            seen: &mut seen,
892            cycles: &mut cycles,
893        });
894        assert_eq!(cycles.len(), 1);
895        assert_eq!(cycles[0].len(), 2);
896    }
897
898    #[test]
899    fn dfs_find_cycles_from_diamond_graph() {
900        let (modules, all_succs, succ_ranges) =
901            build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
902        let succs = SuccessorMap {
903            all_succs: &all_succs,
904            succ_ranges: &succ_ranges,
905            modules: &modules,
906        };
907        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
908        let mut seen = FxHashSet::default();
909        let mut cycles = Vec::new();
910
911        dfs_find_cycles_from_for_test(DfsCycleInput {
912            start: 0,
913            depth_limit: 3,
914            scc_set: &scc_set,
915            succs: &succs,
916            max_cycles: 10,
917            seen: &mut seen,
918            cycles: &mut cycles,
919        });
920        assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
921        assert!(cycles.iter().all(|c| c.len() == 3));
922    }
923
924    #[test]
925    fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
926        let (modules, all_succs, succ_ranges) =
927            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
928        let succs = SuccessorMap {
929            all_succs: &all_succs,
930            succ_ranges: &succ_ranges,
931            modules: &modules,
932        };
933        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
934        let mut seen = FxHashSet::default();
935        let mut cycles = Vec::new();
936
937        dfs_find_cycles_from_for_test(DfsCycleInput {
938            start: 0,
939            depth_limit: 3,
940            scc_set: &scc_set,
941            succs: &succs,
942            max_cycles: 10,
943            seen: &mut seen,
944            cycles: &mut cycles,
945        });
946        assert!(
947            cycles.is_empty(),
948            "depth_limit=3 should prevent finding a 4-node cycle"
949        );
950    }
951
952    #[test]
953    fn dfs_find_cycles_from_depth_limit_exact_match() {
954        let (modules, all_succs, succ_ranges) =
955            build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
956        let succs = SuccessorMap {
957            all_succs: &all_succs,
958            succ_ranges: &succ_ranges,
959            modules: &modules,
960        };
961        let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
962        let mut seen = FxHashSet::default();
963        let mut cycles = Vec::new();
964
965        dfs_find_cycles_from_for_test(DfsCycleInput {
966            start: 0,
967            depth_limit: 4,
968            scc_set: &scc_set,
969            succs: &succs,
970            max_cycles: 10,
971            seen: &mut seen,
972            cycles: &mut cycles,
973        });
974        assert_eq!(
975            cycles.len(),
976            1,
977            "depth_limit=4 should find the 4-node cycle"
978        );
979        assert_eq!(cycles[0].len(), 4);
980    }
981
982    #[test]
983    fn dfs_find_cycles_from_respects_max_cycles() {
984        let edges: Vec<(usize, usize)> = (0..4)
985            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
986            .collect();
987        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
988        let succs = SuccessorMap {
989            all_succs: &all_succs,
990            succ_ranges: &succ_ranges,
991            modules: &modules,
992        };
993        let scc_set: FxHashSet<usize> = (0..4).collect();
994        let mut seen = FxHashSet::default();
995        let mut cycles = Vec::new();
996
997        dfs_find_cycles_from_for_test(DfsCycleInput {
998            start: 0,
999            depth_limit: 2,
1000            scc_set: &scc_set,
1001            succs: &succs,
1002            max_cycles: 2,
1003            seen: &mut seen,
1004            cycles: &mut cycles,
1005        });
1006        assert!(
1007            cycles.len() <= 2,
1008            "should respect max_cycles limit, got {}",
1009            cycles.len()
1010        );
1011    }
1012
1013    #[test]
1014    fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
1015        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1016        let succs = SuccessorMap {
1017            all_succs: &all_succs,
1018            succ_ranges: &succ_ranges,
1019            modules: &modules,
1020        };
1021        let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
1022        let mut seen = FxHashSet::default();
1023        let mut cycles = Vec::new();
1024
1025        for depth in 2..=3 {
1026            dfs_find_cycles_from_for_test(DfsCycleInput {
1027                start: 0,
1028                depth_limit: depth,
1029                scc_set: &scc_set,
1030                succs: &succs,
1031                max_cycles: 10,
1032                seen: &mut seen,
1033                cycles: &mut cycles,
1034            });
1035        }
1036        assert!(
1037            cycles.is_empty(),
1038            "should not find cycles through nodes outside the SCC set"
1039        );
1040    }
1041
1042    #[test]
1043    fn enumerate_elementary_cycles_empty_scc() {
1044        let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
1045        let succs = SuccessorMap {
1046            all_succs: &all_succs,
1047            succ_ranges: &succ_ranges,
1048            modules: &modules,
1049        };
1050        let cycles = enumerate_elementary_cycles(&[], &succs, 10);
1051        assert!(cycles.is_empty());
1052    }
1053
1054    #[test]
1055    fn enumerate_elementary_cycles_max_cycles_limit() {
1056        let edges: Vec<(usize, usize)> = (0..4)
1057            .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
1058            .collect();
1059        let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
1060        let succs = SuccessorMap {
1061            all_succs: &all_succs,
1062            succ_ranges: &succ_ranges,
1063            modules: &modules,
1064        };
1065        let scc_nodes: Vec<usize> = (0..4).collect();
1066
1067        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
1068        assert!(
1069            cycles.len() <= 3,
1070            "should respect max_cycles=3 limit, got {}",
1071            cycles.len()
1072        );
1073    }
1074
1075    #[test]
1076    fn enumerate_elementary_cycles_finds_all_in_triangle() {
1077        let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
1078        let succs = SuccessorMap {
1079            all_succs: &all_succs,
1080            succ_ranges: &succ_ranges,
1081            modules: &modules,
1082        };
1083        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1084
1085        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1086        assert_eq!(cycles.len(), 1);
1087        assert_eq!(cycles[0].len(), 3);
1088    }
1089
1090    #[test]
1091    fn enumerate_elementary_cycles_iterative_deepening_order() {
1092        let (modules, all_succs, succ_ranges) =
1093            build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
1094        let succs = SuccessorMap {
1095            all_succs: &all_succs,
1096            succ_ranges: &succ_ranges,
1097            modules: &modules,
1098        };
1099        let scc_nodes: Vec<usize> = vec![0, 1, 2];
1100
1101        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1102        assert!(cycles.len() >= 2, "should find at least 2 cycles");
1103        assert!(
1104            cycles[0].len() <= cycles[cycles.len() - 1].len(),
1105            "shorter cycles should be found before longer ones"
1106        );
1107    }
1108
1109    #[test]
1110    fn find_cycles_max_cycles_per_scc_respected() {
1111        let edges: Vec<(u32, u32)> = (0..5)
1112            .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
1113            .collect();
1114        let graph = build_cycle_graph(5, &edges);
1115        let cycles = graph.find_cycles();
1116        assert!(
1117            cycles.len() <= 20,
1118            "should cap at MAX_CYCLES_PER_SCC, got {}",
1119            cycles.len()
1120        );
1121        assert!(
1122            !cycles.is_empty(),
1123            "dense graph should still find some cycles"
1124        );
1125    }
1126
1127    #[test]
1128    fn find_cycles_graph_with_no_cycles_returns_empty() {
1129        let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
1130        assert!(graph.find_cycles().is_empty());
1131    }
1132
1133    #[test]
1134    fn find_cycles_diamond_no_cycle() {
1135        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
1136        assert!(graph.find_cycles().is_empty());
1137    }
1138
1139    #[test]
1140    fn find_cycles_diamond_with_back_edge() {
1141        let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
1142        let cycles = graph.find_cycles();
1143        assert!(
1144            cycles.len() >= 2,
1145            "diamond with back-edge should have at least 2 elementary cycles, got {}",
1146            cycles.len()
1147        );
1148        assert_eq!(cycles[0].len(), 3);
1149    }
1150
1151    #[test]
1152    fn canonical_cycle_non_sequential_indices() {
1153        let (modules, _, _) = build_test_succs(5, &[]);
1154        let result = canonical_cycle(&[3, 1, 4], &modules);
1155        assert_eq!(result, vec![1, 4, 3]);
1156    }
1157
1158    #[test]
1159    fn canonical_cycle_different_starting_points_same_result() {
1160        let (modules, _, _) = build_test_succs(4, &[]);
1161        let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1162        let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1163        let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1164        let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1165        assert_eq!(r1, r2);
1166        assert_eq!(r2, r3);
1167        assert_eq!(r3, r4);
1168        assert_eq!(r1, vec![0, 1, 2, 3]);
1169    }
1170
1171    #[test]
1172    fn canonical_cycle_two_node_both_rotations() {
1173        let (modules, _, _) = build_test_succs(2, &[]);
1174        assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1175        assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1176    }
1177
1178    #[test]
1179    fn dfs_find_cycles_from_self_loop_not_found() {
1180        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1181        let succs = SuccessorMap {
1182            all_succs: &all_succs,
1183            succ_ranges: &succ_ranges,
1184            modules: &modules,
1185        };
1186        let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1187        let mut seen = FxHashSet::default();
1188        let mut cycles = Vec::new();
1189
1190        for depth in 1..=3 {
1191            dfs_find_cycles_from_for_test(DfsCycleInput {
1192                start: 0,
1193                depth_limit: depth,
1194                scc_set: &scc_set,
1195                succs: &succs,
1196                max_cycles: 10,
1197                seen: &mut seen,
1198                cycles: &mut cycles,
1199            });
1200        }
1201        assert!(
1202            cycles.is_empty(),
1203            "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1204        );
1205    }
1206
1207    #[test]
1208    fn enumerate_elementary_cycles_self_loop_not_found() {
1209        let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1210        let succs = SuccessorMap {
1211            all_succs: &all_succs,
1212            succ_ranges: &succ_ranges,
1213            modules: &modules,
1214        };
1215        let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1216        assert!(
1217            cycles.is_empty(),
1218            "self-loop should not produce elementary cycles"
1219        );
1220    }
1221
1222    #[test]
1223    fn find_cycles_two_cycles_sharing_edge() {
1224        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1225        let cycles = graph.find_cycles();
1226        assert_eq!(
1227            cycles.len(),
1228            2,
1229            "two cycles sharing edge A->B should both be found, got {}",
1230            cycles.len()
1231        );
1232        assert!(
1233            cycles.iter().all(|c| c.len() == 3),
1234            "both cycles should have length 3"
1235        );
1236    }
1237
1238    #[test]
1239    fn enumerate_elementary_cycles_shared_edge() {
1240        let (modules, all_succs, succ_ranges) =
1241            build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1242        let succs = SuccessorMap {
1243            all_succs: &all_succs,
1244            succ_ranges: &succ_ranges,
1245            modules: &modules,
1246        };
1247        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1248        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1249        assert_eq!(
1250            cycles.len(),
1251            2,
1252            "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1253            cycles.len()
1254        );
1255    }
1256
1257    #[test]
1258    fn enumerate_elementary_cycles_pentagon_with_chords() {
1259        let (modules, all_succs, succ_ranges) =
1260            build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1261        let succs = SuccessorMap {
1262            all_succs: &all_succs,
1263            succ_ranges: &succ_ranges,
1264            modules: &modules,
1265        };
1266        let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1267        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1268
1269        assert!(
1270            cycles.len() >= 3,
1271            "pentagon with chords should have at least 3 elementary cycles, got {}",
1272            cycles.len()
1273        );
1274        let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1275        assert_eq!(
1276            unique.len(),
1277            cycles.len(),
1278            "all enumerated cycles should be unique"
1279        );
1280        assert_eq!(
1281            cycles[0].len(),
1282            3,
1283            "shortest cycle in pentagon with chords should be length 3"
1284        );
1285    }
1286
1287    #[test]
1288    fn find_cycles_large_scc_complete_graph_k6() {
1289        let edges: Vec<(u32, u32)> = (0..6)
1290            .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1291            .collect();
1292        let graph = build_cycle_graph(6, &edges);
1293        let cycles = graph.find_cycles();
1294
1295        assert!(
1296            cycles.len() <= 20,
1297            "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1298            cycles.len()
1299        );
1300        assert_eq!(
1301            cycles.len(),
1302            20,
1303            "K6 has far more than 20 elementary cycles, so we should hit the cap"
1304        );
1305        assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1306    }
1307
1308    #[test]
1309    fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1310        let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1311        let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1312        let succs = SuccessorMap {
1313            all_succs: &all_succs,
1314            succ_ranges: &succ_ranges,
1315            modules: &modules,
1316        };
1317        let scc_nodes: Vec<usize> = (0..15).collect();
1318        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1319
1320        assert!(
1321            cycles.is_empty(),
1322            "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1323            cycles.len()
1324        );
1325    }
1326
1327    #[test]
1328    fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1329        let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1330        let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1331        let succs = SuccessorMap {
1332            all_succs: &all_succs,
1333            succ_ranges: &succ_ranges,
1334            modules: &modules,
1335        };
1336        let scc_nodes: Vec<usize> = (0..12).collect();
1337        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1338
1339        assert_eq!(
1340            cycles.len(),
1341            1,
1342            "a pure 12-node cycle should be found at the depth cap boundary"
1343        );
1344        assert_eq!(cycles[0].len(), 12);
1345    }
1346
1347    #[test]
1348    fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1349        let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1350        let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1351        let succs = SuccessorMap {
1352            all_succs: &all_succs,
1353            succ_ranges: &succ_ranges,
1354            modules: &modules,
1355        };
1356        let scc_nodes: Vec<usize> = (0..13).collect();
1357        let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1358
1359        assert!(
1360            cycles.is_empty(),
1361            "13-node pure cycle exceeds depth cap of 12"
1362        );
1363    }
1364
1365    #[test]
1366    fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1367        let edges: Vec<(u32, u32)> = (0..7)
1368            .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1369            .collect();
1370        let graph = build_cycle_graph(7, &edges);
1371        let cycles = graph.find_cycles();
1372
1373        assert!(
1374            cycles.len() <= 20,
1375            "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1376            cycles.len()
1377        );
1378        assert_eq!(
1379            cycles.len(),
1380            20,
1381            "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1382        );
1383    }
1384
1385    #[test]
1386    fn find_cycles_two_dense_sccs_each_capped() {
1387        let mut edges: Vec<(u32, u32)> = Vec::new();
1388        for i in 0..4 {
1389            for j in 0..4 {
1390                if i != j {
1391                    edges.push((i, j));
1392                }
1393            }
1394        }
1395        for i in 4..8 {
1396            for j in 4..8 {
1397                if i != j {
1398                    edges.push((i, j));
1399                }
1400            }
1401        }
1402        let graph = build_cycle_graph(8, &edges);
1403        let cycles = graph.find_cycles();
1404
1405        assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1406        assert!(
1407            cycles.len() > 2,
1408            "should find multiple cycles across both SCCs, got {}",
1409            cycles.len()
1410        );
1411    }
1412
1413    mod proptests {
1414        use super::*;
1415        use proptest::prelude::*;
1416
1417        proptest! {
1418            /// A DAG (directed acyclic graph) should always have zero cycles.
1419            /// We construct a DAG by only allowing edges from lower to higher node indices.
1420            #[test]
1421            fn dag_has_no_cycles(
1422                file_count in 2..20usize,
1423                edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1424            ) {
1425                let dag_edges: Vec<(u32, u32)> = edge_pairs
1426                    .into_iter()
1427                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1428                    .collect();
1429
1430                let graph = build_cycle_graph(file_count, &dag_edges);
1431                let cycles = graph.find_cycles();
1432                prop_assert!(
1433                    cycles.is_empty(),
1434                    "DAG should have no cycles, but found {}",
1435                    cycles.len()
1436                );
1437            }
1438
1439            /// Adding mutual edges A->B->A should always detect a cycle.
1440            #[test]
1441            fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1442                let file_count = 2 + extra_nodes;
1443                let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1444                let cycles = graph.find_cycles();
1445                prop_assert!(
1446                    !cycles.is_empty(),
1447                    "A->B->A should always produce at least one cycle"
1448                );
1449                let has_pair_cycle = cycles.iter().any(|c| {
1450                    c.contains(&FileId(0)) && c.contains(&FileId(1))
1451                });
1452                prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1453            }
1454
1455            /// All cycle members should be valid FileId indices.
1456            #[test]
1457            fn cycle_members_are_valid_indices(
1458                file_count in 2..15usize,
1459                edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1460            ) {
1461                let edges: Vec<(u32, u32)> = edge_pairs
1462                    .into_iter()
1463                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1464                    .collect();
1465
1466                let graph = build_cycle_graph(file_count, &edges);
1467                let cycles = graph.find_cycles();
1468                for cycle in &cycles {
1469                    prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1470                    for file_id in cycle {
1471                        prop_assert!(
1472                            (file_id.0 as usize) < file_count,
1473                            "FileId {} exceeds file count {}",
1474                            file_id.0, file_count
1475                        );
1476                    }
1477                }
1478            }
1479
1480            /// Cycles should be sorted by length (shortest first).
1481            #[test]
1482            fn cycles_sorted_by_length(
1483                file_count in 3..12usize,
1484                edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1485            ) {
1486                let edges: Vec<(u32, u32)> = edge_pairs
1487                    .into_iter()
1488                    .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1489                    .collect();
1490
1491                let graph = build_cycle_graph(file_count, &edges);
1492                let cycles = graph.find_cycles();
1493                for window in cycles.windows(2) {
1494                    prop_assert!(
1495                        window[0].len() <= window[1].len(),
1496                        "Cycles should be sorted by length: {} > {}",
1497                        window[0].len(), window[1].len()
1498                    );
1499                }
1500            }
1501        }
1502    }
1503
1504    /// Build a cycle graph where specific edges are type-only.
1505    fn build_cycle_graph_with_type_only(
1506        file_count: usize,
1507        edges_spec: &[(u32, u32, bool)], // (source, target, is_type_only)
1508    ) -> ModuleGraph {
1509        let files: Vec<DiscoveredFile> = (0..file_count)
1510            .map(|i| DiscoveredFile {
1511                id: FileId(i as u32),
1512                path: PathBuf::from(format!("/project/file{i}.ts")),
1513                size_bytes: 100,
1514            })
1515            .collect();
1516
1517        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
1518            .map(|i| {
1519                let imports: Vec<ResolvedImport> = edges_spec
1520                    .iter()
1521                    .filter(|(src, _, _)| *src == i as u32)
1522                    .map(|(_, tgt, type_only)| ResolvedImport {
1523                        info: ImportInfo {
1524                            source: format!("./file{tgt}"),
1525                            imported_name: ImportedName::Named("x".to_string()),
1526                            local_name: "x".to_string(),
1527                            is_type_only: *type_only,
1528                            is_type_only_star: false,
1529                            from_style: false,
1530                            span: oxc_span::Span::new(0, 10),
1531                            source_span: oxc_span::Span::default(),
1532                        },
1533                        target: ResolveResult::InternalModule(FileId(*tgt)),
1534                    })
1535                    .collect();
1536
1537                ResolvedModule {
1538                    file_id: FileId(i as u32),
1539                    path: PathBuf::from(format!("/project/file{i}.ts")),
1540                    exports: vec![fallow_types::extract::ExportInfo {
1541                        name: ExportName::Named("x".to_string()),
1542                        local_name: Some("x".to_string()),
1543                        is_type_only: false,
1544                        visibility: VisibilityTag::None,
1545                        expected_unused_reason: None,
1546                        span: oxc_span::Span::new(0, 20),
1547                        members: vec![],
1548                        is_side_effect_used: false,
1549                        super_class: None,
1550                        deprecated: false,
1551                        deprecated_reason: None,
1552                    }]
1553                    .into(),
1554                    re_exports: vec![],
1555                    resolved_imports: imports,
1556                    resolved_dynamic_imports: vec![],
1557                    resolved_dynamic_patterns: vec![],
1558                    member_accesses: vec![].into(),
1559                    semantic_facts: std::sync::Arc::default(),
1560                    whole_object_uses: std::sync::Arc::default(),
1561                    has_cjs_exports: false,
1562                    has_angular_component_template_url: false,
1563                    unused_import_bindings: FxHashSet::default(),
1564                    type_referenced_import_bindings: vec![],
1565                    value_referenced_import_bindings: vec![],
1566                    namespace_object_aliases: vec![],
1567                    exported_factory_returns: std::sync::Arc::default(),
1568                    exported_factory_return_object_shapes: std::sync::Arc::default(),
1569                    type_member_types: std::sync::Arc::default(),
1570                    missing_export_targets: vec![],
1571                }
1572            })
1573            .collect();
1574
1575        let entry_points = vec![EntryPoint {
1576            path: PathBuf::from("/project/file0.ts"),
1577            source: EntryPointSource::PackageJsonMain,
1578        }];
1579
1580        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1581    }
1582
1583    #[test]
1584    fn type_only_bidirectional_import_not_a_cycle() {
1585        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, true), (1, 0, true)]);
1586        let cycles = graph.find_cycles();
1587        assert!(
1588            cycles.is_empty(),
1589            "type-only bidirectional imports should not be reported as cycles"
1590        );
1591    }
1592
1593    #[test]
1594    fn mixed_type_and_value_import_not_a_cycle() {
1595        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, true)]);
1596        let cycles = graph.find_cycles();
1597        assert!(
1598            cycles.is_empty(),
1599            "A->B (value) + B->A (type-only) is not a runtime cycle"
1600        );
1601    }
1602
1603    #[test]
1604    fn both_value_imports_with_one_type_still_a_cycle() {
1605        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1606        let cycles = graph.find_cycles();
1607        assert!(
1608            !cycles.is_empty(),
1609            "bidirectional value imports should be reported as a cycle"
1610        );
1611    }
1612
1613    #[test]
1614    fn all_value_imports_still_a_cycle() {
1615        let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1616        let cycles = graph.find_cycles();
1617        assert_eq!(cycles.len(), 1);
1618    }
1619
1620    #[test]
1621    fn three_node_type_only_cycle_not_reported() {
1622        let graph =
1623            build_cycle_graph_with_type_only(3, &[(0, 1, true), (1, 2, true), (2, 0, true)]);
1624        let cycles = graph.find_cycles();
1625        assert!(
1626            cycles.is_empty(),
1627            "three-node type-only cycle should not be reported"
1628        );
1629    }
1630
1631    #[test]
1632    fn three_node_cycle_one_value_edge_still_reported() {
1633        let graph =
1634            build_cycle_graph_with_type_only(3, &[(0, 1, false), (1, 2, true), (2, 0, true)]);
1635        let cycles = graph.find_cycles();
1636        assert!(
1637            cycles.is_empty(),
1638            "cycle broken by type-only edge in the middle should not be reported"
1639        );
1640    }
1641}