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