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