1mod build;
7mod cycles;
8mod fan_io;
9mod impact_closure;
10mod namespace_aliases;
11mod namespace_re_exports;
12mod narrowing;
13mod partition_order;
14mod public_exports;
15mod re_export_reachability;
16mod re_exports;
17mod reachability;
18pub mod types;
19
20use std::path::Path;
21
22use fixedbitset::FixedBitSet;
23use rustc_hash::{FxHashMap, FxHashSet};
24
25use crate::resolve::ResolvedModule;
26use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
27use fallow_types::extract::ImportedName;
28
29pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
30pub use impact_closure::{
31 CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
32};
33pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
34pub use re_exports::GraphReExportCycle;
35pub use types::{ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, SymbolReference};
36
37fn is_declaration_file_path(path: &Path) -> bool {
44 path.file_name()
45 .and_then(|n| n.to_str())
46 .is_some_and(|name| {
47 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
48 })
49}
50
51#[derive(Debug, serde::Serialize, serde::Deserialize)]
59pub struct ModuleGraph {
60 pub modules: Vec<ModuleNode>,
70 edges: Vec<Edge>,
72 pub package_usage: FxHashMap<String, Vec<FileId>>,
74 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
78 pub entry_points: FxHashSet<FileId>,
80 pub runtime_entry_points: FxHashSet<FileId>,
82 pub test_entry_points: FxHashSet<FileId>,
84 pub reverse_deps: Vec<Vec<FileId>>,
86 #[serde(skip, default)]
94 namespace_imported: FixedBitSet,
95 pub re_export_cycles: Vec<GraphReExportCycle>,
102}
103
104#[derive(Debug, serde::Serialize, serde::Deserialize)]
112pub struct Edge {
113 pub source: FileId,
115 pub target: FileId,
117 pub symbols: Vec<ImportedSymbol>,
119}
120
121#[derive(Debug, serde::Serialize, serde::Deserialize)]
123pub struct ImportedSymbol {
124 pub imported_name: ImportedName,
127 pub local_name: String,
129 #[serde(with = "crate::cache::span_serde")]
131 pub import_span: oxc_span::Span,
132 pub is_type_only: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DirectImporterSummary {
140 pub source: FileId,
142 pub symbols: Vec<ImportedSymbolSummary>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ImportedSymbolSummary {
149 pub imported: String,
152 pub local: String,
154 pub type_only: bool,
156}
157
158#[cfg(target_pointer_width = "64")]
159const _: () = assert!(std::mem::size_of::<Edge>() == 32);
160#[cfg(target_pointer_width = "64")]
161const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
162
163impl ModuleGraph {
164 fn resolve_entry_point_ids(
165 entry_points: &[EntryPoint],
166 path_to_id: &FxHashMap<&Path, FileId>,
167 ) -> FxHashSet<FileId> {
168 entry_points
169 .iter()
170 .filter_map(|ep| {
171 path_to_id.get(ep.path.as_path()).copied().or_else(|| {
172 dunce::canonicalize(&ep.path)
173 .ok()
174 .and_then(|path| path_to_id.get(path.as_path()).copied())
175 })
176 })
177 .collect()
178 }
179
180 pub fn build(
182 resolved_modules: &[ResolvedModule],
183 entry_points: &[EntryPoint],
184 files: &[DiscoveredFile],
185 ) -> Self {
186 Self::build_with_reachability_roots(
187 resolved_modules,
188 entry_points,
189 entry_points,
190 &[],
191 files,
192 )
193 }
194
195 pub fn build_with_reachability_roots(
197 resolved_modules: &[ResolvedModule],
198 entry_points: &[EntryPoint],
199 runtime_entry_points: &[EntryPoint],
200 test_entry_points: &[EntryPoint],
201 files: &[DiscoveredFile],
202 ) -> Self {
203 let _span = tracing::info_span!("build_graph").entered();
204
205 let module_count = files.len();
206
207 let max_file_id = files
208 .iter()
209 .map(|f| f.id.0 as usize)
210 .max()
211 .map_or(0, |m| m + 1);
212 let total_capacity = max_file_id.max(module_count);
213
214 let path_to_id: FxHashMap<&Path, FileId> =
215 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
216
217 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
218 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
219
220 let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
221 let runtime_entry_point_ids =
222 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
223 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
224
225 for file in files {
226 if is_declaration_file_path(&file.path) {
227 entry_point_ids.insert(file.id);
228 }
229 }
230
231 let mut graph = Self::populate_edges(&build::PopulateEdgesInput {
232 files,
233 module_by_id: &module_by_id,
234 entry_point_ids: &entry_point_ids,
235 runtime_entry_point_ids: &runtime_entry_point_ids,
236 test_entry_point_ids: &test_entry_point_ids,
237 module_count,
238 total_capacity,
239 });
240
241 graph.populate_references(&module_by_id, &entry_point_ids);
242
243 namespace_aliases::propagate_cross_package_aliases(&mut graph, &module_by_id);
244
245 namespace_re_exports::propagate_namespace_re_exports(&mut graph, &module_by_id);
246
247 graph.mark_reachable(
248 &entry_point_ids,
249 &runtime_entry_point_ids,
250 &test_entry_point_ids,
251 total_capacity,
252 );
253
254 graph.re_export_cycles = graph.resolve_re_export_chains(&module_by_id);
255
256 graph
257 }
258
259 #[must_use]
261 pub const fn module_count(&self) -> usize {
262 self.modules.len()
263 }
264
265 #[must_use]
267 pub const fn edge_count(&self) -> usize {
268 self.edges.len()
269 }
270
271 pub(crate) fn reconstruct_namespace_imported(&mut self) {
286 let capacity = self
287 .edges
288 .iter()
289 .map(|edge| edge.target.0 as usize + 1)
290 .max()
291 .unwrap_or(0)
292 .max(self.modules.len());
293 let mut bitset = FixedBitSet::with_capacity(capacity);
294 for edge in &self.edges {
295 if edge
296 .symbols
297 .iter()
298 .any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
299 {
300 let idx = edge.target.0 as usize;
301 if idx < capacity {
302 bitset.insert(idx);
303 }
304 }
305 }
306 self.namespace_imported = bitset;
307 }
308
309 #[must_use]
312 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
313 let idx = file_id.0 as usize;
314 if idx >= self.namespace_imported.len() {
315 return false;
316 }
317 self.namespace_imported.contains(idx)
318 }
319
320 #[must_use]
322 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
323 let idx = file_id.0 as usize;
324 if idx >= self.modules.len() {
325 return Vec::new();
326 }
327 let range = &self.modules[idx].edge_range;
328 self.edges[range.clone()].iter().map(|e| e.target).collect()
329 }
330
331 pub fn outgoing_symbol_edges(
337 &self,
338 file_id: FileId,
339 ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
340 let idx = file_id.0 as usize;
341 let range = if idx < self.modules.len() {
342 self.modules[idx].edge_range.clone()
343 } else {
344 0..0
345 };
346 self.edges[range]
347 .iter()
348 .map(|edge| (edge.target, edge.symbols.as_slice()))
349 }
350
351 #[must_use]
355 pub fn importers_of(&self, target: FileId) -> &[FileId] {
356 self.reverse_deps
357 .get(target.0 as usize)
358 .map_or(&[], Vec::as_slice)
359 }
360
361 #[must_use]
366 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
367 let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
368 return Vec::new();
369 };
370
371 let mut summaries = Vec::new();
372 for &source in importers {
373 let idx = source.0 as usize;
374 let Some(source_node) = self.modules.get(idx) else {
375 continue;
376 };
377 let mut symbols = Vec::new();
378 for edge in &self.edges[source_node.edge_range.clone()] {
379 if edge.target != target {
380 continue;
381 }
382 symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
383 imported: imported_name_label(&symbol.imported_name),
384 local: symbol.local_name.clone(),
385 type_only: symbol.is_type_only,
386 }));
387 }
388 symbols.sort_by(|a, b| {
389 a.imported
390 .cmp(&b.imported)
391 .then_with(|| a.local.cmp(&b.local))
392 .then_with(|| a.type_only.cmp(&b.type_only))
393 });
394 symbols.dedup();
395 summaries.push(DirectImporterSummary { source, symbols });
396 }
397 summaries.sort_by_key(|summary| summary.source.0);
398 summaries
399 }
400
401 #[must_use]
408 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
409 let idx = source.0 as usize;
410 if idx >= self.modules.len() {
411 return None;
412 }
413 let range = &self.modules[idx].edge_range;
414 for edge in &self.edges[range.clone()] {
415 if edge.target == target {
416 return edge
417 .symbols
418 .iter()
419 .find(|s| !s.is_type_only)
420 .or_else(|| edge.symbols.first())
421 .map(|s| s.import_span.start);
422 }
423 }
424 None
425 }
426
427 pub fn outgoing_edge_summaries(
442 &self,
443 file_id: FileId,
444 ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
445 let idx = file_id.0 as usize;
446 let range = if idx < self.modules.len() {
447 self.modules[idx].edge_range.clone()
448 } else {
449 0..0
450 };
451 self.edges[range].iter().map(|edge| {
452 let all_type_only =
453 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
454 let span = edge
455 .symbols
456 .iter()
457 .find(|s| !s.is_type_only)
458 .or_else(|| edge.symbols.first())
459 .map(|s| s.import_span.start);
460 (edge.target, all_type_only, span)
461 })
462 }
463
464 pub fn outgoing_edge_summaries_with_exclusions<'a>(
475 &'a self,
476 file_id: FileId,
477 excluded_span_starts: &'a FxHashSet<u32>,
478 ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
479 let idx = file_id.0 as usize;
480 let range = if idx < self.modules.len() {
481 self.modules[idx].edge_range.clone()
482 } else {
483 0..0
484 };
485 self.edges[range].iter().map(move |edge| {
486 let all_type_only =
487 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
488 let span = edge
489 .symbols
490 .iter()
491 .find(|s| !s.is_type_only)
492 .or_else(|| edge.symbols.first())
493 .map(|s| s.import_span.start);
494 let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
498 let all_client_only = value_symbols.peek().is_some()
499 && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
500 (edge.target, all_type_only, span, all_client_only)
501 })
502 }
503}
504
505fn imported_name_label(name: &ImportedName) -> String {
506 match name {
507 ImportedName::Named(name) => name.clone(),
508 ImportedName::Default => "default".to_string(),
509 ImportedName::Namespace => "*".to_string(),
510 ImportedName::SideEffect => "side-effect".to_string(),
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
518 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
519 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
520 use std::path::PathBuf;
521
522 fn build_simple_graph() -> ModuleGraph {
523 let files = vec![
524 DiscoveredFile {
525 id: FileId(0),
526 path: PathBuf::from("/project/src/entry.ts"),
527 size_bytes: 100,
528 },
529 DiscoveredFile {
530 id: FileId(1),
531 path: PathBuf::from("/project/src/utils.ts"),
532 size_bytes: 50,
533 },
534 ];
535
536 let entry_points = vec![EntryPoint {
537 path: PathBuf::from("/project/src/entry.ts"),
538 source: EntryPointSource::PackageJsonMain,
539 }];
540
541 let resolved_modules = vec![
542 ResolvedModule {
543 file_id: FileId(0),
544 path: PathBuf::from("/project/src/entry.ts"),
545 resolved_imports: vec![ResolvedImport {
546 info: ImportInfo {
547 source: "./utils".to_string(),
548 imported_name: ImportedName::Named("foo".to_string()),
549 local_name: "foo".to_string(),
550 is_type_only: false,
551 from_style: false,
552 span: oxc_span::Span::new(0, 10),
553 source_span: oxc_span::Span::default(),
554 },
555 target: ResolveResult::InternalModule(FileId(1)),
556 }],
557 ..Default::default()
558 },
559 ResolvedModule {
560 file_id: FileId(1),
561 path: PathBuf::from("/project/src/utils.ts"),
562 exports: vec![
563 fallow_types::extract::ExportInfo {
564 name: ExportName::Named("foo".to_string()),
565 local_name: Some("foo".to_string()),
566 is_type_only: false,
567 visibility: VisibilityTag::None,
568 expected_unused_reason: None,
569 span: oxc_span::Span::new(0, 20),
570 members: vec![],
571 is_side_effect_used: false,
572 super_class: None,
573 },
574 fallow_types::extract::ExportInfo {
575 name: ExportName::Named("bar".to_string()),
576 local_name: Some("bar".to_string()),
577 is_type_only: false,
578 visibility: VisibilityTag::None,
579 expected_unused_reason: None,
580 span: oxc_span::Span::new(25, 45),
581 members: vec![],
582 is_side_effect_used: false,
583 super_class: None,
584 },
585 ],
586 ..Default::default()
587 },
588 ];
589
590 ModuleGraph::build(&resolved_modules, &entry_points, &files)
591 }
592
593 #[test]
594 fn graph_module_count() {
595 let graph = build_simple_graph();
596 assert_eq!(graph.module_count(), 2);
597 }
598
599 #[test]
600 fn graph_edge_count() {
601 let graph = build_simple_graph();
602 assert_eq!(graph.edge_count(), 1);
603 }
604
605 #[test]
606 fn graph_entry_point_is_reachable() {
607 let graph = build_simple_graph();
608 assert!(graph.modules[0].is_entry_point());
609 assert!(graph.modules[0].is_reachable());
610 }
611
612 #[test]
613 fn graph_imported_module_is_reachable() {
614 let graph = build_simple_graph();
615 assert!(!graph.modules[1].is_entry_point());
616 assert!(graph.modules[1].is_reachable());
617 }
618
619 #[test]
620 #[expect(
621 clippy::too_many_lines,
622 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
623 would obscure the cross-role assertions"
624 )]
625 fn graph_distinguishes_runtime_test_and_support_reachability() {
626 let files = vec![
627 DiscoveredFile {
628 id: FileId(0),
629 path: PathBuf::from("/project/src/main.ts"),
630 size_bytes: 100,
631 },
632 DiscoveredFile {
633 id: FileId(1),
634 path: PathBuf::from("/project/src/runtime-only.ts"),
635 size_bytes: 50,
636 },
637 DiscoveredFile {
638 id: FileId(2),
639 path: PathBuf::from("/project/tests/app.test.ts"),
640 size_bytes: 50,
641 },
642 DiscoveredFile {
643 id: FileId(3),
644 path: PathBuf::from("/project/tests/setup.ts"),
645 size_bytes: 50,
646 },
647 DiscoveredFile {
648 id: FileId(4),
649 path: PathBuf::from("/project/src/covered.ts"),
650 size_bytes: 50,
651 },
652 ];
653
654 let all_entry_points = vec![
655 EntryPoint {
656 path: PathBuf::from("/project/src/main.ts"),
657 source: EntryPointSource::PackageJsonMain,
658 },
659 EntryPoint {
660 path: PathBuf::from("/project/tests/app.test.ts"),
661 source: EntryPointSource::TestFile,
662 },
663 EntryPoint {
664 path: PathBuf::from("/project/tests/setup.ts"),
665 source: EntryPointSource::Plugin {
666 name: "vitest".to_string(),
667 },
668 },
669 ];
670 let runtime_entry_points = vec![EntryPoint {
671 path: PathBuf::from("/project/src/main.ts"),
672 source: EntryPointSource::PackageJsonMain,
673 }];
674 let test_entry_points = vec![EntryPoint {
675 path: PathBuf::from("/project/tests/app.test.ts"),
676 source: EntryPointSource::TestFile,
677 }];
678
679 let resolved_modules = vec![
680 ResolvedModule {
681 file_id: FileId(0),
682 path: PathBuf::from("/project/src/main.ts"),
683 resolved_imports: vec![ResolvedImport {
684 info: ImportInfo {
685 source: "./runtime-only".to_string(),
686 imported_name: ImportedName::Named("runtimeOnly".to_string()),
687 local_name: "runtimeOnly".to_string(),
688 is_type_only: false,
689 from_style: false,
690 span: oxc_span::Span::new(0, 10),
691 source_span: oxc_span::Span::default(),
692 },
693 target: ResolveResult::InternalModule(FileId(1)),
694 }],
695 ..Default::default()
696 },
697 ResolvedModule {
698 file_id: FileId(1),
699 path: PathBuf::from("/project/src/runtime-only.ts"),
700 exports: vec![fallow_types::extract::ExportInfo {
701 name: ExportName::Named("runtimeOnly".to_string()),
702 local_name: Some("runtimeOnly".to_string()),
703 is_type_only: false,
704 visibility: VisibilityTag::None,
705 expected_unused_reason: None,
706 span: oxc_span::Span::new(0, 20),
707 members: vec![],
708 is_side_effect_used: false,
709 super_class: None,
710 }],
711 ..Default::default()
712 },
713 ResolvedModule {
714 file_id: FileId(2),
715 path: PathBuf::from("/project/tests/app.test.ts"),
716 resolved_imports: vec![ResolvedImport {
717 info: ImportInfo {
718 source: "../src/covered".to_string(),
719 imported_name: ImportedName::Named("covered".to_string()),
720 local_name: "covered".to_string(),
721 is_type_only: false,
722 from_style: false,
723 span: oxc_span::Span::new(0, 10),
724 source_span: oxc_span::Span::default(),
725 },
726 target: ResolveResult::InternalModule(FileId(4)),
727 }],
728 ..Default::default()
729 },
730 ResolvedModule {
731 file_id: FileId(3),
732 path: PathBuf::from("/project/tests/setup.ts"),
733 resolved_imports: vec![ResolvedImport {
734 info: ImportInfo {
735 source: "../src/runtime-only".to_string(),
736 imported_name: ImportedName::Named("runtimeOnly".to_string()),
737 local_name: "runtimeOnly".to_string(),
738 is_type_only: false,
739 from_style: false,
740 span: oxc_span::Span::new(0, 10),
741 source_span: oxc_span::Span::default(),
742 },
743 target: ResolveResult::InternalModule(FileId(1)),
744 }],
745 ..Default::default()
746 },
747 ResolvedModule {
748 file_id: FileId(4),
749 path: PathBuf::from("/project/src/covered.ts"),
750 exports: vec![fallow_types::extract::ExportInfo {
751 name: ExportName::Named("covered".to_string()),
752 local_name: Some("covered".to_string()),
753 is_type_only: false,
754 visibility: VisibilityTag::None,
755 expected_unused_reason: None,
756 span: oxc_span::Span::new(0, 20),
757 members: vec![],
758 is_side_effect_used: false,
759 super_class: None,
760 }],
761 ..Default::default()
762 },
763 ];
764
765 let graph = ModuleGraph::build_with_reachability_roots(
766 &resolved_modules,
767 &all_entry_points,
768 &runtime_entry_points,
769 &test_entry_points,
770 &files,
771 );
772
773 assert!(graph.modules[1].is_reachable());
774 assert!(graph.modules[1].is_runtime_reachable());
775 assert!(
776 !graph.modules[1].is_test_reachable(),
777 "support roots should not make runtime-only modules test reachable"
778 );
779
780 assert!(graph.modules[4].is_reachable());
781 assert!(graph.modules[4].is_test_reachable());
782 assert!(
783 !graph.modules[4].is_runtime_reachable(),
784 "test-only reachability should stay separate from runtime roots"
785 );
786 }
787
788 #[test]
789 fn graph_export_has_reference() {
790 let graph = build_simple_graph();
791 let utils = &graph.modules[1];
792 let foo_export = utils
793 .exports
794 .iter()
795 .find(|e| e.name.to_string() == "foo")
796 .unwrap();
797 assert!(
798 !foo_export.references.is_empty(),
799 "foo should have references"
800 );
801 }
802
803 #[test]
804 fn graph_unused_export_no_reference() {
805 let graph = build_simple_graph();
806 let utils = &graph.modules[1];
807 let bar_export = utils
808 .exports
809 .iter()
810 .find(|e| e.name.to_string() == "bar")
811 .unwrap();
812 assert!(
813 bar_export.references.is_empty(),
814 "bar should have no references"
815 );
816 }
817
818 #[test]
819 fn graph_no_namespace_import() {
820 let graph = build_simple_graph();
821 assert!(!graph.has_namespace_import(FileId(0)));
822 assert!(!graph.has_namespace_import(FileId(1)));
823 }
824
825 #[test]
826 fn graph_has_namespace_import() {
827 let files = vec![
828 DiscoveredFile {
829 id: FileId(0),
830 path: PathBuf::from("/project/entry.ts"),
831 size_bytes: 100,
832 },
833 DiscoveredFile {
834 id: FileId(1),
835 path: PathBuf::from("/project/utils.ts"),
836 size_bytes: 50,
837 },
838 ];
839
840 let entry_points = vec![EntryPoint {
841 path: PathBuf::from("/project/entry.ts"),
842 source: EntryPointSource::PackageJsonMain,
843 }];
844
845 let resolved_modules = vec![
846 ResolvedModule {
847 file_id: FileId(0),
848 path: PathBuf::from("/project/entry.ts"),
849 resolved_imports: vec![ResolvedImport {
850 info: ImportInfo {
851 source: "./utils".to_string(),
852 imported_name: ImportedName::Namespace,
853 local_name: "utils".to_string(),
854 is_type_only: false,
855 from_style: false,
856 span: oxc_span::Span::new(0, 10),
857 source_span: oxc_span::Span::default(),
858 },
859 target: ResolveResult::InternalModule(FileId(1)),
860 }],
861 ..Default::default()
862 },
863 ResolvedModule {
864 file_id: FileId(1),
865 path: PathBuf::from("/project/utils.ts"),
866 exports: vec![fallow_types::extract::ExportInfo {
867 name: ExportName::Named("foo".to_string()),
868 local_name: Some("foo".to_string()),
869 is_type_only: false,
870 visibility: VisibilityTag::None,
871 expected_unused_reason: None,
872 span: oxc_span::Span::new(0, 20),
873 members: vec![],
874 is_side_effect_used: false,
875 super_class: None,
876 }],
877 ..Default::default()
878 },
879 ];
880
881 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
882 assert!(
883 graph.has_namespace_import(FileId(1)),
884 "utils should have namespace import"
885 );
886 }
887
888 #[test]
889 fn graph_has_namespace_import_out_of_bounds() {
890 let graph = build_simple_graph();
891 assert!(!graph.has_namespace_import(FileId(999)));
892 }
893
894 #[test]
899 fn reconstruct_namespace_imported_matches_fresh_build() {
900 let files = vec![
901 DiscoveredFile {
902 id: FileId(0),
903 path: PathBuf::from("/project/entry.ts"),
904 size_bytes: 100,
905 },
906 DiscoveredFile {
907 id: FileId(1),
908 path: PathBuf::from("/project/utils.ts"),
909 size_bytes: 50,
910 },
911 DiscoveredFile {
912 id: FileId(2),
913 path: PathBuf::from("/project/named-only.ts"),
914 size_bytes: 50,
915 },
916 ];
917 let entry_points = vec![EntryPoint {
918 path: PathBuf::from("/project/entry.ts"),
919 source: EntryPointSource::PackageJsonMain,
920 }];
921 let resolved_modules = vec![
922 ResolvedModule {
923 file_id: FileId(0),
924 path: PathBuf::from("/project/entry.ts"),
925 resolved_imports: vec![
926 ResolvedImport {
927 info: ImportInfo {
928 source: "./utils".to_string(),
929 imported_name: ImportedName::Namespace,
930 local_name: "utils".to_string(),
931 is_type_only: false,
932 from_style: false,
933 span: oxc_span::Span::new(0, 10),
934 source_span: oxc_span::Span::default(),
935 },
936 target: ResolveResult::InternalModule(FileId(1)),
937 },
938 ResolvedImport {
939 info: ImportInfo {
940 source: "./named-only".to_string(),
941 imported_name: ImportedName::Named("foo".to_string()),
942 local_name: "foo".to_string(),
943 is_type_only: false,
944 from_style: false,
945 span: oxc_span::Span::new(11, 20),
946 source_span: oxc_span::Span::default(),
947 },
948 target: ResolveResult::InternalModule(FileId(2)),
949 },
950 ],
951 ..Default::default()
952 },
953 ResolvedModule {
954 file_id: FileId(1),
955 path: PathBuf::from("/project/utils.ts"),
956 ..Default::default()
957 },
958 ResolvedModule {
959 file_id: FileId(2),
960 path: PathBuf::from("/project/named-only.ts"),
961 exports: vec![fallow_types::extract::ExportInfo {
962 name: ExportName::Named("foo".to_string()),
963 local_name: Some("foo".to_string()),
964 is_type_only: false,
965 visibility: VisibilityTag::None,
966 expected_unused_reason: None,
967 span: oxc_span::Span::new(0, 20),
968 members: vec![],
969 is_side_effect_used: false,
970 super_class: None,
971 }],
972 ..Default::default()
973 },
974 ];
975
976 let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
977 let fresh = graph.namespace_imported.clone();
978
979 assert!(graph.has_namespace_import(FileId(1)));
981 assert!(!graph.has_namespace_import(FileId(2)));
982
983 graph.namespace_imported = FixedBitSet::default();
986 graph.reconstruct_namespace_imported();
987
988 assert_eq!(
989 graph.namespace_imported, fresh,
990 "reconstructed namespace_imported must equal the fresh-built bitset"
991 );
992 assert!(graph.has_namespace_import(FileId(1)));
993 assert!(!graph.has_namespace_import(FileId(2)));
994 }
995
996 #[test]
997 fn graph_unreachable_module() {
998 let files = vec![
999 DiscoveredFile {
1000 id: FileId(0),
1001 path: PathBuf::from("/project/entry.ts"),
1002 size_bytes: 100,
1003 },
1004 DiscoveredFile {
1005 id: FileId(1),
1006 path: PathBuf::from("/project/utils.ts"),
1007 size_bytes: 50,
1008 },
1009 DiscoveredFile {
1010 id: FileId(2),
1011 path: PathBuf::from("/project/orphan.ts"),
1012 size_bytes: 30,
1013 },
1014 ];
1015
1016 let entry_points = vec![EntryPoint {
1017 path: PathBuf::from("/project/entry.ts"),
1018 source: EntryPointSource::PackageJsonMain,
1019 }];
1020
1021 let resolved_modules = vec![
1022 ResolvedModule {
1023 file_id: FileId(0),
1024 path: PathBuf::from("/project/entry.ts"),
1025 resolved_imports: vec![ResolvedImport {
1026 info: ImportInfo {
1027 source: "./utils".to_string(),
1028 imported_name: ImportedName::Named("foo".to_string()),
1029 local_name: "foo".to_string(),
1030 is_type_only: false,
1031 from_style: false,
1032 span: oxc_span::Span::new(0, 10),
1033 source_span: oxc_span::Span::default(),
1034 },
1035 target: ResolveResult::InternalModule(FileId(1)),
1036 }],
1037 ..Default::default()
1038 },
1039 ResolvedModule {
1040 file_id: FileId(1),
1041 path: PathBuf::from("/project/utils.ts"),
1042 exports: vec![fallow_types::extract::ExportInfo {
1043 name: ExportName::Named("foo".to_string()),
1044 local_name: Some("foo".to_string()),
1045 is_type_only: false,
1046 visibility: VisibilityTag::None,
1047 expected_unused_reason: None,
1048 span: oxc_span::Span::new(0, 20),
1049 members: vec![],
1050 is_side_effect_used: false,
1051 super_class: None,
1052 }],
1053 ..Default::default()
1054 },
1055 ResolvedModule {
1056 file_id: FileId(2),
1057 path: PathBuf::from("/project/orphan.ts"),
1058 exports: vec![fallow_types::extract::ExportInfo {
1059 name: ExportName::Named("orphan".to_string()),
1060 local_name: Some("orphan".to_string()),
1061 is_type_only: false,
1062 visibility: VisibilityTag::None,
1063 expected_unused_reason: None,
1064 span: oxc_span::Span::new(0, 20),
1065 members: vec![],
1066 is_side_effect_used: false,
1067 super_class: None,
1068 }],
1069 ..Default::default()
1070 },
1071 ];
1072
1073 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1074
1075 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1076 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1077 assert!(
1078 !graph.modules[2].is_reachable(),
1079 "orphan should NOT be reachable"
1080 );
1081 }
1082
1083 #[test]
1084 fn graph_package_usage_tracked() {
1085 let files = vec![DiscoveredFile {
1086 id: FileId(0),
1087 path: PathBuf::from("/project/entry.ts"),
1088 size_bytes: 100,
1089 }];
1090
1091 let entry_points = vec![EntryPoint {
1092 path: PathBuf::from("/project/entry.ts"),
1093 source: EntryPointSource::PackageJsonMain,
1094 }];
1095
1096 let resolved_modules = vec![ResolvedModule {
1097 file_id: FileId(0),
1098 path: PathBuf::from("/project/entry.ts"),
1099 exports: vec![],
1100 re_exports: vec![],
1101 resolved_imports: vec![
1102 ResolvedImport {
1103 info: ImportInfo {
1104 source: "react".to_string(),
1105 imported_name: ImportedName::Default,
1106 local_name: "React".to_string(),
1107 is_type_only: false,
1108 from_style: false,
1109 span: oxc_span::Span::new(0, 10),
1110 source_span: oxc_span::Span::default(),
1111 },
1112 target: ResolveResult::NpmPackage("react".to_string()),
1113 },
1114 ResolvedImport {
1115 info: ImportInfo {
1116 source: "lodash".to_string(),
1117 imported_name: ImportedName::Named("merge".to_string()),
1118 local_name: "merge".to_string(),
1119 is_type_only: false,
1120 from_style: false,
1121 span: oxc_span::Span::new(15, 30),
1122 source_span: oxc_span::Span::default(),
1123 },
1124 target: ResolveResult::NpmPackage("lodash".to_string()),
1125 },
1126 ],
1127 ..Default::default()
1128 }];
1129
1130 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1131 assert!(graph.package_usage.contains_key("react"));
1132 assert!(graph.package_usage.contains_key("lodash"));
1133 assert!(!graph.package_usage.contains_key("express"));
1134 }
1135
1136 #[test]
1137 fn graph_empty() {
1138 let graph = ModuleGraph::build(&[], &[], &[]);
1139 assert_eq!(graph.module_count(), 0);
1140 assert_eq!(graph.edge_count(), 0);
1141 }
1142
1143 #[test]
1149 fn graph_postcard_round_trip_is_lossless() {
1150 let graph = build_simple_graph();
1151
1152 let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1153 let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1154 decoded.reconstruct_namespace_imported();
1156
1157 assert_eq!(decoded.module_count(), graph.module_count());
1158 assert_eq!(decoded.edge_count(), graph.edge_count());
1159 assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1160
1161 let utils = &decoded.modules[1];
1163 let foo = utils
1164 .exports
1165 .iter()
1166 .find(|e| e.name.to_string() == "foo")
1167 .expect("foo export survives round-trip");
1168 assert!(!foo.references.is_empty());
1169 let bar = utils
1170 .exports
1171 .iter()
1172 .find(|e| e.name.to_string() == "bar")
1173 .expect("bar export survives round-trip");
1174 assert!(bar.references.is_empty());
1175
1176 assert!(decoded.modules[0].is_entry_point());
1178 assert!(decoded.modules[0].is_reachable());
1179 assert!(decoded.modules[1].is_reachable());
1180 assert_eq!(decoded.entry_points, graph.entry_points);
1181 }
1182
1183 #[test]
1184 fn graph_cjs_exports_tracked() {
1185 let files = vec![DiscoveredFile {
1186 id: FileId(0),
1187 path: PathBuf::from("/project/entry.ts"),
1188 size_bytes: 100,
1189 }];
1190
1191 let entry_points = vec![EntryPoint {
1192 path: PathBuf::from("/project/entry.ts"),
1193 source: EntryPointSource::PackageJsonMain,
1194 }];
1195
1196 let resolved_modules = vec![ResolvedModule {
1197 file_id: FileId(0),
1198 path: PathBuf::from("/project/entry.ts"),
1199 has_cjs_exports: true,
1200 has_angular_component_template_url: false,
1201 ..Default::default()
1202 }];
1203
1204 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1205 assert!(graph.modules[0].has_cjs_exports());
1206 }
1207
1208 #[test]
1209 fn graph_edges_for_returns_targets() {
1210 let graph = build_simple_graph();
1211 let targets = graph.edges_for(FileId(0));
1212 assert_eq!(targets, vec![FileId(1)]);
1213 }
1214
1215 #[test]
1216 fn graph_edges_for_no_imports() {
1217 let graph = build_simple_graph();
1218 let targets = graph.edges_for(FileId(1));
1219 assert!(targets.is_empty());
1220 }
1221
1222 #[test]
1223 fn graph_edges_for_out_of_bounds() {
1224 let graph = build_simple_graph();
1225 let targets = graph.edges_for(FileId(999));
1226 assert!(targets.is_empty());
1227 }
1228
1229 #[test]
1230 fn graph_direct_importer_summaries_include_symbols() {
1231 let graph = build_simple_graph();
1232 let summaries = graph.direct_importer_summaries(FileId(1));
1233
1234 assert_eq!(
1235 summaries,
1236 vec![DirectImporterSummary {
1237 source: FileId(0),
1238 symbols: vec![ImportedSymbolSummary {
1239 imported: "foo".to_string(),
1240 local: "foo".to_string(),
1241 type_only: false,
1242 }],
1243 }]
1244 );
1245 }
1246
1247 #[test]
1248 fn graph_find_import_span_start_found() {
1249 let graph = build_simple_graph();
1250 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1251 assert!(span_start.is_some());
1252 assert_eq!(span_start.unwrap(), 0);
1253 }
1254
1255 #[test]
1256 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1257 let files = vec![
1258 DiscoveredFile {
1259 id: FileId(0),
1260 path: PathBuf::from("/project/entry.ts"),
1261 size_bytes: 100,
1262 },
1263 DiscoveredFile {
1264 id: FileId(1),
1265 path: PathBuf::from("/project/utils.ts"),
1266 size_bytes: 50,
1267 },
1268 ];
1269 let entry_points = vec![EntryPoint {
1270 path: PathBuf::from("/project/entry.ts"),
1271 source: EntryPointSource::PackageJsonMain,
1272 }];
1273 let resolved_modules = vec![
1274 ResolvedModule {
1275 file_id: FileId(0),
1276 path: PathBuf::from("/project/entry.ts"),
1277 resolved_imports: vec![
1278 ResolvedImport {
1279 info: ImportInfo {
1280 source: "./utils".to_string(),
1281 imported_name: ImportedName::Named("Foo".to_string()),
1282 local_name: "Foo".to_string(),
1283 is_type_only: true,
1284 from_style: false,
1285 span: oxc_span::Span::new(10, 20),
1286 source_span: oxc_span::Span::default(),
1287 },
1288 target: ResolveResult::InternalModule(FileId(1)),
1289 },
1290 ResolvedImport {
1291 info: ImportInfo {
1292 source: "./utils".to_string(),
1293 imported_name: ImportedName::Named("foo".to_string()),
1294 local_name: "foo".to_string(),
1295 is_type_only: false,
1296 from_style: false,
1297 span: oxc_span::Span::new(50, 60),
1298 source_span: oxc_span::Span::default(),
1299 },
1300 target: ResolveResult::InternalModule(FileId(1)),
1301 },
1302 ],
1303 ..Default::default()
1304 },
1305 ResolvedModule {
1306 file_id: FileId(1),
1307 path: PathBuf::from("/project/utils.ts"),
1308 ..Default::default()
1309 },
1310 ];
1311
1312 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1313 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1314 }
1315
1316 #[test]
1317 fn graph_find_import_span_start_wrong_target() {
1318 let graph = build_simple_graph();
1319 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1320 assert!(span_start.is_none());
1321 }
1322
1323 #[test]
1324 fn graph_find_import_span_start_source_out_of_bounds() {
1325 let graph = build_simple_graph();
1326 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1327 assert!(span_start.is_none());
1328 }
1329
1330 #[test]
1331 fn graph_find_import_span_start_no_edges() {
1332 let graph = build_simple_graph();
1333 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1334 assert!(span_start.is_none());
1335 }
1336
1337 #[test]
1338 fn graph_reverse_deps_populated() {
1339 let graph = build_simple_graph();
1340 assert!(graph.reverse_deps[1].contains(&FileId(0)));
1341 assert!(graph.reverse_deps[0].is_empty());
1342 }
1343
1344 #[test]
1345 fn graph_type_only_package_usage_tracked() {
1346 let files = vec![DiscoveredFile {
1347 id: FileId(0),
1348 path: PathBuf::from("/project/entry.ts"),
1349 size_bytes: 100,
1350 }];
1351 let entry_points = vec![EntryPoint {
1352 path: PathBuf::from("/project/entry.ts"),
1353 source: EntryPointSource::PackageJsonMain,
1354 }];
1355 let resolved_modules = vec![ResolvedModule {
1356 file_id: FileId(0),
1357 path: PathBuf::from("/project/entry.ts"),
1358 resolved_imports: vec![
1359 ResolvedImport {
1360 info: ImportInfo {
1361 source: "react".to_string(),
1362 imported_name: ImportedName::Named("FC".to_string()),
1363 local_name: "FC".to_string(),
1364 is_type_only: true,
1365 from_style: false,
1366 span: oxc_span::Span::new(0, 10),
1367 source_span: oxc_span::Span::default(),
1368 },
1369 target: ResolveResult::NpmPackage("react".to_string()),
1370 },
1371 ResolvedImport {
1372 info: ImportInfo {
1373 source: "react".to_string(),
1374 imported_name: ImportedName::Named("useState".to_string()),
1375 local_name: "useState".to_string(),
1376 is_type_only: false,
1377 from_style: false,
1378 span: oxc_span::Span::new(15, 30),
1379 source_span: oxc_span::Span::default(),
1380 },
1381 target: ResolveResult::NpmPackage("react".to_string()),
1382 },
1383 ],
1384 ..Default::default()
1385 }];
1386
1387 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1388 assert!(graph.package_usage.contains_key("react"));
1389 assert!(graph.type_only_package_usage.contains_key("react"));
1390 }
1391
1392 #[test]
1393 fn graph_default_import_reference() {
1394 let files = vec![
1395 DiscoveredFile {
1396 id: FileId(0),
1397 path: PathBuf::from("/project/entry.ts"),
1398 size_bytes: 100,
1399 },
1400 DiscoveredFile {
1401 id: FileId(1),
1402 path: PathBuf::from("/project/utils.ts"),
1403 size_bytes: 50,
1404 },
1405 ];
1406 let entry_points = vec![EntryPoint {
1407 path: PathBuf::from("/project/entry.ts"),
1408 source: EntryPointSource::PackageJsonMain,
1409 }];
1410 let resolved_modules = vec![
1411 ResolvedModule {
1412 file_id: FileId(0),
1413 path: PathBuf::from("/project/entry.ts"),
1414 resolved_imports: vec![ResolvedImport {
1415 info: ImportInfo {
1416 source: "./utils".to_string(),
1417 imported_name: ImportedName::Default,
1418 local_name: "Utils".to_string(),
1419 is_type_only: false,
1420 from_style: false,
1421 span: oxc_span::Span::new(0, 10),
1422 source_span: oxc_span::Span::default(),
1423 },
1424 target: ResolveResult::InternalModule(FileId(1)),
1425 }],
1426 ..Default::default()
1427 },
1428 ResolvedModule {
1429 file_id: FileId(1),
1430 path: PathBuf::from("/project/utils.ts"),
1431 exports: vec![fallow_types::extract::ExportInfo {
1432 name: ExportName::Default,
1433 local_name: None,
1434 is_type_only: false,
1435 visibility: VisibilityTag::None,
1436 expected_unused_reason: None,
1437 span: oxc_span::Span::new(0, 20),
1438 members: vec![],
1439 is_side_effect_used: false,
1440 super_class: None,
1441 }],
1442 ..Default::default()
1443 },
1444 ];
1445
1446 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1447 let utils = &graph.modules[1];
1448 let default_export = utils
1449 .exports
1450 .iter()
1451 .find(|e| matches!(e.name, ExportName::Default))
1452 .unwrap();
1453 assert!(!default_export.references.is_empty());
1454 assert_eq!(
1455 default_export.references[0].kind,
1456 ReferenceKind::DefaultImport
1457 );
1458 }
1459
1460 #[test]
1461 fn graph_side_effect_import_no_export_reference() {
1462 let files = vec![
1463 DiscoveredFile {
1464 id: FileId(0),
1465 path: PathBuf::from("/project/entry.ts"),
1466 size_bytes: 100,
1467 },
1468 DiscoveredFile {
1469 id: FileId(1),
1470 path: PathBuf::from("/project/styles.ts"),
1471 size_bytes: 50,
1472 },
1473 ];
1474 let entry_points = vec![EntryPoint {
1475 path: PathBuf::from("/project/entry.ts"),
1476 source: EntryPointSource::PackageJsonMain,
1477 }];
1478 let resolved_modules = vec![
1479 ResolvedModule {
1480 file_id: FileId(0),
1481 path: PathBuf::from("/project/entry.ts"),
1482 resolved_imports: vec![ResolvedImport {
1483 info: ImportInfo {
1484 source: "./styles".to_string(),
1485 imported_name: ImportedName::SideEffect,
1486 local_name: String::new(),
1487 is_type_only: false,
1488 from_style: false,
1489 span: oxc_span::Span::new(0, 10),
1490 source_span: oxc_span::Span::default(),
1491 },
1492 target: ResolveResult::InternalModule(FileId(1)),
1493 }],
1494 ..Default::default()
1495 },
1496 ResolvedModule {
1497 file_id: FileId(1),
1498 path: PathBuf::from("/project/styles.ts"),
1499 exports: vec![fallow_types::extract::ExportInfo {
1500 name: ExportName::Named("primaryColor".to_string()),
1501 local_name: Some("primaryColor".to_string()),
1502 is_type_only: false,
1503 visibility: VisibilityTag::None,
1504 expected_unused_reason: None,
1505 span: oxc_span::Span::new(0, 20),
1506 members: vec![],
1507 is_side_effect_used: false,
1508 super_class: None,
1509 }],
1510 ..Default::default()
1511 },
1512 ];
1513
1514 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1515 assert_eq!(graph.edge_count(), 1);
1516 let styles = &graph.modules[1];
1517 let export = &styles.exports[0];
1518 assert!(
1519 export.references.is_empty(),
1520 "side-effect import should not reference named exports"
1521 );
1522 }
1523
1524 #[test]
1525 fn graph_multiple_entry_points() {
1526 let files = vec![
1527 DiscoveredFile {
1528 id: FileId(0),
1529 path: PathBuf::from("/project/main.ts"),
1530 size_bytes: 100,
1531 },
1532 DiscoveredFile {
1533 id: FileId(1),
1534 path: PathBuf::from("/project/worker.ts"),
1535 size_bytes: 100,
1536 },
1537 DiscoveredFile {
1538 id: FileId(2),
1539 path: PathBuf::from("/project/shared.ts"),
1540 size_bytes: 50,
1541 },
1542 ];
1543 let entry_points = vec![
1544 EntryPoint {
1545 path: PathBuf::from("/project/main.ts"),
1546 source: EntryPointSource::PackageJsonMain,
1547 },
1548 EntryPoint {
1549 path: PathBuf::from("/project/worker.ts"),
1550 source: EntryPointSource::PackageJsonMain,
1551 },
1552 ];
1553 let resolved_modules = vec![
1554 ResolvedModule {
1555 file_id: FileId(0),
1556 path: PathBuf::from("/project/main.ts"),
1557 resolved_imports: vec![ResolvedImport {
1558 info: ImportInfo {
1559 source: "./shared".to_string(),
1560 imported_name: ImportedName::Named("helper".to_string()),
1561 local_name: "helper".to_string(),
1562 is_type_only: false,
1563 from_style: false,
1564 span: oxc_span::Span::new(0, 10),
1565 source_span: oxc_span::Span::default(),
1566 },
1567 target: ResolveResult::InternalModule(FileId(2)),
1568 }],
1569 ..Default::default()
1570 },
1571 ResolvedModule {
1572 file_id: FileId(1),
1573 path: PathBuf::from("/project/worker.ts"),
1574 ..Default::default()
1575 },
1576 ResolvedModule {
1577 file_id: FileId(2),
1578 path: PathBuf::from("/project/shared.ts"),
1579 exports: vec![fallow_types::extract::ExportInfo {
1580 name: ExportName::Named("helper".to_string()),
1581 local_name: Some("helper".to_string()),
1582 is_type_only: false,
1583 visibility: VisibilityTag::None,
1584 expected_unused_reason: None,
1585 span: oxc_span::Span::new(0, 20),
1586 members: vec![],
1587 is_side_effect_used: false,
1588 super_class: None,
1589 }],
1590 ..Default::default()
1591 },
1592 ];
1593
1594 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1595 assert!(graph.modules[0].is_entry_point());
1596 assert!(graph.modules[1].is_entry_point());
1597 assert!(!graph.modules[2].is_entry_point());
1598 assert!(graph.modules[0].is_reachable());
1599 assert!(graph.modules[1].is_reachable());
1600 assert!(graph.modules[2].is_reachable());
1601 }
1602}