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