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