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