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