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