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