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 #[expect(
36 clippy::expect_used,
37 reason = "Tarjan traversal only pops nodes that were pushed onto the SCC stack"
38 )]
39 pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
40 let n = self.modules.len();
41 if n == 0 {
42 return Vec::new();
43 }
44
45 let mut index_counter: u32 = 0;
46 let mut indices: Vec<u32> = vec![u32::MAX; n];
47 let mut lowlinks: Vec<u32> = vec![0; n];
48 let mut on_stack = FixedBitSet::with_capacity(n);
49 let mut stack: Vec<usize> = Vec::new();
50 let mut sccs: Vec<Vec<FileId>> = Vec::new();
51
52 struct Frame {
53 node: usize,
54 succ_pos: usize,
55 succ_end: usize,
56 }
57
58 let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
59 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
60 let mut seen_set = FxHashSet::default();
61 for module in &self.modules {
62 let start = all_succs.len();
63 seen_set.clear();
64 for edge in &self.edges[module.edge_range.clone()] {
65 if edge.symbols.iter().all(|s| s.is_type_only) {
66 continue;
67 }
68 let target = edge.target.0 as usize;
69 if target < n && seen_set.insert(target) {
70 all_succs.push(target);
71 }
72 }
73 let end = all_succs.len();
74 succ_ranges.push(start..end);
75 }
76
77 let mut dfs_stack: Vec<Frame> = Vec::new();
78
79 for start_node in 0..n {
80 if indices[start_node] != u32::MAX {
81 continue;
82 }
83
84 indices[start_node] = index_counter;
85 lowlinks[start_node] = index_counter;
86 index_counter += 1;
87 on_stack.insert(start_node);
88 stack.push(start_node);
89
90 let range = &succ_ranges[start_node];
91 dfs_stack.push(Frame {
92 node: start_node,
93 succ_pos: range.start,
94 succ_end: range.end,
95 });
96
97 while let Some(frame) = dfs_stack.last_mut() {
98 if frame.succ_pos < frame.succ_end {
99 let w = all_succs[frame.succ_pos];
100 frame.succ_pos += 1;
101
102 if indices[w] == u32::MAX {
103 indices[w] = index_counter;
104 lowlinks[w] = index_counter;
105 index_counter += 1;
106 on_stack.insert(w);
107 stack.push(w);
108
109 let range = &succ_ranges[w];
110 dfs_stack.push(Frame {
111 node: w,
112 succ_pos: range.start,
113 succ_end: range.end,
114 });
115 } else if on_stack.contains(w) {
116 let v = frame.node;
117 lowlinks[v] = lowlinks[v].min(indices[w]);
118 }
119 } else {
120 let v = frame.node;
121 let v_lowlink = lowlinks[v];
122 let v_index = indices[v];
123 dfs_stack.pop();
124
125 if let Some(parent) = dfs_stack.last_mut() {
126 lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
127 }
128
129 if v_lowlink == v_index {
130 let mut scc = Vec::new();
131 loop {
132 let w = stack.pop().expect("SCC stack should not be empty");
133 on_stack.set(w, false);
134 scc.push(FileId(w as u32));
135 if w == v {
136 break;
137 }
138 }
139 if scc.len() >= 2 {
140 sccs.push(scc);
141 }
142 }
143 }
144 }
145 }
146
147 self.enumerate_cycles_from_sccs(&sccs, &all_succs, &succ_ranges)
148 }
149
150 #[expect(
152 clippy::cast_possible_truncation,
153 reason = "file count is bounded by project size, well under u32::MAX"
154 )]
155 fn enumerate_cycles_from_sccs(
156 &self,
157 sccs: &[Vec<FileId>],
158 all_succs: &[usize],
159 succ_ranges: &[Range<usize>],
160 ) -> Vec<Vec<FileId>> {
161 const MAX_CYCLES_PER_SCC: usize = 20;
162
163 let succs = SuccessorMap {
164 all_succs,
165 succ_ranges,
166 modules: &self.modules,
167 };
168
169 let mut result: Vec<Vec<FileId>> = Vec::new();
170 let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
171
172 for scc in sccs {
173 if scc.len() == 2 {
174 let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
175 if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
176 cycle.swap(0, 1);
177 }
178 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
179 if seen_cycles.insert(key) {
180 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
181 }
182 continue;
183 }
184
185 let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
186 let elementary = enumerate_elementary_cycles(&scc_nodes, &succs, MAX_CYCLES_PER_SCC);
187
188 for cycle in elementary {
189 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
190 if seen_cycles.insert(key) {
191 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
192 }
193 }
194 }
195
196 result.sort_by(|a, b| {
197 a.len().cmp(&b.len()).then_with(|| {
198 self.modules[a[0].0 as usize]
199 .path
200 .cmp(&self.modules[b[0].0 as usize].path)
201 })
202 });
203
204 result
205 }
206}
207
208fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
210 if cycle.is_empty() {
211 return Vec::new();
212 }
213 let min_pos = cycle
214 .iter()
215 .enumerate()
216 .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
217 .map_or(0, |(i, _)| i);
218 let mut result = cycle[min_pos..].to_vec();
219 result.extend_from_slice(&cycle[..min_pos]);
220 result
221}
222
223struct CycleFrame {
224 succ_pos: usize,
225 succ_end: usize,
226}
227
228struct SuccessorMap<'a> {
229 all_succs: &'a [usize],
230 succ_ranges: &'a [Range<usize>],
231 modules: &'a [ModuleNode],
232}
233
234#[expect(
235 clippy::cast_possible_truncation,
236 reason = "file count is bounded by project size, well under u32::MAX"
237)]
238fn try_record_cycle(
239 path: &[usize],
240 modules: &[ModuleNode],
241 seen: &mut FxHashSet<Vec<u32>>,
242 cycles: &mut Vec<Vec<usize>>,
243) {
244 let canonical = canonical_cycle(path, modules);
245 let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
246 if seen.insert(key) {
247 cycles.push(canonical);
248 }
249}
250
251fn dfs_find_cycles_from(
256 start: usize,
257 depth_limit: usize,
258 scc_set: &FxHashSet<usize>,
259 succs: &SuccessorMap<'_>,
260 max_cycles: usize,
261 seen: &mut FxHashSet<Vec<u32>>,
262 cycles: &mut Vec<Vec<usize>>,
263) {
264 let mut path: Vec<usize> = vec![start];
265 let mut path_set = FixedBitSet::with_capacity(succs.modules.len());
266 path_set.insert(start);
267
268 let range = &succs.succ_ranges[start];
269 let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
270 succ_pos: range.start,
271 succ_end: range.end,
272 }];
273
274 while let Some(frame) = dfs.last_mut() {
275 if cycles.len() >= max_cycles {
276 return;
277 }
278
279 if frame.succ_pos >= frame.succ_end {
280 dfs.pop();
281 if path.len() > 1 {
282 let Some(removed) = path.pop() else {
283 continue;
284 };
285 path_set.set(removed, false);
286 }
287 continue;
288 }
289
290 let w = succs.all_succs[frame.succ_pos];
291 frame.succ_pos += 1;
292
293 if !scc_set.contains(&w) {
294 continue;
295 }
296
297 if w == start && path.len() >= 2 && path.len() == depth_limit {
298 try_record_cycle(&path, succs.modules, seen, cycles);
299 continue;
300 }
301
302 if path_set.contains(w) || path.len() >= depth_limit {
303 continue;
304 }
305
306 path.push(w);
307 path_set.insert(w);
308
309 let range = &succs.succ_ranges[w];
310 dfs.push(CycleFrame {
311 succ_pos: range.start,
312 succ_end: range.end,
313 });
314 }
315}
316
317fn enumerate_elementary_cycles(
323 scc_nodes: &[usize],
324 succs: &SuccessorMap<'_>,
325 max_cycles: usize,
326) -> Vec<Vec<usize>> {
327 let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
328 let mut cycles: Vec<Vec<usize>> = Vec::new();
329 let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
330
331 let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
332 sorted_nodes.sort_by(|a, b| succs.modules[*a].path.cmp(&succs.modules[*b].path));
333
334 let max_depth = scc_nodes.len().min(12); for depth_limit in 2..=max_depth {
336 if cycles.len() >= max_cycles {
337 break;
338 }
339
340 for &start in &sorted_nodes {
341 if cycles.len() >= max_cycles {
342 break;
343 }
344
345 dfs_find_cycles_from(
346 start,
347 depth_limit,
348 &scc_set,
349 succs,
350 max_cycles,
351 &mut seen,
352 &mut cycles,
353 );
354 }
355 }
356
357 cycles
358}
359
360#[cfg(test)]
361mod tests {
362 use std::ops::Range;
363 use std::path::PathBuf;
364
365 use rustc_hash::FxHashSet;
366
367 use crate::graph::types::ModuleNode;
368 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
369 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
370 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
371
372 use super::{
373 ModuleGraph, SuccessorMap, canonical_cycle, dfs_find_cycles_from,
374 enumerate_elementary_cycles, try_record_cycle,
375 };
376
377 #[expect(
379 clippy::cast_possible_truncation,
380 reason = "test file counts are trivially small"
381 )]
382 fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
383 let files: Vec<DiscoveredFile> = (0..file_count)
384 .map(|i| DiscoveredFile {
385 id: FileId(i as u32),
386 path: PathBuf::from(format!("/project/file{i}.ts")),
387 size_bytes: 100,
388 })
389 .collect();
390
391 let resolved_modules: Vec<ResolvedModule> = (0..file_count)
392 .map(|i| {
393 let imports: Vec<ResolvedImport> = edges_spec
394 .iter()
395 .filter(|(src, _)| *src == i as u32)
396 .map(|(_, tgt)| ResolvedImport {
397 info: ImportInfo {
398 source: format!("./file{tgt}"),
399 imported_name: ImportedName::Named("x".to_string()),
400 local_name: "x".to_string(),
401 is_type_only: false,
402 from_style: false,
403 span: oxc_span::Span::new(0, 10),
404 source_span: oxc_span::Span::default(),
405 },
406 target: ResolveResult::InternalModule(FileId(*tgt)),
407 })
408 .collect();
409
410 ResolvedModule {
411 file_id: FileId(i as u32),
412 path: PathBuf::from(format!("/project/file{i}.ts")),
413 exports: vec![fallow_types::extract::ExportInfo {
414 name: ExportName::Named("x".to_string()),
415 local_name: Some("x".to_string()),
416 is_type_only: false,
417 visibility: VisibilityTag::None,
418 span: oxc_span::Span::new(0, 20),
419 members: vec![],
420 is_side_effect_used: false,
421 super_class: None,
422 }],
423 re_exports: vec![],
424 resolved_imports: imports,
425 resolved_dynamic_imports: vec![],
426 resolved_dynamic_patterns: vec![],
427 member_accesses: vec![],
428 whole_object_uses: vec![],
429 has_cjs_exports: false,
430 has_angular_component_template_url: false,
431 unused_import_bindings: FxHashSet::default(),
432 type_referenced_import_bindings: vec![],
433 value_referenced_import_bindings: vec![],
434 namespace_object_aliases: vec![],
435 }
436 })
437 .collect();
438
439 let entry_points = vec![EntryPoint {
440 path: PathBuf::from("/project/file0.ts"),
441 source: EntryPointSource::PackageJsonMain,
442 }];
443
444 ModuleGraph::build(&resolved_modules, &entry_points, &files)
445 }
446
447 #[test]
448 fn find_cycles_empty_graph() {
449 let graph = ModuleGraph::build(&[], &[], &[]);
450 assert!(graph.find_cycles().is_empty());
451 }
452
453 #[test]
454 fn find_cycles_no_cycles() {
455 let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
456 assert!(graph.find_cycles().is_empty());
457 }
458
459 #[test]
460 fn find_cycles_simple_two_node_cycle() {
461 let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
462 let cycles = graph.find_cycles();
463 assert_eq!(cycles.len(), 1);
464 assert_eq!(cycles[0].len(), 2);
465 }
466
467 #[test]
468 fn find_cycles_three_node_cycle() {
469 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
470 let cycles = graph.find_cycles();
471 assert_eq!(cycles.len(), 1);
472 assert_eq!(cycles[0].len(), 3);
473 }
474
475 #[test]
476 fn find_cycles_self_import_ignored() {
477 let graph = build_cycle_graph(1, &[(0, 0)]);
478 let cycles = graph.find_cycles();
479 assert!(
480 cycles.is_empty(),
481 "self-imports should not be reported as cycles"
482 );
483 }
484
485 #[test]
486 fn find_cycles_multiple_independent_cycles() {
487 let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
488 let cycles = graph.find_cycles();
489 assert_eq!(cycles.len(), 2);
490 assert!(cycles.iter().all(|c| c.len() == 2));
491 }
492
493 #[test]
494 fn find_cycles_linear_chain_with_back_edge() {
495 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
496 let cycles = graph.find_cycles();
497 assert_eq!(cycles.len(), 1);
498 assert_eq!(cycles[0].len(), 3);
499 let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
500 assert!(ids.contains(&1));
501 assert!(ids.contains(&2));
502 assert!(ids.contains(&3));
503 assert!(!ids.contains(&0));
504 }
505
506 #[test]
507 fn find_cycles_overlapping_cycles_enumerated() {
508 let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
509 let cycles = graph.find_cycles();
510 assert_eq!(
511 cycles.len(),
512 2,
513 "should find 2 elementary cycles, not 1 SCC"
514 );
515 assert!(
516 cycles.iter().all(|c| c.len() == 2),
517 "both cycles should have length 2"
518 );
519 }
520
521 #[test]
522 fn find_cycles_deterministic_ordering() {
523 let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
524 let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
525 let cycles1 = graph1.find_cycles();
526 let cycles2 = graph2.find_cycles();
527 assert_eq!(cycles1.len(), cycles2.len());
528 for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
529 let paths1: Vec<&PathBuf> = c1
530 .iter()
531 .map(|f| &graph1.modules[f.0 as usize].path)
532 .collect();
533 let paths2: Vec<&PathBuf> = c2
534 .iter()
535 .map(|f| &graph2.modules[f.0 as usize].path)
536 .collect();
537 assert_eq!(paths1, paths2);
538 }
539 }
540
541 #[test]
542 fn find_cycles_sorted_by_length() {
543 let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
544 let cycles = graph.find_cycles();
545 assert_eq!(cycles.len(), 2);
546 assert!(
547 cycles[0].len() <= cycles[1].len(),
548 "cycles should be sorted by length"
549 );
550 }
551
552 #[test]
553 fn find_cycles_large_cycle() {
554 let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
555 let graph = build_cycle_graph(10, &edges);
556 let cycles = graph.find_cycles();
557 assert_eq!(cycles.len(), 1);
558 assert_eq!(cycles[0].len(), 10);
559 }
560
561 #[test]
562 fn find_cycles_complex_scc_multiple_elementary() {
563 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
564 let cycles = graph.find_cycles();
565 assert!(
566 cycles.len() >= 2,
567 "should find at least 2 elementary cycles, got {}",
568 cycles.len()
569 );
570 assert!(cycles.iter().all(|c| c.len() <= 4));
571 }
572
573 #[test]
574 fn find_cycles_no_duplicate_cycles() {
575 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
576 let cycles = graph.find_cycles();
577 assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
578 assert_eq!(cycles[0].len(), 3);
579 }
580
581 #[expect(
586 clippy::cast_possible_truncation,
587 reason = "test file counts are trivially small"
588 )]
589 fn build_test_succs(
590 file_count: usize,
591 edges_spec: &[(usize, usize)],
592 ) -> (Vec<ModuleNode>, Vec<usize>, Vec<Range<usize>>) {
593 let modules: Vec<ModuleNode> = (0..file_count)
594 .map(|i| {
595 let mut node = ModuleNode {
596 file_id: FileId(i as u32),
597 path: PathBuf::from(format!("/project/file{i}.ts")),
598 edge_range: 0..0,
599 exports: vec![],
600 re_exports: vec![],
601 flags: ModuleNode::flags_from(i == 0, true, false),
602 };
603 node.set_reachable(true);
604 node
605 })
606 .collect();
607
608 let mut all_succs: Vec<usize> = Vec::new();
609 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(file_count);
610 for src in 0..file_count {
611 let start = all_succs.len();
612 let mut seen = FxHashSet::default();
613 for &(s, t) in edges_spec {
614 if s == src && t < file_count && seen.insert(t) {
615 all_succs.push(t);
616 }
617 }
618 let end = all_succs.len();
619 succ_ranges.push(start..end);
620 }
621
622 (modules, all_succs, succ_ranges)
623 }
624
625 #[test]
626 fn canonical_cycle_empty() {
627 let modules: Vec<ModuleNode> = vec![];
628 assert!(canonical_cycle(&[], &modules).is_empty());
629 }
630
631 #[test]
632 fn canonical_cycle_rotates_to_smallest_path() {
633 let (modules, _, _) = build_test_succs(3, &[]);
634 let result = canonical_cycle(&[2, 0, 1], &modules);
635 assert_eq!(result, vec![0, 1, 2]);
636 }
637
638 #[test]
639 fn canonical_cycle_already_canonical() {
640 let (modules, _, _) = build_test_succs(3, &[]);
641 let result = canonical_cycle(&[0, 1, 2], &modules);
642 assert_eq!(result, vec![0, 1, 2]);
643 }
644
645 #[test]
646 fn canonical_cycle_single_node() {
647 let (modules, _, _) = build_test_succs(1, &[]);
648 let result = canonical_cycle(&[0], &modules);
649 assert_eq!(result, vec![0]);
650 }
651
652 #[test]
653 fn try_record_cycle_inserts_new_cycle() {
654 let (modules, _, _) = build_test_succs(3, &[]);
655 let mut seen = FxHashSet::default();
656 let mut cycles = Vec::new();
657
658 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
659 assert_eq!(cycles.len(), 1);
660 assert_eq!(cycles[0], vec![0, 1, 2]);
661 }
662
663 #[test]
664 fn try_record_cycle_deduplicates_rotated_cycle() {
665 let (modules, _, _) = build_test_succs(3, &[]);
666 let mut seen = FxHashSet::default();
667 let mut cycles = Vec::new();
668
669 try_record_cycle(&[0, 1, 2], &modules, &mut seen, &mut cycles);
670 try_record_cycle(&[1, 2, 0], &modules, &mut seen, &mut cycles);
671 try_record_cycle(&[2, 0, 1], &modules, &mut seen, &mut cycles);
672
673 assert_eq!(
674 cycles.len(),
675 1,
676 "rotations of the same cycle should be deduped"
677 );
678 }
679
680 #[test]
681 fn try_record_cycle_single_node_self_loop() {
682 let (modules, _, _) = build_test_succs(1, &[]);
683 let mut seen = FxHashSet::default();
684 let mut cycles = Vec::new();
685
686 try_record_cycle(&[0], &modules, &mut seen, &mut cycles);
687 assert_eq!(cycles.len(), 1);
688 assert_eq!(cycles[0], vec![0]);
689 }
690
691 #[test]
692 fn try_record_cycle_distinct_cycles_both_recorded() {
693 let (modules, _, _) = build_test_succs(4, &[]);
694 let mut seen = FxHashSet::default();
695 let mut cycles = Vec::new();
696
697 try_record_cycle(&[0, 1], &modules, &mut seen, &mut cycles);
698 try_record_cycle(&[2, 3], &modules, &mut seen, &mut cycles);
699
700 assert_eq!(cycles.len(), 2);
701 }
702
703 #[test]
704 fn successor_map_empty_graph() {
705 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
706 let succs = SuccessorMap {
707 all_succs: &all_succs,
708 succ_ranges: &succ_ranges,
709 modules: &modules,
710 };
711 assert!(succs.all_succs.is_empty());
712 assert!(succs.succ_ranges.is_empty());
713 }
714
715 #[test]
716 fn successor_map_single_node_self_edge() {
717 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
718 let succs = SuccessorMap {
719 all_succs: &all_succs,
720 succ_ranges: &succ_ranges,
721 modules: &modules,
722 };
723 assert_eq!(succs.all_succs.len(), 1);
724 assert_eq!(succs.all_succs[0], 0);
725 assert_eq!(succs.succ_ranges[0], 0..1);
726 }
727
728 #[test]
729 fn successor_map_deduplicates_edges() {
730 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (0, 1)]);
731 let succs = SuccessorMap {
732 all_succs: &all_succs,
733 succ_ranges: &succ_ranges,
734 modules: &modules,
735 };
736 let range = &succs.succ_ranges[0];
737 assert_eq!(
738 range.end - range.start,
739 1,
740 "duplicate edges should be deduped"
741 );
742 }
743
744 #[test]
745 fn successor_map_multiple_successors() {
746 let (modules, all_succs, succ_ranges) = build_test_succs(4, &[(0, 1), (0, 2), (0, 3)]);
747 let succs = SuccessorMap {
748 all_succs: &all_succs,
749 succ_ranges: &succ_ranges,
750 modules: &modules,
751 };
752 let range = &succs.succ_ranges[0];
753 assert_eq!(range.end - range.start, 3);
754 for i in 1..4 {
755 let r = &succs.succ_ranges[i];
756 assert_eq!(r.end - r.start, 0);
757 }
758 }
759
760 #[test]
761 fn dfs_find_cycles_from_isolated_node() {
762 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[]);
763 let succs = SuccessorMap {
764 all_succs: &all_succs,
765 succ_ranges: &succ_ranges,
766 modules: &modules,
767 };
768 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
769 let mut seen = FxHashSet::default();
770 let mut cycles = Vec::new();
771
772 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
773 assert!(cycles.is_empty(), "isolated node should have no cycles");
774 }
775
776 #[test]
777 fn dfs_find_cycles_from_simple_two_cycle() {
778 let (modules, all_succs, succ_ranges) = build_test_succs(2, &[(0, 1), (1, 0)]);
779 let succs = SuccessorMap {
780 all_succs: &all_succs,
781 succ_ranges: &succ_ranges,
782 modules: &modules,
783 };
784 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
785 let mut seen = FxHashSet::default();
786 let mut cycles = Vec::new();
787
788 dfs_find_cycles_from(0, 2, &scc_set, &succs, 10, &mut seen, &mut cycles);
789 assert_eq!(cycles.len(), 1);
790 assert_eq!(cycles[0].len(), 2);
791 }
792
793 #[test]
794 fn dfs_find_cycles_from_diamond_graph() {
795 let (modules, all_succs, succ_ranges) =
796 build_test_succs(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
797 let succs = SuccessorMap {
798 all_succs: &all_succs,
799 succ_ranges: &succ_ranges,
800 modules: &modules,
801 };
802 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
803 let mut seen = FxHashSet::default();
804 let mut cycles = Vec::new();
805
806 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
807 assert_eq!(cycles.len(), 2, "diamond should have two 3-node cycles");
808 assert!(cycles.iter().all(|c| c.len() == 3));
809 }
810
811 #[test]
812 fn dfs_find_cycles_from_depth_limit_prevents_longer_cycles() {
813 let (modules, all_succs, succ_ranges) =
814 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
815 let succs = SuccessorMap {
816 all_succs: &all_succs,
817 succ_ranges: &succ_ranges,
818 modules: &modules,
819 };
820 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
821 let mut seen = FxHashSet::default();
822 let mut cycles = Vec::new();
823
824 dfs_find_cycles_from(0, 3, &scc_set, &succs, 10, &mut seen, &mut cycles);
825 assert!(
826 cycles.is_empty(),
827 "depth_limit=3 should prevent finding a 4-node cycle"
828 );
829 }
830
831 #[test]
832 fn dfs_find_cycles_from_depth_limit_exact_match() {
833 let (modules, all_succs, succ_ranges) =
834 build_test_succs(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
835 let succs = SuccessorMap {
836 all_succs: &all_succs,
837 succ_ranges: &succ_ranges,
838 modules: &modules,
839 };
840 let scc_set: FxHashSet<usize> = [0, 1, 2, 3].into_iter().collect();
841 let mut seen = FxHashSet::default();
842 let mut cycles = Vec::new();
843
844 dfs_find_cycles_from(0, 4, &scc_set, &succs, 10, &mut seen, &mut cycles);
845 assert_eq!(
846 cycles.len(),
847 1,
848 "depth_limit=4 should find the 4-node cycle"
849 );
850 assert_eq!(cycles[0].len(), 4);
851 }
852
853 #[test]
854 fn dfs_find_cycles_from_respects_max_cycles() {
855 let edges: Vec<(usize, usize)> = (0..4)
856 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
857 .collect();
858 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
859 let succs = SuccessorMap {
860 all_succs: &all_succs,
861 succ_ranges: &succ_ranges,
862 modules: &modules,
863 };
864 let scc_set: FxHashSet<usize> = (0..4).collect();
865 let mut seen = FxHashSet::default();
866 let mut cycles = Vec::new();
867
868 dfs_find_cycles_from(0, 2, &scc_set, &succs, 2, &mut seen, &mut cycles);
869 assert!(
870 cycles.len() <= 2,
871 "should respect max_cycles limit, got {}",
872 cycles.len()
873 );
874 }
875
876 #[test]
877 fn dfs_find_cycles_from_ignores_nodes_outside_scc() {
878 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
879 let succs = SuccessorMap {
880 all_succs: &all_succs,
881 succ_ranges: &succ_ranges,
882 modules: &modules,
883 };
884 let scc_set: FxHashSet<usize> = [0, 1].into_iter().collect();
885 let mut seen = FxHashSet::default();
886 let mut cycles = Vec::new();
887
888 for depth in 2..=3 {
889 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
890 }
891 assert!(
892 cycles.is_empty(),
893 "should not find cycles through nodes outside the SCC set"
894 );
895 }
896
897 #[test]
898 fn enumerate_elementary_cycles_empty_scc() {
899 let (modules, all_succs, succ_ranges) = build_test_succs(0, &[]);
900 let succs = SuccessorMap {
901 all_succs: &all_succs,
902 succ_ranges: &succ_ranges,
903 modules: &modules,
904 };
905 let cycles = enumerate_elementary_cycles(&[], &succs, 10);
906 assert!(cycles.is_empty());
907 }
908
909 #[test]
910 fn enumerate_elementary_cycles_max_cycles_limit() {
911 let edges: Vec<(usize, usize)> = (0..4)
912 .flat_map(|i| (0..4).filter(move |&j| i != j).map(move |j| (i, j)))
913 .collect();
914 let (modules, all_succs, succ_ranges) = build_test_succs(4, &edges);
915 let succs = SuccessorMap {
916 all_succs: &all_succs,
917 succ_ranges: &succ_ranges,
918 modules: &modules,
919 };
920 let scc_nodes: Vec<usize> = (0..4).collect();
921
922 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 3);
923 assert!(
924 cycles.len() <= 3,
925 "should respect max_cycles=3 limit, got {}",
926 cycles.len()
927 );
928 }
929
930 #[test]
931 fn enumerate_elementary_cycles_finds_all_in_triangle() {
932 let (modules, all_succs, succ_ranges) = build_test_succs(3, &[(0, 1), (1, 2), (2, 0)]);
933 let succs = SuccessorMap {
934 all_succs: &all_succs,
935 succ_ranges: &succ_ranges,
936 modules: &modules,
937 };
938 let scc_nodes: Vec<usize> = vec![0, 1, 2];
939
940 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
941 assert_eq!(cycles.len(), 1);
942 assert_eq!(cycles[0].len(), 3);
943 }
944
945 #[test]
946 fn enumerate_elementary_cycles_iterative_deepening_order() {
947 let (modules, all_succs, succ_ranges) =
948 build_test_succs(3, &[(0, 1), (1, 0), (1, 2), (2, 0)]);
949 let succs = SuccessorMap {
950 all_succs: &all_succs,
951 succ_ranges: &succ_ranges,
952 modules: &modules,
953 };
954 let scc_nodes: Vec<usize> = vec![0, 1, 2];
955
956 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
957 assert!(cycles.len() >= 2, "should find at least 2 cycles");
958 assert!(
959 cycles[0].len() <= cycles[cycles.len() - 1].len(),
960 "shorter cycles should be found before longer ones"
961 );
962 }
963
964 #[test]
965 fn find_cycles_max_cycles_per_scc_respected() {
966 let edges: Vec<(u32, u32)> = (0..5)
967 .flat_map(|i| (0..5).filter(move |&j| i != j).map(move |j| (i, j)))
968 .collect();
969 let graph = build_cycle_graph(5, &edges);
970 let cycles = graph.find_cycles();
971 assert!(
972 cycles.len() <= 20,
973 "should cap at MAX_CYCLES_PER_SCC, got {}",
974 cycles.len()
975 );
976 assert!(
977 !cycles.is_empty(),
978 "dense graph should still find some cycles"
979 );
980 }
981
982 #[test]
983 fn find_cycles_graph_with_no_cycles_returns_empty() {
984 let graph = build_cycle_graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
985 assert!(graph.find_cycles().is_empty());
986 }
987
988 #[test]
989 fn find_cycles_diamond_no_cycle() {
990 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)]);
991 assert!(graph.find_cycles().is_empty());
992 }
993
994 #[test]
995 fn find_cycles_diamond_with_back_edge() {
996 let graph = build_cycle_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 0)]);
997 let cycles = graph.find_cycles();
998 assert!(
999 cycles.len() >= 2,
1000 "diamond with back-edge should have at least 2 elementary cycles, got {}",
1001 cycles.len()
1002 );
1003 assert_eq!(cycles[0].len(), 3);
1004 }
1005
1006 #[test]
1007 fn canonical_cycle_non_sequential_indices() {
1008 let (modules, _, _) = build_test_succs(5, &[]);
1009 let result = canonical_cycle(&[3, 1, 4], &modules);
1010 assert_eq!(result, vec![1, 4, 3]);
1011 }
1012
1013 #[test]
1014 fn canonical_cycle_different_starting_points_same_result() {
1015 let (modules, _, _) = build_test_succs(4, &[]);
1016 let r1 = canonical_cycle(&[0, 1, 2, 3], &modules);
1017 let r2 = canonical_cycle(&[1, 2, 3, 0], &modules);
1018 let r3 = canonical_cycle(&[2, 3, 0, 1], &modules);
1019 let r4 = canonical_cycle(&[3, 0, 1, 2], &modules);
1020 assert_eq!(r1, r2);
1021 assert_eq!(r2, r3);
1022 assert_eq!(r3, r4);
1023 assert_eq!(r1, vec![0, 1, 2, 3]);
1024 }
1025
1026 #[test]
1027 fn canonical_cycle_two_node_both_rotations() {
1028 let (modules, _, _) = build_test_succs(2, &[]);
1029 assert_eq!(canonical_cycle(&[0, 1], &modules), vec![0, 1]);
1030 assert_eq!(canonical_cycle(&[1, 0], &modules), vec![0, 1]);
1031 }
1032
1033 #[test]
1034 fn dfs_find_cycles_from_self_loop_not_found() {
1035 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1036 let succs = SuccessorMap {
1037 all_succs: &all_succs,
1038 succ_ranges: &succ_ranges,
1039 modules: &modules,
1040 };
1041 let scc_set: FxHashSet<usize> = std::iter::once(0).collect();
1042 let mut seen = FxHashSet::default();
1043 let mut cycles = Vec::new();
1044
1045 for depth in 1..=3 {
1046 dfs_find_cycles_from(0, depth, &scc_set, &succs, 10, &mut seen, &mut cycles);
1047 }
1048 assert!(
1049 cycles.is_empty(),
1050 "self-loop should not be detected as a cycle by dfs_find_cycles_from"
1051 );
1052 }
1053
1054 #[test]
1055 fn enumerate_elementary_cycles_self_loop_not_found() {
1056 let (modules, all_succs, succ_ranges) = build_test_succs(1, &[(0, 0)]);
1057 let succs = SuccessorMap {
1058 all_succs: &all_succs,
1059 succ_ranges: &succ_ranges,
1060 modules: &modules,
1061 };
1062 let cycles = enumerate_elementary_cycles(&[0], &succs, 20);
1063 assert!(
1064 cycles.is_empty(),
1065 "self-loop should not produce elementary cycles"
1066 );
1067 }
1068
1069 #[test]
1070 fn find_cycles_two_cycles_sharing_edge() {
1071 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1072 let cycles = graph.find_cycles();
1073 assert_eq!(
1074 cycles.len(),
1075 2,
1076 "two cycles sharing edge A->B should both be found, got {}",
1077 cycles.len()
1078 );
1079 assert!(
1080 cycles.iter().all(|c| c.len() == 3),
1081 "both cycles should have length 3"
1082 );
1083 }
1084
1085 #[test]
1086 fn enumerate_elementary_cycles_shared_edge() {
1087 let (modules, all_succs, succ_ranges) =
1088 build_test_succs(4, &[(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)]);
1089 let succs = SuccessorMap {
1090 all_succs: &all_succs,
1091 succ_ranges: &succ_ranges,
1092 modules: &modules,
1093 };
1094 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3];
1095 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1096 assert_eq!(
1097 cycles.len(),
1098 2,
1099 "should find exactly 2 elementary cycles sharing edge 0->1, got {}",
1100 cycles.len()
1101 );
1102 }
1103
1104 #[test]
1105 fn enumerate_elementary_cycles_pentagon_with_chords() {
1106 let (modules, all_succs, succ_ranges) =
1107 build_test_succs(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3)]);
1108 let succs = SuccessorMap {
1109 all_succs: &all_succs,
1110 succ_ranges: &succ_ranges,
1111 modules: &modules,
1112 };
1113 let scc_nodes: Vec<usize> = vec![0, 1, 2, 3, 4];
1114 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1115
1116 assert!(
1117 cycles.len() >= 3,
1118 "pentagon with chords should have at least 3 elementary cycles, got {}",
1119 cycles.len()
1120 );
1121 let unique: FxHashSet<Vec<usize>> = cycles.iter().cloned().collect();
1122 assert_eq!(
1123 unique.len(),
1124 cycles.len(),
1125 "all enumerated cycles should be unique"
1126 );
1127 assert_eq!(
1128 cycles[0].len(),
1129 3,
1130 "shortest cycle in pentagon with chords should be length 3"
1131 );
1132 }
1133
1134 #[test]
1135 fn find_cycles_large_scc_complete_graph_k6() {
1136 let edges: Vec<(u32, u32)> = (0..6)
1137 .flat_map(|i| (0..6).filter(move |&j| i != j).map(move |j| (i, j)))
1138 .collect();
1139 let graph = build_cycle_graph(6, &edges);
1140 let cycles = graph.find_cycles();
1141
1142 assert!(
1143 cycles.len() <= 20,
1144 "should cap at MAX_CYCLES_PER_SCC (20), got {}",
1145 cycles.len()
1146 );
1147 assert_eq!(
1148 cycles.len(),
1149 20,
1150 "K6 has far more than 20 elementary cycles, so we should hit the cap"
1151 );
1152 assert_eq!(cycles[0].len(), 2, "shortest cycles in K6 should be 2-node");
1153 }
1154
1155 #[test]
1156 fn enumerate_elementary_cycles_respects_depth_cap_of_12() {
1157 let edges: Vec<(usize, usize)> = (0..15).map(|i| (i, (i + 1) % 15)).collect();
1158 let (modules, all_succs, succ_ranges) = build_test_succs(15, &edges);
1159 let succs = SuccessorMap {
1160 all_succs: &all_succs,
1161 succ_ranges: &succ_ranges,
1162 modules: &modules,
1163 };
1164 let scc_nodes: Vec<usize> = (0..15).collect();
1165 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1166
1167 assert!(
1168 cycles.is_empty(),
1169 "a pure 15-node cycle should not be found with depth cap of 12, got {} cycles",
1170 cycles.len()
1171 );
1172 }
1173
1174 #[test]
1175 fn enumerate_elementary_cycles_finds_cycle_at_depth_cap_boundary() {
1176 let edges: Vec<(usize, usize)> = (0..12).map(|i| (i, (i + 1) % 12)).collect();
1177 let (modules, all_succs, succ_ranges) = build_test_succs(12, &edges);
1178 let succs = SuccessorMap {
1179 all_succs: &all_succs,
1180 succ_ranges: &succ_ranges,
1181 modules: &modules,
1182 };
1183 let scc_nodes: Vec<usize> = (0..12).collect();
1184 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1185
1186 assert_eq!(
1187 cycles.len(),
1188 1,
1189 "a pure 12-node cycle should be found at the depth cap boundary"
1190 );
1191 assert_eq!(cycles[0].len(), 12);
1192 }
1193
1194 #[test]
1195 fn enumerate_elementary_cycles_13_node_pure_cycle_not_found() {
1196 let edges: Vec<(usize, usize)> = (0..13).map(|i| (i, (i + 1) % 13)).collect();
1197 let (modules, all_succs, succ_ranges) = build_test_succs(13, &edges);
1198 let succs = SuccessorMap {
1199 all_succs: &all_succs,
1200 succ_ranges: &succ_ranges,
1201 modules: &modules,
1202 };
1203 let scc_nodes: Vec<usize> = (0..13).collect();
1204 let cycles = enumerate_elementary_cycles(&scc_nodes, &succs, 20);
1205
1206 assert!(
1207 cycles.is_empty(),
1208 "13-node pure cycle exceeds depth cap of 12"
1209 );
1210 }
1211
1212 #[test]
1213 fn find_cycles_max_cycles_per_scc_enforced_on_k7() {
1214 let edges: Vec<(u32, u32)> = (0..7)
1215 .flat_map(|i| (0..7).filter(move |&j| i != j).map(move |j| (i, j)))
1216 .collect();
1217 let graph = build_cycle_graph(7, &edges);
1218 let cycles = graph.find_cycles();
1219
1220 assert!(
1221 cycles.len() <= 20,
1222 "K7 should cap at MAX_CYCLES_PER_SCC (20), got {}",
1223 cycles.len()
1224 );
1225 assert_eq!(
1226 cycles.len(),
1227 20,
1228 "K7 has far more than 20 elementary cycles, should hit the cap exactly"
1229 );
1230 }
1231
1232 #[test]
1233 fn find_cycles_two_dense_sccs_each_capped() {
1234 let mut edges: Vec<(u32, u32)> = Vec::new();
1235 for i in 0..4 {
1236 for j in 0..4 {
1237 if i != j {
1238 edges.push((i, j));
1239 }
1240 }
1241 }
1242 for i in 4..8 {
1243 for j in 4..8 {
1244 if i != j {
1245 edges.push((i, j));
1246 }
1247 }
1248 }
1249 let graph = build_cycle_graph(8, &edges);
1250 let cycles = graph.find_cycles();
1251
1252 assert!(!cycles.is_empty(), "two dense SCCs should produce cycles");
1253 assert!(
1254 cycles.len() > 2,
1255 "should find multiple cycles across both SCCs, got {}",
1256 cycles.len()
1257 );
1258 }
1259
1260 mod proptests {
1261 use super::*;
1262 use proptest::prelude::*;
1263
1264 proptest! {
1265 #[test]
1268 fn dag_has_no_cycles(
1269 file_count in 2..20usize,
1270 edge_pairs in prop::collection::vec((0..19u32, 0..19u32), 0..30),
1271 ) {
1272 let dag_edges: Vec<(u32, u32)> = edge_pairs
1273 .into_iter()
1274 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a < b)
1275 .collect();
1276
1277 let graph = build_cycle_graph(file_count, &dag_edges);
1278 let cycles = graph.find_cycles();
1279 prop_assert!(
1280 cycles.is_empty(),
1281 "DAG should have no cycles, but found {}",
1282 cycles.len()
1283 );
1284 }
1285
1286 #[test]
1288 fn mutual_edges_always_detect_cycle(extra_nodes in 0..10usize) {
1289 let file_count = 2 + extra_nodes;
1290 let graph = build_cycle_graph(file_count, &[(0, 1), (1, 0)]);
1291 let cycles = graph.find_cycles();
1292 prop_assert!(
1293 !cycles.is_empty(),
1294 "A->B->A should always produce at least one cycle"
1295 );
1296 let has_pair_cycle = cycles.iter().any(|c| {
1297 c.contains(&FileId(0)) && c.contains(&FileId(1))
1298 });
1299 prop_assert!(has_pair_cycle, "Should find a cycle containing nodes 0 and 1");
1300 }
1301
1302 #[test]
1304 fn cycle_members_are_valid_indices(
1305 file_count in 2..15usize,
1306 edge_pairs in prop::collection::vec((0..14u32, 0..14u32), 1..20),
1307 ) {
1308 let edges: Vec<(u32, u32)> = edge_pairs
1309 .into_iter()
1310 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1311 .collect();
1312
1313 let graph = build_cycle_graph(file_count, &edges);
1314 let cycles = graph.find_cycles();
1315 for cycle in &cycles {
1316 prop_assert!(cycle.len() >= 2, "Cycles must have at least 2 nodes");
1317 for file_id in cycle {
1318 prop_assert!(
1319 (file_id.0 as usize) < file_count,
1320 "FileId {} exceeds file count {}",
1321 file_id.0, file_count
1322 );
1323 }
1324 }
1325 }
1326
1327 #[test]
1329 fn cycles_sorted_by_length(
1330 file_count in 3..12usize,
1331 edge_pairs in prop::collection::vec((0..11u32, 0..11u32), 2..25),
1332 ) {
1333 let edges: Vec<(u32, u32)> = edge_pairs
1334 .into_iter()
1335 .filter(|(a, b)| (*a as usize) < file_count && (*b as usize) < file_count && a != b)
1336 .collect();
1337
1338 let graph = build_cycle_graph(file_count, &edges);
1339 let cycles = graph.find_cycles();
1340 for window in cycles.windows(2) {
1341 prop_assert!(
1342 window[0].len() <= window[1].len(),
1343 "Cycles should be sorted by length: {} > {}",
1344 window[0].len(), window[1].len()
1345 );
1346 }
1347 }
1348 }
1349 }
1350
1351 fn build_cycle_graph_with_type_only(
1353 file_count: usize,
1354 edges_spec: &[(u32, u32, bool)], ) -> ModuleGraph {
1356 let files: Vec<DiscoveredFile> = (0..file_count)
1357 .map(|i| DiscoveredFile {
1358 id: FileId(i as u32),
1359 path: PathBuf::from(format!("/project/file{i}.ts")),
1360 size_bytes: 100,
1361 })
1362 .collect();
1363
1364 let resolved_modules: Vec<ResolvedModule> = (0..file_count)
1365 .map(|i| {
1366 let imports: Vec<ResolvedImport> = edges_spec
1367 .iter()
1368 .filter(|(src, _, _)| *src == i as u32)
1369 .map(|(_, tgt, type_only)| ResolvedImport {
1370 info: ImportInfo {
1371 source: format!("./file{tgt}"),
1372 imported_name: ImportedName::Named("x".to_string()),
1373 local_name: "x".to_string(),
1374 is_type_only: *type_only,
1375 from_style: false,
1376 span: oxc_span::Span::new(0, 10),
1377 source_span: oxc_span::Span::default(),
1378 },
1379 target: ResolveResult::InternalModule(FileId(*tgt)),
1380 })
1381 .collect();
1382
1383 ResolvedModule {
1384 file_id: FileId(i as u32),
1385 path: PathBuf::from(format!("/project/file{i}.ts")),
1386 exports: vec![fallow_types::extract::ExportInfo {
1387 name: ExportName::Named("x".to_string()),
1388 local_name: Some("x".to_string()),
1389 is_type_only: false,
1390 visibility: VisibilityTag::None,
1391 span: oxc_span::Span::new(0, 20),
1392 members: vec![],
1393 is_side_effect_used: false,
1394 super_class: None,
1395 }],
1396 re_exports: vec![],
1397 resolved_imports: imports,
1398 resolved_dynamic_imports: vec![],
1399 resolved_dynamic_patterns: vec![],
1400 member_accesses: vec![],
1401 whole_object_uses: vec![],
1402 has_cjs_exports: false,
1403 has_angular_component_template_url: false,
1404 unused_import_bindings: FxHashSet::default(),
1405 type_referenced_import_bindings: vec![],
1406 value_referenced_import_bindings: vec![],
1407 namespace_object_aliases: vec![],
1408 }
1409 })
1410 .collect();
1411
1412 let entry_points = vec![EntryPoint {
1413 path: PathBuf::from("/project/file0.ts"),
1414 source: EntryPointSource::PackageJsonMain,
1415 }];
1416
1417 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1418 }
1419
1420 #[test]
1421 fn type_only_bidirectional_import_not_a_cycle() {
1422 let graph = build_cycle_graph_with_type_only(2, &[(0, 1, true), (1, 0, true)]);
1423 let cycles = graph.find_cycles();
1424 assert!(
1425 cycles.is_empty(),
1426 "type-only bidirectional imports should not be reported as cycles"
1427 );
1428 }
1429
1430 #[test]
1431 fn mixed_type_and_value_import_not_a_cycle() {
1432 let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, true)]);
1433 let cycles = graph.find_cycles();
1434 assert!(
1435 cycles.is_empty(),
1436 "A->B (value) + B->A (type-only) is not a runtime cycle"
1437 );
1438 }
1439
1440 #[test]
1441 fn both_value_imports_with_one_type_still_a_cycle() {
1442 let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1443 let cycles = graph.find_cycles();
1444 assert!(
1445 !cycles.is_empty(),
1446 "bidirectional value imports should be reported as a cycle"
1447 );
1448 }
1449
1450 #[test]
1451 fn all_value_imports_still_a_cycle() {
1452 let graph = build_cycle_graph_with_type_only(2, &[(0, 1, false), (1, 0, false)]);
1453 let cycles = graph.find_cycles();
1454 assert_eq!(cycles.len(), 1);
1455 }
1456
1457 #[test]
1458 fn three_node_type_only_cycle_not_reported() {
1459 let graph =
1460 build_cycle_graph_with_type_only(3, &[(0, 1, true), (1, 2, true), (2, 0, true)]);
1461 let cycles = graph.find_cycles();
1462 assert!(
1463 cycles.is_empty(),
1464 "three-node type-only cycle should not be reported"
1465 );
1466 }
1467
1468 #[test]
1469 fn three_node_cycle_one_value_edge_still_reported() {
1470 let graph =
1471 build_cycle_graph_with_type_only(3, &[(0, 1, false), (1, 2, true), (2, 0, true)]);
1472 let cycles = graph.find_cycles();
1473 assert!(
1474 cycles.is_empty(),
1475 "cycle broken by type-only edge in the middle should not be reported"
1476 );
1477 }
1478}