1use 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
15pub struct CycleOptions {
16 pub ignore_lazy_imports: bool,
21}
22
23impl ModuleGraph {
24 #[must_use]
37 pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
38 self.find_cycles_with(CycleOptions::default())
39 }
40
41 #[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 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 #[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
163struct SccFrame {
165 node: usize,
166 succ_pos: usize,
167 succ_end: usize,
168}
169
170struct 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 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 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 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 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 #[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
281fn 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
324struct 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
392fn 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); 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 #[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 #[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 #[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 #[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 #[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 #[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 fn build_cycle_graph_with_type_only(
1506 file_count: usize,
1507 edges_spec: &[(u32, u32, bool)], ) -> 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}