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