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