1use std::collections::VecDeque;
7
8use rustc_hash::{FxHashMap, FxHashSet};
9use std::ops::Range;
10use std::path::PathBuf;
11
12use fixedbitset::FixedBitSet;
13
14use crate::resolve::{ResolveResult, ResolvedModule};
15use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
16use fallow_types::extract::{ExportName, ImportedName};
17
18#[derive(Debug)]
20pub struct ModuleGraph {
21 pub modules: Vec<ModuleNode>,
23 edges: Vec<Edge>,
25 pub package_usage: FxHashMap<String, Vec<FileId>>,
27 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
31 pub entry_points: FxHashSet<FileId>,
33 pub reverse_deps: Vec<Vec<FileId>>,
35 namespace_imported: FixedBitSet,
37}
38
39#[derive(Debug)]
41pub struct ModuleNode {
42 pub file_id: FileId,
44 pub path: PathBuf,
46 pub edge_range: Range<usize>,
48 pub exports: Vec<ExportSymbol>,
50 pub re_exports: Vec<ReExportEdge>,
52 pub is_entry_point: bool,
54 pub is_reachable: bool,
56 pub has_cjs_exports: bool,
58}
59
60#[derive(Debug)]
62pub struct ReExportEdge {
63 pub source_file: FileId,
65 pub imported_name: String,
67 pub exported_name: String,
69 pub is_type_only: bool,
71}
72
73#[derive(Debug)]
75pub struct ExportSymbol {
76 pub name: ExportName,
78 pub is_type_only: bool,
80 pub span: oxc_span::Span,
82 pub references: Vec<SymbolReference>,
84 pub members: Vec<fallow_types::extract::MemberInfo>,
86}
87
88#[derive(Debug, Clone)]
90pub struct SymbolReference {
91 pub from_file: FileId,
93 pub kind: ReferenceKind,
95 pub import_span: oxc_span::Span,
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub enum ReferenceKind {
103 NamedImport,
105 DefaultImport,
107 NamespaceImport,
109 ReExport,
111 DynamicImport,
113 SideEffectImport,
115}
116
117#[derive(Debug)]
119struct Edge {
120 source: FileId,
121 target: FileId,
122 symbols: Vec<ImportedSymbol>,
123}
124
125#[derive(Debug)]
127struct ImportedSymbol {
128 imported_name: ImportedName,
129 local_name: String,
130 import_span: oxc_span::Span,
132}
133
134#[cfg(target_pointer_width = "64")]
140const _: () = assert!(std::mem::size_of::<Edge>() == 32);
141#[cfg(target_pointer_width = "64")]
142const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 56);
143#[cfg(target_pointer_width = "64")]
144const _: () = assert!(std::mem::size_of::<ExportSymbol>() == 88);
145#[cfg(target_pointer_width = "64")]
146const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);
147#[cfg(target_pointer_width = "64")]
148const _: () = assert!(std::mem::size_of::<ReExportEdge>() == 56);
149
150impl ModuleGraph {
151 pub fn build(
153 resolved_modules: &[ResolvedModule],
154 entry_points: &[EntryPoint],
155 files: &[DiscoveredFile],
156 ) -> Self {
157 let _span = tracing::info_span!("build_graph").entered();
158
159 let module_count = files.len();
160
161 let max_file_id = files
164 .iter()
165 .map(|f| f.id.0 as usize)
166 .max()
167 .map_or(0, |m| m + 1);
168 let total_capacity = max_file_id.max(module_count);
169
170 let path_to_id: FxHashMap<PathBuf, FileId> =
172 files.iter().map(|f| (f.path.clone(), f.id)).collect();
173
174 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
176 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
177
178 let entry_point_ids: FxHashSet<FileId> = entry_points
180 .iter()
181 .filter_map(|ep| {
182 path_to_id.get(&ep.path).copied().or_else(|| {
184 ep.path
186 .canonicalize()
187 .ok()
188 .and_then(|c| path_to_id.get(&c).copied())
189 })
190 })
191 .collect();
192
193 let mut graph = Self::populate_edges(
195 files,
196 &module_by_id,
197 &entry_point_ids,
198 module_count,
199 total_capacity,
200 );
201
202 graph.populate_references(&module_by_id, &entry_point_ids);
204
205 graph.mark_reachable(total_capacity);
207
208 graph.resolve_re_export_chains();
210
211 graph
212 }
213
214 #[allow(clippy::cognitive_complexity)] fn populate_edges(
220 files: &[DiscoveredFile],
221 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
222 entry_point_ids: &FxHashSet<FileId>,
223 module_count: usize,
224 total_capacity: usize,
225 ) -> Self {
226 let mut all_edges = Vec::new();
227 let mut modules = Vec::with_capacity(module_count);
228 let mut package_usage: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
229 let mut type_only_package_usage: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
230 let mut reverse_deps = vec![Vec::new(); total_capacity];
231 let mut namespace_imported = FixedBitSet::with_capacity(total_capacity);
232
233 for file in files {
234 let edge_start = all_edges.len();
235
236 if let Some(resolved) = module_by_id.get(&file.id) {
237 let mut edges_by_target: FxHashMap<FileId, Vec<ImportedSymbol>> =
239 FxHashMap::default();
240
241 for import in &resolved.resolved_imports {
242 match &import.target {
243 ResolveResult::InternalModule(target_id) => {
244 if matches!(import.info.imported_name, ImportedName::Namespace) {
246 let idx = target_id.0 as usize;
247 if idx < total_capacity {
248 namespace_imported.insert(idx);
249 }
250 }
251 edges_by_target
252 .entry(*target_id)
253 .or_default()
254 .push(ImportedSymbol {
255 imported_name: import.info.imported_name.clone(),
256 local_name: import.info.local_name.clone(),
257 import_span: import.info.span,
258 });
259 }
260 ResolveResult::NpmPackage(name) => {
261 package_usage.entry(name.clone()).or_default().push(file.id);
262 if import.info.is_type_only {
263 type_only_package_usage
264 .entry(name.clone())
265 .or_default()
266 .push(file.id);
267 }
268 }
269 _ => {}
270 }
271 }
272
273 for re_export in &resolved.re_exports {
275 if let ResolveResult::InternalModule(target_id) = &re_export.target {
276 edges_by_target
281 .entry(*target_id)
282 .or_default()
283 .push(ImportedSymbol {
284 imported_name: ImportedName::SideEffect,
285 local_name: String::new(),
286 import_span: oxc_span::Span::new(0, 0),
287 });
288 } else if let ResolveResult::NpmPackage(name) = &re_export.target {
289 package_usage.entry(name.clone()).or_default().push(file.id);
290 if re_export.info.is_type_only {
291 type_only_package_usage
292 .entry(name.clone())
293 .or_default()
294 .push(file.id);
295 }
296 }
297 }
298
299 for import in &resolved.resolved_dynamic_imports {
305 if let ResolveResult::InternalModule(target_id) = &import.target {
306 if matches!(import.info.imported_name, ImportedName::Namespace) {
307 let idx = target_id.0 as usize;
308 if idx < total_capacity {
309 namespace_imported.insert(idx);
310 }
311 }
312 edges_by_target
313 .entry(*target_id)
314 .or_default()
315 .push(ImportedSymbol {
316 imported_name: import.info.imported_name.clone(),
317 local_name: import.info.local_name.clone(),
318 import_span: import.info.span,
319 });
320 }
321 }
322
323 for (_pattern, matched_ids) in &resolved.resolved_dynamic_patterns {
325 for target_id in matched_ids {
326 let idx = target_id.0 as usize;
327 if idx < total_capacity {
328 namespace_imported.insert(idx);
329 }
330 edges_by_target
331 .entry(*target_id)
332 .or_default()
333 .push(ImportedSymbol {
334 imported_name: ImportedName::Namespace,
335 local_name: String::new(),
336 import_span: oxc_span::Span::new(0, 0),
337 });
338 }
339 }
340
341 let mut sorted_edges: Vec<_> = edges_by_target.into_iter().collect();
343 sorted_edges.sort_by_key(|(target_id, _)| target_id.0);
344
345 for (target_id, symbols) in sorted_edges {
346 all_edges.push(Edge {
347 source: file.id,
348 target: target_id,
349 symbols,
350 });
351
352 if (target_id.0 as usize) < reverse_deps.len() {
353 reverse_deps[target_id.0 as usize].push(file.id);
354 }
355 }
356 }
357
358 let edge_end = all_edges.len();
359
360 let mut exports: Vec<ExportSymbol> = module_by_id
361 .get(&file.id)
362 .map(|m| {
363 m.exports
364 .iter()
365 .map(|e| ExportSymbol {
366 name: e.name.clone(),
367 is_type_only: e.is_type_only,
368 span: e.span,
369 references: Vec::new(),
370 members: e.members.clone(),
371 })
372 .collect()
373 })
374 .unwrap_or_default();
375
376 if let Some(resolved) = module_by_id.get(&file.id) {
381 for re in &resolved.re_exports {
382 if re.info.exported_name == "*" {
386 continue;
387 }
388
389 let export_name = if re.info.exported_name == "default" {
393 ExportName::Default
394 } else {
395 ExportName::Named(re.info.exported_name.clone())
396 };
397 let already_exists = exports.iter().any(|e| e.name == export_name);
398 if already_exists {
399 continue;
400 }
401
402 exports.push(ExportSymbol {
403 name: export_name,
404 is_type_only: re.info.is_type_only,
405 span: oxc_span::Span::new(0, 0), references: Vec::new(),
407 members: Vec::new(),
408 });
409 }
410 }
411
412 let has_cjs_exports = module_by_id
413 .get(&file.id)
414 .is_some_and(|m| m.has_cjs_exports);
415
416 let re_export_edges: Vec<ReExportEdge> = module_by_id
418 .get(&file.id)
419 .map(|m| {
420 m.re_exports
421 .iter()
422 .filter_map(|re| {
423 if let ResolveResult::InternalModule(target_id) = &re.target {
424 Some(ReExportEdge {
425 source_file: *target_id,
426 imported_name: re.info.imported_name.clone(),
427 exported_name: re.info.exported_name.clone(),
428 is_type_only: re.info.is_type_only,
429 })
430 } else {
431 None
432 }
433 })
434 .collect()
435 })
436 .unwrap_or_default();
437
438 modules.push(ModuleNode {
439 file_id: file.id,
440 path: file.path.clone(),
441 edge_range: edge_start..edge_end,
442 exports,
443 re_exports: re_export_edges,
444 is_entry_point: entry_point_ids.contains(&file.id),
445 is_reachable: false,
446 has_cjs_exports,
447 });
448 }
449
450 Self {
451 modules,
452 edges: all_edges,
453 package_usage,
454 type_only_package_usage,
455 entry_points: entry_point_ids.clone(),
456 reverse_deps,
457 namespace_imported,
458 }
459 }
460
461 #[allow(clippy::cognitive_complexity)]
467 fn populate_references(
468 &mut self,
469 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
470 entry_point_ids: &FxHashSet<FileId>,
471 ) {
472 for edge_idx in 0..self.edges.len() {
473 let source_id = self.edges[edge_idx].source;
474 let target_idx = self.edges[edge_idx].target.0 as usize;
475 if target_idx >= self.modules.len() {
476 continue;
477 }
478 for sym_idx in 0..self.edges[edge_idx].symbols.len() {
479 let sym_imported_name = self.edges[edge_idx].symbols[sym_idx].imported_name.clone();
480 let sym_local_name = self.edges[edge_idx].symbols[sym_idx].local_name.clone();
481 let sym_import_span = self.edges[edge_idx].symbols[sym_idx].import_span;
482
483 let ref_kind = match &sym_imported_name {
484 ImportedName::Named(_) => ReferenceKind::NamedImport,
485 ImportedName::Default => ReferenceKind::DefaultImport,
486 ImportedName::Namespace => ReferenceKind::NamespaceImport,
487 ImportedName::SideEffect => ReferenceKind::SideEffectImport,
488 };
489
490 let target_module = &mut self.modules[target_idx];
491
492 if let Some(export) = target_module
494 .exports
495 .iter_mut()
496 .find(|e| export_matches(&e.name, &sym_imported_name))
497 {
498 export.references.push(SymbolReference {
499 from_file: source_id,
500 kind: ref_kind,
501 import_span: sym_import_span,
502 });
503 }
504
505 if matches!(sym_imported_name, ImportedName::Namespace)
510 && !sym_local_name.is_empty()
511 {
512 let local_name = &sym_local_name;
513 let source_mod = module_by_id.get(&source_id);
514 let accessed_members: Vec<String> = source_mod
515 .map(|m| {
516 m.member_accesses
517 .iter()
518 .filter(|ma| ma.object == *local_name)
519 .map(|ma| ma.member.clone())
520 .collect()
521 })
522 .unwrap_or_default();
523
524 let is_re_exported_from_non_entry = source_mod.is_some_and(|m| {
528 m.exports
529 .iter()
530 .any(|e| e.local_name.as_deref() == Some(local_name.as_str()))
531 }) && !entry_point_ids.contains(&source_id);
532
533 let is_entry_with_no_access =
537 accessed_members.is_empty() && entry_point_ids.contains(&source_id);
538
539 if !is_entry_with_no_access
540 && (accessed_members.is_empty() || is_re_exported_from_non_entry)
541 {
542 for export in &mut self.modules[target_idx].exports {
544 if export.references.iter().all(|r| r.from_file != source_id) {
545 export.references.push(SymbolReference {
546 from_file: source_id,
547 kind: ReferenceKind::NamespaceImport,
548 import_span: sym_import_span,
549 });
550 }
551 }
552 } else {
553 let mut found_members: FxHashSet<String> = FxHashSet::default();
555 for export in &mut self.modules[target_idx].exports {
556 let name_str = export.name.to_string();
557 if accessed_members.contains(&name_str) {
558 found_members.insert(name_str);
559 if export.references.iter().all(|r| r.from_file != source_id) {
560 export.references.push(SymbolReference {
561 from_file: source_id,
562 kind: ReferenceKind::NamespaceImport,
563 import_span: sym_import_span,
564 });
565 }
566 }
567 }
568
569 let target_has_star_re_exports = self.modules[target_idx]
575 .re_exports
576 .iter()
577 .any(|re| re.exported_name == "*");
578 if target_has_star_re_exports {
579 for member in &accessed_members {
580 if !found_members.contains(member) {
581 let export_name = if member == "default" {
582 ExportName::Default
583 } else {
584 ExportName::Named(member.clone())
585 };
586 self.modules[target_idx].exports.push(ExportSymbol {
587 name: export_name,
588 is_type_only: false,
589 span: oxc_span::Span::new(0, 0),
590 references: vec![SymbolReference {
591 from_file: source_id,
592 kind: ReferenceKind::NamespaceImport,
593 import_span: sym_import_span,
594 }],
595 members: Vec::new(),
596 });
597 }
598 }
599 }
600 }
601 } else if matches!(sym_imported_name, ImportedName::Namespace) {
602 for export in &mut self.modules[target_idx].exports {
604 if export.references.iter().all(|r| r.from_file != source_id) {
605 export.references.push(SymbolReference {
606 from_file: source_id,
607 kind: ReferenceKind::NamespaceImport,
608 import_span: sym_import_span,
609 });
610 }
611 }
612 }
613
614 if matches!(sym_imported_name, ImportedName::Default)
619 && !sym_local_name.is_empty()
620 && is_css_module_path(&self.modules[target_idx].path)
621 {
622 let local_name = &sym_local_name;
623 let source_mod = module_by_id.get(&source_id);
624 let is_whole_object = source_mod
625 .is_some_and(|m| m.whole_object_uses.iter().any(|n| n == local_name));
626 let accessed_members: Vec<String> = source_mod
627 .map(|m| {
628 m.member_accesses
629 .iter()
630 .filter(|ma| ma.object == *local_name)
631 .map(|ma| ma.member.clone())
632 .collect()
633 })
634 .unwrap_or_default();
635 if is_whole_object || accessed_members.is_empty() {
636 for export in &mut self.modules[target_idx].exports {
637 if export.references.iter().all(|r| r.from_file != source_id) {
638 export.references.push(SymbolReference {
639 from_file: source_id,
640 kind: ReferenceKind::DefaultImport,
641 import_span: sym_import_span,
642 });
643 }
644 }
645 } else {
646 for export in &mut self.modules[target_idx].exports {
647 let name_str = export.name.to_string();
648 if accessed_members.contains(&name_str)
649 && export.references.iter().all(|r| r.from_file != source_id)
650 {
651 export.references.push(SymbolReference {
652 from_file: source_id,
653 kind: ReferenceKind::DefaultImport,
654 import_span: sym_import_span,
655 });
656 }
657 }
658 }
659 }
660 }
661 }
662 }
663
664 fn mark_reachable(&mut self, total_capacity: usize) {
666 let mut visited = FixedBitSet::with_capacity(total_capacity);
667 let mut queue = VecDeque::new();
668
669 for &ep_id in &self.entry_points {
670 if (ep_id.0 as usize) < total_capacity {
671 visited.insert(ep_id.0 as usize);
672 queue.push_back(ep_id);
673 }
674 }
675
676 while let Some(file_id) = queue.pop_front() {
677 if (file_id.0 as usize) >= self.modules.len() {
678 continue;
679 }
680 let module = &self.modules[file_id.0 as usize];
681 for edge in &self.edges[module.edge_range.clone()] {
682 let target_idx = edge.target.0 as usize;
683 if target_idx < total_capacity && !visited.contains(target_idx) {
684 visited.insert(target_idx);
685 queue.push_back(edge.target);
686 }
687 }
688 }
689
690 for (idx, module) in self.modules.iter_mut().enumerate() {
691 module.is_reachable = visited.contains(idx);
692 }
693 }
694
695 fn resolve_re_export_chains(&mut self) {
699 let re_export_info: Vec<(FileId, FileId, String, String)> = self
701 .modules
702 .iter()
703 .flat_map(|m| {
704 m.re_exports.iter().map(move |re| {
705 (
706 m.file_id,
707 re.source_file,
708 re.imported_name.clone(),
709 re.exported_name.clone(),
710 )
711 })
712 })
713 .collect();
714
715 if re_export_info.is_empty() {
716 return;
717 }
718
719 let mut changed = true;
723 let max_iterations = 20; let mut iteration = 0;
725 let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
729
730 while changed && iteration < max_iterations {
731 changed = false;
732 iteration += 1;
733
734 for &(barrel_id, source_id, ref imported_name, ref exported_name) in &re_export_info {
735 let barrel_idx = barrel_id.0 as usize;
736 let source_idx = source_id.0 as usize;
737
738 if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
739 continue;
740 }
741
742 if exported_name == "*" {
743 let barrel_file_id = self.modules[barrel_idx].file_id;
750 let named_refs: Vec<(String, SymbolReference)> = self
751 .edges
752 .iter()
753 .filter(|edge| edge.target == barrel_file_id)
754 .flat_map(|edge| {
755 edge.symbols.iter().filter_map(move |sym| {
756 if let ImportedName::Named(name) = &sym.imported_name {
757 Some((
758 name.clone(),
759 SymbolReference {
760 from_file: edge.source,
761 kind: ReferenceKind::NamedImport,
762 import_span: sym.import_span,
763 },
764 ))
765 } else {
766 None
767 }
768 })
769 })
770 .collect();
771
772 let barrel_export_refs: Vec<(String, SymbolReference)> = self.modules
775 [barrel_idx]
776 .exports
777 .iter()
778 .flat_map(|e| {
779 e.references
780 .iter()
781 .map(move |r| (e.name.to_string(), r.clone()))
782 })
783 .collect();
784
785 let source_has_star_re_exports = self.modules[source_idx]
789 .re_exports
790 .iter()
791 .any(|re| re.exported_name == "*");
792
793 let source = &mut self.modules[source_idx];
800 for (name, ref_item) in named_refs.iter().chain(barrel_export_refs.iter()) {
801 let export_name = if name == "default" {
802 ExportName::Default
803 } else {
804 ExportName::Named(name.clone())
805 };
806 if let Some(export) =
807 source.exports.iter_mut().find(|e| e.name == export_name)
808 {
809 if export
810 .references
811 .iter()
812 .all(|r| r.from_file != ref_item.from_file)
813 {
814 export.references.push(ref_item.clone());
815 changed = true;
816 }
817 } else if source_has_star_re_exports {
818 source.exports.push(ExportSymbol {
823 name: export_name,
824 is_type_only: false,
825 span: oxc_span::Span::new(0, 0),
826 references: vec![ref_item.clone()],
827 members: Vec::new(),
828 });
829 changed = true;
830 }
831 }
832 } else {
833 let refs_on_barrel: Vec<SymbolReference> = {
835 let barrel = &self.modules[barrel_idx];
836 barrel
837 .exports
838 .iter()
839 .filter(|e| e.name.to_string() == *exported_name)
840 .flat_map(|e| e.references.clone())
841 .collect()
842 };
843
844 if refs_on_barrel.is_empty() {
845 continue;
846 }
847
848 let source = &mut self.modules[source_idx];
850 let target_exports: Vec<usize> = source
851 .exports
852 .iter()
853 .enumerate()
854 .filter(|(_, e)| e.name.to_string() == *imported_name)
855 .map(|(i, _)| i)
856 .collect();
857
858 for export_idx in target_exports {
859 existing_refs.clear();
860 existing_refs.extend(
861 source.exports[export_idx]
862 .references
863 .iter()
864 .map(|r| r.from_file),
865 );
866 for ref_item in &refs_on_barrel {
867 if !existing_refs.contains(&ref_item.from_file) {
868 source.exports[export_idx].references.push(ref_item.clone());
869 changed = true;
870 }
871 }
872 }
873 }
874 }
875 }
876
877 if iteration >= max_iterations {
878 tracing::warn!(
879 iterations = max_iterations,
880 "Re-export chain resolution hit iteration limit, some chains may be incomplete"
881 );
882 }
883 }
884
885 pub const fn module_count(&self) -> usize {
887 self.modules.len()
888 }
889
890 pub const fn edge_count(&self) -> usize {
892 self.edges.len()
893 }
894
895 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
898 let idx = file_id.0 as usize;
899 if idx >= self.namespace_imported.len() {
900 return false;
901 }
902 self.namespace_imported.contains(idx)
903 }
904
905 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
907 let idx = file_id.0 as usize;
908 if idx >= self.modules.len() {
909 return Vec::new();
910 }
911 let range = &self.modules[idx].edge_range;
912 self.edges[range.clone()].iter().map(|e| e.target).collect()
913 }
914
915 pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
924 let n = self.modules.len();
925 if n == 0 {
926 return Vec::new();
927 }
928
929 let mut index_counter: u32 = 0;
931 let mut indices: Vec<u32> = vec![u32::MAX; n]; let mut lowlinks: Vec<u32> = vec![0; n];
933 let mut on_stack = FixedBitSet::with_capacity(n);
934 let mut stack: Vec<usize> = Vec::new();
935 let mut sccs: Vec<Vec<FileId>> = Vec::new();
936
937 struct Frame {
939 node: usize,
940 succ_pos: usize,
941 succ_end: usize,
942 }
943
944 let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
946 let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
947 let mut seen_set = FxHashSet::default();
948 for module in &self.modules {
949 let start = all_succs.len();
950 seen_set.clear();
951 for edge in &self.edges[module.edge_range.clone()] {
952 let target = edge.target.0 as usize;
953 if target < n && seen_set.insert(target) {
954 all_succs.push(target);
955 }
956 }
957 let end = all_succs.len();
958 succ_ranges.push(start..end);
959 }
960
961 let mut dfs_stack: Vec<Frame> = Vec::new();
962
963 for start_node in 0..n {
964 if indices[start_node] != u32::MAX {
965 continue;
966 }
967
968 indices[start_node] = index_counter;
970 lowlinks[start_node] = index_counter;
971 index_counter += 1;
972 on_stack.insert(start_node);
973 stack.push(start_node);
974
975 let range = &succ_ranges[start_node];
976 dfs_stack.push(Frame {
977 node: start_node,
978 succ_pos: range.start,
979 succ_end: range.end,
980 });
981
982 while let Some(frame) = dfs_stack.last_mut() {
983 if frame.succ_pos < frame.succ_end {
984 let w = all_succs[frame.succ_pos];
985 frame.succ_pos += 1;
986
987 if indices[w] == u32::MAX {
988 indices[w] = index_counter;
990 lowlinks[w] = index_counter;
991 index_counter += 1;
992 on_stack.insert(w);
993 stack.push(w);
994
995 let range = &succ_ranges[w];
996 dfs_stack.push(Frame {
997 node: w,
998 succ_pos: range.start,
999 succ_end: range.end,
1000 });
1001 } else if on_stack.contains(w) {
1002 let v = frame.node;
1004 lowlinks[v] = lowlinks[v].min(indices[w]);
1005 }
1006 } else {
1007 let v = frame.node;
1009 let v_lowlink = lowlinks[v];
1010 let v_index = indices[v];
1011 dfs_stack.pop();
1012
1013 if let Some(parent) = dfs_stack.last_mut() {
1015 lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
1016 }
1017
1018 if v_lowlink == v_index {
1020 let mut scc = Vec::new();
1021 loop {
1022 let w = stack.pop().expect("SCC stack should not be empty");
1023 on_stack.set(w, false);
1024 scc.push(FileId(w as u32));
1025 if w == v {
1026 break;
1027 }
1028 }
1029 if scc.len() >= 2 {
1031 sccs.push(scc);
1032 }
1033 }
1034 }
1035 }
1036 }
1037
1038 const MAX_CYCLES_PER_SCC: usize = 20;
1042
1043 let mut result: Vec<Vec<FileId>> = Vec::new();
1044 let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
1045
1046 for scc in &sccs {
1047 if scc.len() == 2 {
1048 let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
1049 if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
1051 cycle.swap(0, 1);
1052 }
1053 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
1054 if seen_cycles.insert(key) {
1055 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
1056 }
1057 continue;
1058 }
1059
1060 let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
1061 let elementary = enumerate_elementary_cycles(
1062 &scc_nodes,
1063 &all_succs,
1064 &succ_ranges,
1065 MAX_CYCLES_PER_SCC,
1066 &self.modules,
1067 );
1068
1069 for cycle in elementary {
1070 let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
1071 if seen_cycles.insert(key) {
1072 result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
1073 }
1074 }
1075 }
1076
1077 result.sort_by(|a, b| {
1079 a.len().cmp(&b.len()).then_with(|| {
1080 self.modules[a[0].0 as usize]
1081 .path
1082 .cmp(&self.modules[b[0].0 as usize].path)
1083 })
1084 });
1085
1086 result
1087 }
1088}
1089
1090fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
1092 if cycle.is_empty() {
1093 return Vec::new();
1094 }
1095 let min_pos = cycle
1096 .iter()
1097 .enumerate()
1098 .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
1099 .map_or(0, |(i, _)| i);
1100 let mut result = cycle[min_pos..].to_vec();
1101 result.extend_from_slice(&cycle[..min_pos]);
1102 result
1103}
1104
1105fn enumerate_elementary_cycles(
1111 scc_nodes: &[usize],
1112 all_succs: &[usize],
1113 succ_ranges: &[Range<usize>],
1114 max_cycles: usize,
1115 modules: &[ModuleNode],
1116) -> Vec<Vec<usize>> {
1117 let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
1118 let mut cycles: Vec<Vec<usize>> = Vec::new();
1119 let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
1120
1121 let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
1123 sorted_nodes.sort_by(|a, b| modules[*a].path.cmp(&modules[*b].path));
1124
1125 struct CycleFrame {
1127 succ_pos: usize,
1128 succ_end: usize,
1129 }
1130
1131 let max_depth = scc_nodes.len().min(12); for depth_limit in 2..=max_depth {
1134 if cycles.len() >= max_cycles {
1135 break;
1136 }
1137
1138 for &start in &sorted_nodes {
1139 if cycles.len() >= max_cycles {
1140 break;
1141 }
1142
1143 let mut path: Vec<usize> = vec![start];
1144 let mut path_set = FixedBitSet::with_capacity(modules.len());
1145 path_set.insert(start);
1146
1147 let range = &succ_ranges[start];
1148 let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
1149 succ_pos: range.start,
1150 succ_end: range.end,
1151 }];
1152
1153 while let Some(frame) = dfs.last_mut() {
1154 if cycles.len() >= max_cycles {
1155 break;
1156 }
1157
1158 if frame.succ_pos >= frame.succ_end {
1159 dfs.pop();
1161 if path.len() > 1 {
1162 let last = path.pop().unwrap();
1163 path_set.set(last, false);
1164 }
1165 continue;
1166 }
1167
1168 let w = all_succs[frame.succ_pos];
1169 frame.succ_pos += 1;
1170
1171 if !scc_set.contains(&w) {
1173 continue;
1174 }
1175
1176 if w == start && path.len() >= 2 && path.len() == depth_limit {
1177 let canonical = canonical_cycle(&path, modules);
1179 let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
1180 if seen.insert(key) {
1181 cycles.push(canonical);
1182 }
1183 } else if !path_set.contains(w) && path.len() < depth_limit {
1184 path.push(w);
1186 path_set.insert(w);
1187
1188 let range = &succ_ranges[w];
1189 dfs.push(CycleFrame {
1190 succ_pos: range.start,
1191 succ_end: range.end,
1192 });
1193 }
1194 }
1195 }
1196 }
1197
1198 cycles
1199}
1200
1201fn is_css_module_path(path: &std::path::Path) -> bool {
1203 path.file_stem()
1204 .and_then(|s| s.to_str())
1205 .is_some_and(|stem| stem.ends_with(".module"))
1206 && path
1207 .extension()
1208 .and_then(|e| e.to_str())
1209 .is_some_and(|ext| ext == "css" || ext == "scss")
1210}
1211
1212fn export_matches(export: &ExportName, import: &ImportedName) -> bool {
1214 match (export, import) {
1215 (ExportName::Named(e), ImportedName::Named(i)) => e == i,
1216 (ExportName::Default, ImportedName::Default) => true,
1217 _ => false,
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1225 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1226 use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
1227 use std::path::PathBuf;
1228
1229 #[test]
1230 fn export_matches_named_same() {
1231 assert!(export_matches(
1232 &ExportName::Named("foo".to_string()),
1233 &ImportedName::Named("foo".to_string())
1234 ));
1235 }
1236
1237 #[test]
1238 fn export_matches_named_different() {
1239 assert!(!export_matches(
1240 &ExportName::Named("foo".to_string()),
1241 &ImportedName::Named("bar".to_string())
1242 ));
1243 }
1244
1245 #[test]
1246 fn export_matches_default() {
1247 assert!(export_matches(&ExportName::Default, &ImportedName::Default));
1248 }
1249
1250 #[test]
1251 fn export_matches_named_vs_default() {
1252 assert!(!export_matches(
1253 &ExportName::Named("foo".to_string()),
1254 &ImportedName::Default
1255 ));
1256 }
1257
1258 #[test]
1259 fn export_matches_default_vs_named() {
1260 assert!(!export_matches(
1261 &ExportName::Default,
1262 &ImportedName::Named("foo".to_string())
1263 ));
1264 }
1265
1266 #[test]
1267 fn export_matches_namespace_no_match() {
1268 assert!(!export_matches(
1269 &ExportName::Named("foo".to_string()),
1270 &ImportedName::Namespace
1271 ));
1272 assert!(!export_matches(
1273 &ExportName::Default,
1274 &ImportedName::Namespace
1275 ));
1276 }
1277
1278 #[test]
1279 fn export_matches_side_effect_no_match() {
1280 assert!(!export_matches(
1281 &ExportName::Named("foo".to_string()),
1282 &ImportedName::SideEffect
1283 ));
1284 }
1285
1286 fn build_simple_graph() -> ModuleGraph {
1288 let files = vec![
1290 DiscoveredFile {
1291 id: FileId(0),
1292 path: PathBuf::from("/project/src/entry.ts"),
1293 size_bytes: 100,
1294 },
1295 DiscoveredFile {
1296 id: FileId(1),
1297 path: PathBuf::from("/project/src/utils.ts"),
1298 size_bytes: 50,
1299 },
1300 ];
1301
1302 let entry_points = vec![EntryPoint {
1303 path: PathBuf::from("/project/src/entry.ts"),
1304 source: EntryPointSource::PackageJsonMain,
1305 }];
1306
1307 let resolved_modules = vec![
1308 ResolvedModule {
1309 file_id: FileId(0),
1310 path: PathBuf::from("/project/src/entry.ts"),
1311 exports: vec![],
1312 re_exports: vec![],
1313 resolved_imports: vec![ResolvedImport {
1314 info: ImportInfo {
1315 source: "./utils".to_string(),
1316 imported_name: ImportedName::Named("foo".to_string()),
1317 local_name: "foo".to_string(),
1318 is_type_only: false,
1319 span: oxc_span::Span::new(0, 10),
1320 },
1321 target: ResolveResult::InternalModule(FileId(1)),
1322 }],
1323 resolved_dynamic_imports: vec![],
1324 resolved_dynamic_patterns: vec![],
1325 member_accesses: vec![],
1326 whole_object_uses: vec![],
1327 has_cjs_exports: false,
1328 },
1329 ResolvedModule {
1330 file_id: FileId(1),
1331 path: PathBuf::from("/project/src/utils.ts"),
1332 exports: vec![
1333 fallow_types::extract::ExportInfo {
1334 name: ExportName::Named("foo".to_string()),
1335 local_name: Some("foo".to_string()),
1336 is_type_only: false,
1337 span: oxc_span::Span::new(0, 20),
1338 members: vec![],
1339 },
1340 fallow_types::extract::ExportInfo {
1341 name: ExportName::Named("bar".to_string()),
1342 local_name: Some("bar".to_string()),
1343 is_type_only: false,
1344 span: oxc_span::Span::new(25, 45),
1345 members: vec![],
1346 },
1347 ],
1348 re_exports: vec![],
1349 resolved_imports: vec![],
1350 resolved_dynamic_imports: vec![],
1351 resolved_dynamic_patterns: vec![],
1352 member_accesses: vec![],
1353 whole_object_uses: vec![],
1354 has_cjs_exports: false,
1355 },
1356 ];
1357
1358 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1359 }
1360
1361 #[test]
1362 fn graph_module_count() {
1363 let graph = build_simple_graph();
1364 assert_eq!(graph.module_count(), 2);
1365 }
1366
1367 #[test]
1368 fn graph_edge_count() {
1369 let graph = build_simple_graph();
1370 assert_eq!(graph.edge_count(), 1);
1371 }
1372
1373 #[test]
1374 fn graph_entry_point_is_reachable() {
1375 let graph = build_simple_graph();
1376 assert!(graph.modules[0].is_entry_point);
1377 assert!(graph.modules[0].is_reachable);
1378 }
1379
1380 #[test]
1381 fn graph_imported_module_is_reachable() {
1382 let graph = build_simple_graph();
1383 assert!(!graph.modules[1].is_entry_point);
1384 assert!(graph.modules[1].is_reachable);
1385 }
1386
1387 #[test]
1388 fn graph_export_has_reference() {
1389 let graph = build_simple_graph();
1390 let utils = &graph.modules[1];
1391 let foo_export = utils
1392 .exports
1393 .iter()
1394 .find(|e| e.name.to_string() == "foo")
1395 .unwrap();
1396 assert!(
1397 !foo_export.references.is_empty(),
1398 "foo should have references"
1399 );
1400 }
1401
1402 #[test]
1403 fn graph_unused_export_no_reference() {
1404 let graph = build_simple_graph();
1405 let utils = &graph.modules[1];
1406 let bar_export = utils
1407 .exports
1408 .iter()
1409 .find(|e| e.name.to_string() == "bar")
1410 .unwrap();
1411 assert!(
1412 bar_export.references.is_empty(),
1413 "bar should have no references"
1414 );
1415 }
1416
1417 #[test]
1418 fn graph_no_namespace_import() {
1419 let graph = build_simple_graph();
1420 assert!(!graph.has_namespace_import(FileId(0)));
1421 assert!(!graph.has_namespace_import(FileId(1)));
1422 }
1423
1424 #[test]
1425 fn graph_has_namespace_import() {
1426 let files = vec![
1427 DiscoveredFile {
1428 id: FileId(0),
1429 path: PathBuf::from("/project/entry.ts"),
1430 size_bytes: 100,
1431 },
1432 DiscoveredFile {
1433 id: FileId(1),
1434 path: PathBuf::from("/project/utils.ts"),
1435 size_bytes: 50,
1436 },
1437 ];
1438
1439 let entry_points = vec![EntryPoint {
1440 path: PathBuf::from("/project/entry.ts"),
1441 source: EntryPointSource::PackageJsonMain,
1442 }];
1443
1444 let resolved_modules = vec![
1445 ResolvedModule {
1446 file_id: FileId(0),
1447 path: PathBuf::from("/project/entry.ts"),
1448 exports: vec![],
1449 re_exports: vec![],
1450 resolved_imports: vec![ResolvedImport {
1451 info: ImportInfo {
1452 source: "./utils".to_string(),
1453 imported_name: ImportedName::Namespace,
1454 local_name: "utils".to_string(),
1455 is_type_only: false,
1456 span: oxc_span::Span::new(0, 10),
1457 },
1458 target: ResolveResult::InternalModule(FileId(1)),
1459 }],
1460 resolved_dynamic_imports: vec![],
1461 resolved_dynamic_patterns: vec![],
1462 member_accesses: vec![],
1463 whole_object_uses: vec![],
1464 has_cjs_exports: false,
1465 },
1466 ResolvedModule {
1467 file_id: FileId(1),
1468 path: PathBuf::from("/project/utils.ts"),
1469 exports: vec![fallow_types::extract::ExportInfo {
1470 name: ExportName::Named("foo".to_string()),
1471 local_name: Some("foo".to_string()),
1472 is_type_only: false,
1473 span: oxc_span::Span::new(0, 20),
1474 members: vec![],
1475 }],
1476 re_exports: vec![],
1477 resolved_imports: vec![],
1478 resolved_dynamic_imports: vec![],
1479 resolved_dynamic_patterns: vec![],
1480 member_accesses: vec![],
1481 whole_object_uses: vec![],
1482 has_cjs_exports: false,
1483 },
1484 ];
1485
1486 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1487 assert!(
1488 graph.has_namespace_import(FileId(1)),
1489 "utils should have namespace import"
1490 );
1491 }
1492
1493 #[test]
1494 fn graph_has_namespace_import_out_of_bounds() {
1495 let graph = build_simple_graph();
1496 assert!(!graph.has_namespace_import(FileId(999)));
1497 }
1498
1499 #[test]
1500 fn graph_unreachable_module() {
1501 let files = vec![
1503 DiscoveredFile {
1504 id: FileId(0),
1505 path: PathBuf::from("/project/entry.ts"),
1506 size_bytes: 100,
1507 },
1508 DiscoveredFile {
1509 id: FileId(1),
1510 path: PathBuf::from("/project/utils.ts"),
1511 size_bytes: 50,
1512 },
1513 DiscoveredFile {
1514 id: FileId(2),
1515 path: PathBuf::from("/project/orphan.ts"),
1516 size_bytes: 30,
1517 },
1518 ];
1519
1520 let entry_points = vec![EntryPoint {
1521 path: PathBuf::from("/project/entry.ts"),
1522 source: EntryPointSource::PackageJsonMain,
1523 }];
1524
1525 let resolved_modules = vec![
1526 ResolvedModule {
1527 file_id: FileId(0),
1528 path: PathBuf::from("/project/entry.ts"),
1529 exports: vec![],
1530 re_exports: vec![],
1531 resolved_imports: vec![ResolvedImport {
1532 info: ImportInfo {
1533 source: "./utils".to_string(),
1534 imported_name: ImportedName::Named("foo".to_string()),
1535 local_name: "foo".to_string(),
1536 is_type_only: false,
1537 span: oxc_span::Span::new(0, 10),
1538 },
1539 target: ResolveResult::InternalModule(FileId(1)),
1540 }],
1541 resolved_dynamic_imports: vec![],
1542 resolved_dynamic_patterns: vec![],
1543 member_accesses: vec![],
1544 whole_object_uses: vec![],
1545 has_cjs_exports: false,
1546 },
1547 ResolvedModule {
1548 file_id: FileId(1),
1549 path: PathBuf::from("/project/utils.ts"),
1550 exports: vec![fallow_types::extract::ExportInfo {
1551 name: ExportName::Named("foo".to_string()),
1552 local_name: Some("foo".to_string()),
1553 is_type_only: false,
1554 span: oxc_span::Span::new(0, 20),
1555 members: vec![],
1556 }],
1557 re_exports: vec![],
1558 resolved_imports: vec![],
1559 resolved_dynamic_imports: vec![],
1560 resolved_dynamic_patterns: vec![],
1561 member_accesses: vec![],
1562 whole_object_uses: vec![],
1563 has_cjs_exports: false,
1564 },
1565 ResolvedModule {
1566 file_id: FileId(2),
1567 path: PathBuf::from("/project/orphan.ts"),
1568 exports: vec![fallow_types::extract::ExportInfo {
1569 name: ExportName::Named("orphan".to_string()),
1570 local_name: Some("orphan".to_string()),
1571 is_type_only: false,
1572 span: oxc_span::Span::new(0, 20),
1573 members: vec![],
1574 }],
1575 re_exports: vec![],
1576 resolved_imports: vec![],
1577 resolved_dynamic_imports: vec![],
1578 resolved_dynamic_patterns: vec![],
1579 member_accesses: vec![],
1580 whole_object_uses: vec![],
1581 has_cjs_exports: false,
1582 },
1583 ];
1584
1585 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1586
1587 assert!(graph.modules[0].is_reachable, "entry should be reachable");
1588 assert!(graph.modules[1].is_reachable, "utils should be reachable");
1589 assert!(
1590 !graph.modules[2].is_reachable,
1591 "orphan should NOT be reachable"
1592 );
1593 }
1594
1595 #[test]
1596 fn graph_package_usage_tracked() {
1597 let files = vec![DiscoveredFile {
1598 id: FileId(0),
1599 path: PathBuf::from("/project/entry.ts"),
1600 size_bytes: 100,
1601 }];
1602
1603 let entry_points = vec![EntryPoint {
1604 path: PathBuf::from("/project/entry.ts"),
1605 source: EntryPointSource::PackageJsonMain,
1606 }];
1607
1608 let resolved_modules = vec![ResolvedModule {
1609 file_id: FileId(0),
1610 path: PathBuf::from("/project/entry.ts"),
1611 exports: vec![],
1612 re_exports: vec![],
1613 resolved_imports: vec![
1614 ResolvedImport {
1615 info: ImportInfo {
1616 source: "react".to_string(),
1617 imported_name: ImportedName::Default,
1618 local_name: "React".to_string(),
1619 is_type_only: false,
1620 span: oxc_span::Span::new(0, 10),
1621 },
1622 target: ResolveResult::NpmPackage("react".to_string()),
1623 },
1624 ResolvedImport {
1625 info: ImportInfo {
1626 source: "lodash".to_string(),
1627 imported_name: ImportedName::Named("merge".to_string()),
1628 local_name: "merge".to_string(),
1629 is_type_only: false,
1630 span: oxc_span::Span::new(15, 30),
1631 },
1632 target: ResolveResult::NpmPackage("lodash".to_string()),
1633 },
1634 ],
1635 resolved_dynamic_imports: vec![],
1636 resolved_dynamic_patterns: vec![],
1637 member_accesses: vec![],
1638 whole_object_uses: vec![],
1639 has_cjs_exports: false,
1640 }];
1641
1642 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1643 assert!(graph.package_usage.contains_key("react"));
1644 assert!(graph.package_usage.contains_key("lodash"));
1645 assert!(!graph.package_usage.contains_key("express"));
1646 }
1647
1648 #[test]
1649 fn graph_re_export_chain_propagates_references() {
1650 let files = vec![
1652 DiscoveredFile {
1653 id: FileId(0),
1654 path: PathBuf::from("/project/entry.ts"),
1655 size_bytes: 100,
1656 },
1657 DiscoveredFile {
1658 id: FileId(1),
1659 path: PathBuf::from("/project/barrel.ts"),
1660 size_bytes: 50,
1661 },
1662 DiscoveredFile {
1663 id: FileId(2),
1664 path: PathBuf::from("/project/source.ts"),
1665 size_bytes: 50,
1666 },
1667 ];
1668
1669 let entry_points = vec![EntryPoint {
1670 path: PathBuf::from("/project/entry.ts"),
1671 source: EntryPointSource::PackageJsonMain,
1672 }];
1673
1674 let resolved_modules = vec![
1675 ResolvedModule {
1677 file_id: FileId(0),
1678 path: PathBuf::from("/project/entry.ts"),
1679 exports: vec![],
1680 re_exports: vec![],
1681 resolved_imports: vec![ResolvedImport {
1682 info: ImportInfo {
1683 source: "./barrel".to_string(),
1684 imported_name: ImportedName::Named("foo".to_string()),
1685 local_name: "foo".to_string(),
1686 is_type_only: false,
1687 span: oxc_span::Span::new(0, 10),
1688 },
1689 target: ResolveResult::InternalModule(FileId(1)),
1690 }],
1691 resolved_dynamic_imports: vec![],
1692 resolved_dynamic_patterns: vec![],
1693 member_accesses: vec![],
1694 whole_object_uses: vec![],
1695 has_cjs_exports: false,
1696 },
1697 ResolvedModule {
1699 file_id: FileId(1),
1700 path: PathBuf::from("/project/barrel.ts"),
1701 exports: vec![fallow_types::extract::ExportInfo {
1702 name: ExportName::Named("foo".to_string()),
1703 local_name: Some("foo".to_string()),
1704 is_type_only: false,
1705 span: oxc_span::Span::new(0, 20),
1706 members: vec![],
1707 }],
1708 re_exports: vec![ResolvedReExport {
1709 info: fallow_types::extract::ReExportInfo {
1710 source: "./source".to_string(),
1711 imported_name: "foo".to_string(),
1712 exported_name: "foo".to_string(),
1713 is_type_only: false,
1714 },
1715 target: ResolveResult::InternalModule(FileId(2)),
1716 }],
1717 resolved_imports: vec![],
1718 resolved_dynamic_imports: vec![],
1719 resolved_dynamic_patterns: vec![],
1720 member_accesses: vec![],
1721 whole_object_uses: vec![],
1722 has_cjs_exports: false,
1723 },
1724 ResolvedModule {
1726 file_id: FileId(2),
1727 path: PathBuf::from("/project/source.ts"),
1728 exports: vec![fallow_types::extract::ExportInfo {
1729 name: ExportName::Named("foo".to_string()),
1730 local_name: Some("foo".to_string()),
1731 is_type_only: false,
1732 span: oxc_span::Span::new(0, 20),
1733 members: vec![],
1734 }],
1735 re_exports: vec![],
1736 resolved_imports: vec![],
1737 resolved_dynamic_imports: vec![],
1738 resolved_dynamic_patterns: vec![],
1739 member_accesses: vec![],
1740 whole_object_uses: vec![],
1741 has_cjs_exports: false,
1742 },
1743 ];
1744
1745 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1746
1747 let source_module = &graph.modules[2];
1749 let foo_export = source_module
1750 .exports
1751 .iter()
1752 .find(|e| e.name.to_string() == "foo")
1753 .unwrap();
1754 assert!(
1755 !foo_export.references.is_empty(),
1756 "source foo should have propagated references through barrel re-export chain"
1757 );
1758 }
1759
1760 #[test]
1761 fn graph_empty() {
1762 let graph = ModuleGraph::build(&[], &[], &[]);
1763 assert_eq!(graph.module_count(), 0);
1764 assert_eq!(graph.edge_count(), 0);
1765 }
1766
1767 #[test]
1768 fn graph_cjs_exports_tracked() {
1769 let files = vec![DiscoveredFile {
1770 id: FileId(0),
1771 path: PathBuf::from("/project/entry.ts"),
1772 size_bytes: 100,
1773 }];
1774
1775 let entry_points = vec![EntryPoint {
1776 path: PathBuf::from("/project/entry.ts"),
1777 source: EntryPointSource::PackageJsonMain,
1778 }];
1779
1780 let resolved_modules = vec![ResolvedModule {
1781 file_id: FileId(0),
1782 path: PathBuf::from("/project/entry.ts"),
1783 exports: vec![],
1784 re_exports: vec![],
1785 resolved_imports: vec![],
1786 resolved_dynamic_imports: vec![],
1787 resolved_dynamic_patterns: vec![],
1788 member_accesses: vec![],
1789 whole_object_uses: vec![],
1790 has_cjs_exports: true,
1791 }];
1792
1793 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1794 assert!(graph.modules[0].has_cjs_exports);
1795 }
1796
1797 #[test]
1798 fn barrel_re_export_creates_export_symbol() {
1799 let files = vec![
1800 DiscoveredFile {
1801 id: FileId(0),
1802 path: PathBuf::from("/project/entry.ts"),
1803 size_bytes: 100,
1804 },
1805 DiscoveredFile {
1806 id: FileId(1),
1807 path: PathBuf::from("/project/barrel.ts"),
1808 size_bytes: 50,
1809 },
1810 DiscoveredFile {
1811 id: FileId(2),
1812 path: PathBuf::from("/project/source.ts"),
1813 size_bytes: 50,
1814 },
1815 ];
1816
1817 let entry_points = vec![EntryPoint {
1818 path: PathBuf::from("/project/entry.ts"),
1819 source: EntryPointSource::PackageJsonMain,
1820 }];
1821
1822 let resolved_modules = vec![
1823 ResolvedModule {
1824 file_id: FileId(0),
1825 path: PathBuf::from("/project/entry.ts"),
1826 exports: vec![],
1827 re_exports: vec![],
1828 resolved_imports: vec![ResolvedImport {
1829 info: ImportInfo {
1830 source: "./barrel".to_string(),
1831 imported_name: ImportedName::Named("foo".to_string()),
1832 local_name: "foo".to_string(),
1833 is_type_only: false,
1834 span: oxc_span::Span::new(0, 10),
1835 },
1836 target: ResolveResult::InternalModule(FileId(1)),
1837 }],
1838 resolved_dynamic_imports: vec![],
1839 resolved_dynamic_patterns: vec![],
1840 member_accesses: vec![],
1841 whole_object_uses: vec![],
1842 has_cjs_exports: false,
1843 },
1844 ResolvedModule {
1845 file_id: FileId(1),
1846 path: PathBuf::from("/project/barrel.ts"),
1847 exports: vec![],
1848 re_exports: vec![ResolvedReExport {
1849 info: fallow_types::extract::ReExportInfo {
1850 source: "./source".to_string(),
1851 imported_name: "foo".to_string(),
1852 exported_name: "foo".to_string(),
1853 is_type_only: false,
1854 },
1855 target: ResolveResult::InternalModule(FileId(2)),
1856 }],
1857 resolved_imports: vec![],
1858 resolved_dynamic_imports: vec![],
1859 resolved_dynamic_patterns: vec![],
1860 member_accesses: vec![],
1861 whole_object_uses: vec![],
1862 has_cjs_exports: false,
1863 },
1864 ResolvedModule {
1865 file_id: FileId(2),
1866 path: PathBuf::from("/project/source.ts"),
1867 exports: vec![fallow_types::extract::ExportInfo {
1868 name: ExportName::Named("foo".to_string()),
1869 local_name: Some("foo".to_string()),
1870 is_type_only: false,
1871 span: oxc_span::Span::new(0, 20),
1872 members: vec![],
1873 }],
1874 re_exports: vec![],
1875 resolved_imports: vec![],
1876 resolved_dynamic_imports: vec![],
1877 resolved_dynamic_patterns: vec![],
1878 member_accesses: vec![],
1879 whole_object_uses: vec![],
1880 has_cjs_exports: false,
1881 },
1882 ];
1883
1884 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1885
1886 let barrel = &graph.modules[1];
1887 let foo_export = barrel.exports.iter().find(|e| e.name.to_string() == "foo");
1888 assert!(
1889 foo_export.is_some(),
1890 "barrel should have ExportSymbol for re-exported 'foo'"
1891 );
1892
1893 let foo = foo_export.unwrap();
1894 assert!(
1895 !foo.references.is_empty(),
1896 "barrel's foo should have a reference from entry.ts"
1897 );
1898
1899 let source = &graph.modules[2];
1900 let source_foo = source
1901 .exports
1902 .iter()
1903 .find(|e| e.name.to_string() == "foo")
1904 .unwrap();
1905 assert!(
1906 !source_foo.references.is_empty(),
1907 "source foo should have propagated references through barrel"
1908 );
1909 }
1910
1911 #[test]
1912 fn barrel_unused_re_export_has_no_references() {
1913 let files = vec![
1914 DiscoveredFile {
1915 id: FileId(0),
1916 path: PathBuf::from("/project/entry.ts"),
1917 size_bytes: 100,
1918 },
1919 DiscoveredFile {
1920 id: FileId(1),
1921 path: PathBuf::from("/project/barrel.ts"),
1922 size_bytes: 50,
1923 },
1924 DiscoveredFile {
1925 id: FileId(2),
1926 path: PathBuf::from("/project/source.ts"),
1927 size_bytes: 50,
1928 },
1929 ];
1930
1931 let entry_points = vec![EntryPoint {
1932 path: PathBuf::from("/project/entry.ts"),
1933 source: EntryPointSource::PackageJsonMain,
1934 }];
1935
1936 let resolved_modules = vec![
1937 ResolvedModule {
1938 file_id: FileId(0),
1939 path: PathBuf::from("/project/entry.ts"),
1940 exports: vec![],
1941 re_exports: vec![],
1942 resolved_imports: vec![ResolvedImport {
1943 info: ImportInfo {
1944 source: "./barrel".to_string(),
1945 imported_name: ImportedName::Named("foo".to_string()),
1946 local_name: "foo".to_string(),
1947 is_type_only: false,
1948 span: oxc_span::Span::new(0, 10),
1949 },
1950 target: ResolveResult::InternalModule(FileId(1)),
1951 }],
1952 resolved_dynamic_imports: vec![],
1953 resolved_dynamic_patterns: vec![],
1954 member_accesses: vec![],
1955 whole_object_uses: vec![],
1956 has_cjs_exports: false,
1957 },
1958 ResolvedModule {
1959 file_id: FileId(1),
1960 path: PathBuf::from("/project/barrel.ts"),
1961 exports: vec![],
1962 re_exports: vec![
1963 ResolvedReExport {
1964 info: fallow_types::extract::ReExportInfo {
1965 source: "./source".to_string(),
1966 imported_name: "foo".to_string(),
1967 exported_name: "foo".to_string(),
1968 is_type_only: false,
1969 },
1970 target: ResolveResult::InternalModule(FileId(2)),
1971 },
1972 ResolvedReExport {
1973 info: fallow_types::extract::ReExportInfo {
1974 source: "./source".to_string(),
1975 imported_name: "bar".to_string(),
1976 exported_name: "bar".to_string(),
1977 is_type_only: false,
1978 },
1979 target: ResolveResult::InternalModule(FileId(2)),
1980 },
1981 ],
1982 resolved_imports: vec![],
1983 resolved_dynamic_imports: vec![],
1984 resolved_dynamic_patterns: vec![],
1985 member_accesses: vec![],
1986 whole_object_uses: vec![],
1987 has_cjs_exports: false,
1988 },
1989 ResolvedModule {
1990 file_id: FileId(2),
1991 path: PathBuf::from("/project/source.ts"),
1992 exports: vec![
1993 fallow_types::extract::ExportInfo {
1994 name: ExportName::Named("foo".to_string()),
1995 local_name: Some("foo".to_string()),
1996 is_type_only: false,
1997 span: oxc_span::Span::new(0, 20),
1998 members: vec![],
1999 },
2000 fallow_types::extract::ExportInfo {
2001 name: ExportName::Named("bar".to_string()),
2002 local_name: Some("bar".to_string()),
2003 is_type_only: false,
2004 span: oxc_span::Span::new(25, 45),
2005 members: vec![],
2006 },
2007 ],
2008 re_exports: vec![],
2009 resolved_imports: vec![],
2010 resolved_dynamic_imports: vec![],
2011 resolved_dynamic_patterns: vec![],
2012 member_accesses: vec![],
2013 whole_object_uses: vec![],
2014 has_cjs_exports: false,
2015 },
2016 ];
2017
2018 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2019
2020 let barrel = &graph.modules[1];
2021 let foo = barrel
2022 .exports
2023 .iter()
2024 .find(|e| e.name.to_string() == "foo")
2025 .unwrap();
2026 assert!(!foo.references.is_empty(), "barrel's foo should be used");
2027
2028 let bar = barrel
2029 .exports
2030 .iter()
2031 .find(|e| e.name.to_string() == "bar")
2032 .unwrap();
2033 assert!(
2034 bar.references.is_empty(),
2035 "barrel's bar should be unused (no consumer imports it)"
2036 );
2037 }
2038
2039 #[test]
2040 fn type_only_re_export_creates_type_only_export_symbol() {
2041 let files = vec![
2042 DiscoveredFile {
2043 id: FileId(0),
2044 path: PathBuf::from("/project/entry.ts"),
2045 size_bytes: 100,
2046 },
2047 DiscoveredFile {
2048 id: FileId(1),
2049 path: PathBuf::from("/project/barrel.ts"),
2050 size_bytes: 50,
2051 },
2052 DiscoveredFile {
2053 id: FileId(2),
2054 path: PathBuf::from("/project/source.ts"),
2055 size_bytes: 50,
2056 },
2057 ];
2058
2059 let entry_points = vec![EntryPoint {
2060 path: PathBuf::from("/project/entry.ts"),
2061 source: EntryPointSource::PackageJsonMain,
2062 }];
2063
2064 let resolved_modules = vec![
2065 ResolvedModule {
2066 file_id: FileId(0),
2067 path: PathBuf::from("/project/entry.ts"),
2068 exports: vec![],
2069 re_exports: vec![],
2070 resolved_imports: vec![ResolvedImport {
2071 info: ImportInfo {
2072 source: "./barrel".to_string(),
2073 imported_name: ImportedName::Named("UsedType".to_string()),
2074 local_name: "UsedType".to_string(),
2075 is_type_only: true,
2076 span: oxc_span::Span::new(0, 10),
2077 },
2078 target: ResolveResult::InternalModule(FileId(1)),
2079 }],
2080 resolved_dynamic_imports: vec![],
2081 resolved_dynamic_patterns: vec![],
2082 member_accesses: vec![],
2083 whole_object_uses: vec![],
2084 has_cjs_exports: false,
2085 },
2086 ResolvedModule {
2087 file_id: FileId(1),
2088 path: PathBuf::from("/project/barrel.ts"),
2089 exports: vec![],
2090 re_exports: vec![
2091 ResolvedReExport {
2092 info: fallow_types::extract::ReExportInfo {
2093 source: "./source".to_string(),
2094 imported_name: "UsedType".to_string(),
2095 exported_name: "UsedType".to_string(),
2096 is_type_only: true,
2097 },
2098 target: ResolveResult::InternalModule(FileId(2)),
2099 },
2100 ResolvedReExport {
2101 info: fallow_types::extract::ReExportInfo {
2102 source: "./source".to_string(),
2103 imported_name: "UnusedType".to_string(),
2104 exported_name: "UnusedType".to_string(),
2105 is_type_only: true,
2106 },
2107 target: ResolveResult::InternalModule(FileId(2)),
2108 },
2109 ],
2110 resolved_imports: vec![],
2111 resolved_dynamic_imports: vec![],
2112 resolved_dynamic_patterns: vec![],
2113 member_accesses: vec![],
2114 whole_object_uses: vec![],
2115 has_cjs_exports: false,
2116 },
2117 ResolvedModule {
2118 file_id: FileId(2),
2119 path: PathBuf::from("/project/source.ts"),
2120 exports: vec![
2121 fallow_types::extract::ExportInfo {
2122 name: ExportName::Named("UsedType".to_string()),
2123 local_name: Some("UsedType".to_string()),
2124 is_type_only: true,
2125 span: oxc_span::Span::new(0, 20),
2126 members: vec![],
2127 },
2128 fallow_types::extract::ExportInfo {
2129 name: ExportName::Named("UnusedType".to_string()),
2130 local_name: Some("UnusedType".to_string()),
2131 is_type_only: true,
2132 span: oxc_span::Span::new(25, 45),
2133 members: vec![],
2134 },
2135 ],
2136 re_exports: vec![],
2137 resolved_imports: vec![],
2138 resolved_dynamic_imports: vec![],
2139 resolved_dynamic_patterns: vec![],
2140 member_accesses: vec![],
2141 whole_object_uses: vec![],
2142 has_cjs_exports: false,
2143 },
2144 ];
2145
2146 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2147
2148 let barrel = &graph.modules[1];
2149
2150 let used_type = barrel
2151 .exports
2152 .iter()
2153 .find(|e| e.name.to_string() == "UsedType")
2154 .expect("barrel should have ExportSymbol for UsedType");
2155 assert!(used_type.is_type_only, "UsedType should be type-only");
2156 assert!(
2157 !used_type.references.is_empty(),
2158 "UsedType should have references"
2159 );
2160
2161 let unused_type = barrel
2162 .exports
2163 .iter()
2164 .find(|e| e.name.to_string() == "UnusedType")
2165 .expect("barrel should have ExportSymbol for UnusedType");
2166 assert!(unused_type.is_type_only, "UnusedType should be type-only");
2167 assert!(
2168 unused_type.references.is_empty(),
2169 "UnusedType should have no references"
2170 );
2171 }
2172
2173 #[test]
2174 fn default_re_export_creates_default_export_symbol() {
2175 let files = vec![
2176 DiscoveredFile {
2177 id: FileId(0),
2178 path: PathBuf::from("/project/entry.ts"),
2179 size_bytes: 100,
2180 },
2181 DiscoveredFile {
2182 id: FileId(1),
2183 path: PathBuf::from("/project/barrel.ts"),
2184 size_bytes: 50,
2185 },
2186 DiscoveredFile {
2187 id: FileId(2),
2188 path: PathBuf::from("/project/source.ts"),
2189 size_bytes: 50,
2190 },
2191 ];
2192
2193 let entry_points = vec![EntryPoint {
2194 path: PathBuf::from("/project/entry.ts"),
2195 source: EntryPointSource::PackageJsonMain,
2196 }];
2197
2198 let resolved_modules = vec![
2199 ResolvedModule {
2200 file_id: FileId(0),
2201 path: PathBuf::from("/project/entry.ts"),
2202 exports: vec![],
2203 re_exports: vec![],
2204 resolved_imports: vec![ResolvedImport {
2205 info: ImportInfo {
2206 source: "./barrel".to_string(),
2207 imported_name: ImportedName::Named("Accordion".to_string()),
2208 local_name: "Accordion".to_string(),
2209 is_type_only: false,
2210 span: oxc_span::Span::new(0, 10),
2211 },
2212 target: ResolveResult::InternalModule(FileId(1)),
2213 }],
2214 resolved_dynamic_imports: vec![],
2215 resolved_dynamic_patterns: vec![],
2216 member_accesses: vec![],
2217 whole_object_uses: vec![],
2218 has_cjs_exports: false,
2219 },
2220 ResolvedModule {
2221 file_id: FileId(1),
2222 path: PathBuf::from("/project/barrel.ts"),
2223 exports: vec![],
2224 re_exports: vec![ResolvedReExport {
2225 info: fallow_types::extract::ReExportInfo {
2226 source: "./source".to_string(),
2227 imported_name: "default".to_string(),
2228 exported_name: "Accordion".to_string(),
2229 is_type_only: false,
2230 },
2231 target: ResolveResult::InternalModule(FileId(2)),
2232 }],
2233 resolved_imports: vec![],
2234 resolved_dynamic_imports: vec![],
2235 resolved_dynamic_patterns: vec![],
2236 member_accesses: vec![],
2237 whole_object_uses: vec![],
2238 has_cjs_exports: false,
2239 },
2240 ResolvedModule {
2241 file_id: FileId(2),
2242 path: PathBuf::from("/project/source.ts"),
2243 exports: vec![fallow_types::extract::ExportInfo {
2244 name: ExportName::Default,
2245 local_name: None,
2246 is_type_only: false,
2247 span: oxc_span::Span::new(0, 20),
2248 members: vec![],
2249 }],
2250 re_exports: vec![],
2251 resolved_imports: vec![],
2252 resolved_dynamic_imports: vec![],
2253 resolved_dynamic_patterns: vec![],
2254 member_accesses: vec![],
2255 whole_object_uses: vec![],
2256 has_cjs_exports: false,
2257 },
2258 ];
2259
2260 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2261
2262 let barrel = &graph.modules[1];
2263 let accordion = barrel
2264 .exports
2265 .iter()
2266 .find(|e| e.name.to_string() == "Accordion")
2267 .expect("barrel should have ExportSymbol for Accordion");
2268 assert!(
2269 !accordion.references.is_empty(),
2270 "Accordion should have reference from entry.ts"
2271 );
2272
2273 let source = &graph.modules[2];
2274 let default_export = source
2275 .exports
2276 .iter()
2277 .find(|e| matches!(e.name, ExportName::Default))
2278 .unwrap();
2279 assert!(
2280 !default_export.references.is_empty(),
2281 "source default export should have propagated references"
2282 );
2283 }
2284
2285 #[test]
2286 fn multi_level_re_export_chain_propagation() {
2287 let files = vec![
2288 DiscoveredFile {
2289 id: FileId(0),
2290 path: PathBuf::from("/project/entry.ts"),
2291 size_bytes: 100,
2292 },
2293 DiscoveredFile {
2294 id: FileId(1),
2295 path: PathBuf::from("/project/barrel1.ts"),
2296 size_bytes: 50,
2297 },
2298 DiscoveredFile {
2299 id: FileId(2),
2300 path: PathBuf::from("/project/barrel2.ts"),
2301 size_bytes: 50,
2302 },
2303 DiscoveredFile {
2304 id: FileId(3),
2305 path: PathBuf::from("/project/source.ts"),
2306 size_bytes: 50,
2307 },
2308 ];
2309
2310 let entry_points = vec![EntryPoint {
2311 path: PathBuf::from("/project/entry.ts"),
2312 source: EntryPointSource::PackageJsonMain,
2313 }];
2314
2315 let resolved_modules = vec![
2316 ResolvedModule {
2317 file_id: FileId(0),
2318 path: PathBuf::from("/project/entry.ts"),
2319 exports: vec![],
2320 re_exports: vec![],
2321 resolved_imports: vec![ResolvedImport {
2322 info: ImportInfo {
2323 source: "./barrel1".to_string(),
2324 imported_name: ImportedName::Named("foo".to_string()),
2325 local_name: "foo".to_string(),
2326 is_type_only: false,
2327 span: oxc_span::Span::new(0, 10),
2328 },
2329 target: ResolveResult::InternalModule(FileId(1)),
2330 }],
2331 resolved_dynamic_imports: vec![],
2332 resolved_dynamic_patterns: vec![],
2333 member_accesses: vec![],
2334 whole_object_uses: vec![],
2335 has_cjs_exports: false,
2336 },
2337 ResolvedModule {
2338 file_id: FileId(1),
2339 path: PathBuf::from("/project/barrel1.ts"),
2340 exports: vec![],
2341 re_exports: vec![ResolvedReExport {
2342 info: fallow_types::extract::ReExportInfo {
2343 source: "./barrel2".to_string(),
2344 imported_name: "foo".to_string(),
2345 exported_name: "foo".to_string(),
2346 is_type_only: false,
2347 },
2348 target: ResolveResult::InternalModule(FileId(2)),
2349 }],
2350 resolved_imports: vec![],
2351 resolved_dynamic_imports: vec![],
2352 resolved_dynamic_patterns: vec![],
2353 member_accesses: vec![],
2354 whole_object_uses: vec![],
2355 has_cjs_exports: false,
2356 },
2357 ResolvedModule {
2358 file_id: FileId(2),
2359 path: PathBuf::from("/project/barrel2.ts"),
2360 exports: vec![],
2361 re_exports: vec![ResolvedReExport {
2362 info: fallow_types::extract::ReExportInfo {
2363 source: "./source".to_string(),
2364 imported_name: "foo".to_string(),
2365 exported_name: "foo".to_string(),
2366 is_type_only: false,
2367 },
2368 target: ResolveResult::InternalModule(FileId(3)),
2369 }],
2370 resolved_imports: vec![],
2371 resolved_dynamic_imports: vec![],
2372 resolved_dynamic_patterns: vec![],
2373 member_accesses: vec![],
2374 whole_object_uses: vec![],
2375 has_cjs_exports: false,
2376 },
2377 ResolvedModule {
2378 file_id: FileId(3),
2379 path: PathBuf::from("/project/source.ts"),
2380 exports: vec![fallow_types::extract::ExportInfo {
2381 name: ExportName::Named("foo".to_string()),
2382 local_name: Some("foo".to_string()),
2383 is_type_only: false,
2384 span: oxc_span::Span::new(0, 20),
2385 members: vec![],
2386 }],
2387 re_exports: vec![],
2388 resolved_imports: vec![],
2389 resolved_dynamic_imports: vec![],
2390 resolved_dynamic_patterns: vec![],
2391 member_accesses: vec![],
2392 whole_object_uses: vec![],
2393 has_cjs_exports: false,
2394 },
2395 ];
2396
2397 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2398
2399 let barrel1 = &graph.modules[1];
2400 let b1_foo = barrel1
2401 .exports
2402 .iter()
2403 .find(|e| e.name.to_string() == "foo")
2404 .unwrap();
2405 assert!(
2406 !b1_foo.references.is_empty(),
2407 "barrel1's foo should be referenced"
2408 );
2409
2410 let barrel2 = &graph.modules[2];
2411 let b2_foo = barrel2
2412 .exports
2413 .iter()
2414 .find(|e| e.name.to_string() == "foo")
2415 .unwrap();
2416 assert!(
2417 !b2_foo.references.is_empty(),
2418 "barrel2's foo should be referenced (propagated through chain)"
2419 );
2420
2421 let source = &graph.modules[3];
2422 let src_foo = source
2423 .exports
2424 .iter()
2425 .find(|e| e.name.to_string() == "foo")
2426 .unwrap();
2427 assert!(
2428 !src_foo.references.is_empty(),
2429 "source's foo should be referenced (propagated through 2-level chain)"
2430 );
2431 }
2432
2433 fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
2437 let files: Vec<DiscoveredFile> = (0..file_count)
2438 .map(|i| DiscoveredFile {
2439 id: FileId(i as u32),
2440 path: PathBuf::from(format!("/project/file{i}.ts")),
2441 size_bytes: 100,
2442 })
2443 .collect();
2444
2445 let resolved_modules: Vec<ResolvedModule> = (0..file_count)
2446 .map(|i| {
2447 let imports: Vec<ResolvedImport> = edges_spec
2448 .iter()
2449 .filter(|(src, _)| *src == i as u32)
2450 .map(|(_, tgt)| ResolvedImport {
2451 info: ImportInfo {
2452 source: format!("./file{tgt}"),
2453 imported_name: ImportedName::Named("x".to_string()),
2454 local_name: "x".to_string(),
2455 is_type_only: false,
2456 span: oxc_span::Span::new(0, 10),
2457 },
2458 target: ResolveResult::InternalModule(FileId(*tgt)),
2459 })
2460 .collect();
2461
2462 ResolvedModule {
2463 file_id: FileId(i as u32),
2464 path: PathBuf::from(format!("/project/file{i}.ts")),
2465 exports: vec![fallow_types::extract::ExportInfo {
2466 name: ExportName::Named("x".to_string()),
2467 local_name: Some("x".to_string()),
2468 is_type_only: false,
2469 span: oxc_span::Span::new(0, 20),
2470 members: vec![],
2471 }],
2472 re_exports: vec![],
2473 resolved_imports: imports,
2474 resolved_dynamic_imports: vec![],
2475 resolved_dynamic_patterns: vec![],
2476 member_accesses: vec![],
2477 whole_object_uses: vec![],
2478 has_cjs_exports: false,
2479 }
2480 })
2481 .collect();
2482
2483 let entry_points = vec![EntryPoint {
2484 path: PathBuf::from("/project/file0.ts"),
2485 source: EntryPointSource::PackageJsonMain,
2486 }];
2487
2488 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2489 }
2490
2491 #[test]
2492 fn find_cycles_empty_graph() {
2493 let graph = ModuleGraph::build(&[], &[], &[]);
2494 assert!(graph.find_cycles().is_empty());
2495 }
2496
2497 #[test]
2498 fn find_cycles_no_cycles() {
2499 let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
2501 assert!(graph.find_cycles().is_empty());
2502 }
2503
2504 #[test]
2505 fn find_cycles_simple_two_node_cycle() {
2506 let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
2508 let cycles = graph.find_cycles();
2509 assert_eq!(cycles.len(), 1);
2510 assert_eq!(cycles[0].len(), 2);
2511 }
2512
2513 #[test]
2514 fn find_cycles_three_node_cycle() {
2515 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2517 let cycles = graph.find_cycles();
2518 assert_eq!(cycles.len(), 1);
2519 assert_eq!(cycles[0].len(), 3);
2520 }
2521
2522 #[test]
2523 fn find_cycles_self_import_ignored() {
2524 let graph = build_cycle_graph(1, &[(0, 0)]);
2526 let cycles = graph.find_cycles();
2527 assert!(
2528 cycles.is_empty(),
2529 "self-imports should not be reported as cycles"
2530 );
2531 }
2532
2533 #[test]
2534 fn find_cycles_multiple_independent_cycles() {
2535 let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
2539 let cycles = graph.find_cycles();
2540 assert_eq!(cycles.len(), 2);
2541 assert!(cycles.iter().all(|c| c.len() == 2));
2543 }
2544
2545 #[test]
2546 fn find_cycles_linear_chain_with_back_edge() {
2547 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
2549 let cycles = graph.find_cycles();
2550 assert_eq!(cycles.len(), 1);
2551 assert_eq!(cycles[0].len(), 3);
2552 let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
2554 assert!(ids.contains(&1));
2555 assert!(ids.contains(&2));
2556 assert!(ids.contains(&3));
2557 assert!(!ids.contains(&0));
2558 }
2559
2560 #[test]
2561 fn find_cycles_overlapping_cycles_enumerated() {
2562 let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
2564 let cycles = graph.find_cycles();
2565 assert_eq!(
2566 cycles.len(),
2567 2,
2568 "should find 2 elementary cycles, not 1 SCC"
2569 );
2570 assert!(
2571 cycles.iter().all(|c| c.len() == 2),
2572 "both cycles should have length 2"
2573 );
2574 }
2575
2576 #[test]
2577 fn find_cycles_deterministic_ordering() {
2578 let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2580 let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2581 let cycles1 = graph1.find_cycles();
2582 let cycles2 = graph2.find_cycles();
2583 assert_eq!(cycles1.len(), cycles2.len());
2584 for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
2585 let paths1: Vec<&PathBuf> = c1
2586 .iter()
2587 .map(|f| &graph1.modules[f.0 as usize].path)
2588 .collect();
2589 let paths2: Vec<&PathBuf> = c2
2590 .iter()
2591 .map(|f| &graph2.modules[f.0 as usize].path)
2592 .collect();
2593 assert_eq!(paths1, paths2);
2594 }
2595 }
2596
2597 #[test]
2598 fn find_cycles_sorted_by_length() {
2599 let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
2601 let cycles = graph.find_cycles();
2602 assert_eq!(cycles.len(), 2);
2603 assert!(
2604 cycles[0].len() <= cycles[1].len(),
2605 "cycles should be sorted by length"
2606 );
2607 }
2608
2609 #[test]
2610 fn find_cycles_large_cycle() {
2611 let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
2613 let graph = build_cycle_graph(10, &edges);
2614 let cycles = graph.find_cycles();
2615 assert_eq!(cycles.len(), 1);
2616 assert_eq!(cycles[0].len(), 10);
2617 }
2618
2619 #[test]
2620 fn find_cycles_complex_scc_multiple_elementary() {
2621 let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
2624 let cycles = graph.find_cycles();
2625 assert!(
2627 cycles.len() >= 2,
2628 "should find at least 2 elementary cycles, got {}",
2629 cycles.len()
2630 );
2631 assert!(cycles.iter().all(|c| c.len() <= 4));
2633 }
2634
2635 #[test]
2636 fn find_cycles_no_duplicate_cycles() {
2637 let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2640 let cycles = graph.find_cycles();
2641 assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
2642 assert_eq!(cycles[0].len(), 3);
2643 }
2644}