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