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