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