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