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