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)]
53pub struct ModuleGraph {
54 pub modules: Vec<ModuleNode>,
64 edges: Vec<Edge>,
66 pub package_usage: FxHashMap<String, Vec<FileId>>,
68 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
72 pub entry_points: FxHashSet<FileId>,
74 pub runtime_entry_points: FxHashSet<FileId>,
76 pub test_entry_points: FxHashSet<FileId>,
78 pub reverse_deps: Vec<Vec<FileId>>,
80 namespace_imported: FixedBitSet,
82 pub re_export_cycles: Vec<GraphReExportCycle>,
90}
91
92#[derive(Debug)]
100pub struct Edge {
101 pub source: FileId,
103 pub target: FileId,
105 pub symbols: Vec<ImportedSymbol>,
107}
108
109#[derive(Debug)]
111pub struct ImportedSymbol {
112 pub imported_name: ImportedName,
115 pub local_name: String,
117 pub import_span: oxc_span::Span,
119 pub is_type_only: bool,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DirectImporterSummary {
127 pub source: FileId,
129 pub symbols: Vec<ImportedSymbolSummary>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ImportedSymbolSummary {
136 pub imported: String,
139 pub local: String,
141 pub type_only: bool,
143}
144
145#[cfg(target_pointer_width = "64")]
146const _: () = assert!(std::mem::size_of::<Edge>() == 32);
147#[cfg(target_pointer_width = "64")]
148const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
149
150impl ModuleGraph {
151 fn resolve_entry_point_ids(
152 entry_points: &[EntryPoint],
153 path_to_id: &FxHashMap<&Path, FileId>,
154 ) -> FxHashSet<FileId> {
155 entry_points
156 .iter()
157 .filter_map(|ep| {
158 path_to_id.get(ep.path.as_path()).copied().or_else(|| {
159 dunce::canonicalize(&ep.path)
160 .ok()
161 .and_then(|path| path_to_id.get(path.as_path()).copied())
162 })
163 })
164 .collect()
165 }
166
167 pub fn build(
169 resolved_modules: &[ResolvedModule],
170 entry_points: &[EntryPoint],
171 files: &[DiscoveredFile],
172 ) -> Self {
173 Self::build_with_reachability_roots(
174 resolved_modules,
175 entry_points,
176 entry_points,
177 &[],
178 files,
179 )
180 }
181
182 pub fn build_with_reachability_roots(
184 resolved_modules: &[ResolvedModule],
185 entry_points: &[EntryPoint],
186 runtime_entry_points: &[EntryPoint],
187 test_entry_points: &[EntryPoint],
188 files: &[DiscoveredFile],
189 ) -> Self {
190 let _span = tracing::info_span!("build_graph").entered();
191
192 let module_count = files.len();
193
194 let max_file_id = files
195 .iter()
196 .map(|f| f.id.0 as usize)
197 .max()
198 .map_or(0, |m| m + 1);
199 let total_capacity = max_file_id.max(module_count);
200
201 let path_to_id: FxHashMap<&Path, FileId> =
202 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
203
204 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
205 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
206
207 let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
208 let runtime_entry_point_ids =
209 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
210 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
211
212 for file in files {
213 if is_declaration_file_path(&file.path) {
214 entry_point_ids.insert(file.id);
215 }
216 }
217
218 let mut graph = Self::populate_edges(&build::PopulateEdgesInput {
219 files,
220 module_by_id: &module_by_id,
221 entry_point_ids: &entry_point_ids,
222 runtime_entry_point_ids: &runtime_entry_point_ids,
223 test_entry_point_ids: &test_entry_point_ids,
224 module_count,
225 total_capacity,
226 });
227
228 graph.populate_references(&module_by_id, &entry_point_ids);
229
230 namespace_aliases::propagate_cross_package_aliases(&mut graph, &module_by_id);
231
232 namespace_re_exports::propagate_namespace_re_exports(&mut graph, &module_by_id);
233
234 graph.mark_reachable(
235 &entry_point_ids,
236 &runtime_entry_point_ids,
237 &test_entry_point_ids,
238 total_capacity,
239 );
240
241 graph.re_export_cycles = graph.resolve_re_export_chains(&module_by_id);
242
243 graph
244 }
245
246 #[must_use]
248 pub const fn module_count(&self) -> usize {
249 self.modules.len()
250 }
251
252 #[must_use]
254 pub const fn edge_count(&self) -> usize {
255 self.edges.len()
256 }
257
258 #[must_use]
261 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
262 let idx = file_id.0 as usize;
263 if idx >= self.namespace_imported.len() {
264 return false;
265 }
266 self.namespace_imported.contains(idx)
267 }
268
269 #[must_use]
271 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
272 let idx = file_id.0 as usize;
273 if idx >= self.modules.len() {
274 return Vec::new();
275 }
276 let range = &self.modules[idx].edge_range;
277 self.edges[range.clone()].iter().map(|e| e.target).collect()
278 }
279
280 pub fn outgoing_symbol_edges(
286 &self,
287 file_id: FileId,
288 ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
289 let idx = file_id.0 as usize;
290 let range = if idx < self.modules.len() {
291 self.modules[idx].edge_range.clone()
292 } else {
293 0..0
294 };
295 self.edges[range]
296 .iter()
297 .map(|edge| (edge.target, edge.symbols.as_slice()))
298 }
299
300 #[must_use]
304 pub fn importers_of(&self, target: FileId) -> &[FileId] {
305 self.reverse_deps
306 .get(target.0 as usize)
307 .map_or(&[], Vec::as_slice)
308 }
309
310 #[must_use]
315 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
316 let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
317 return Vec::new();
318 };
319
320 let mut summaries = Vec::new();
321 for &source in importers {
322 let idx = source.0 as usize;
323 let Some(source_node) = self.modules.get(idx) else {
324 continue;
325 };
326 let mut symbols = Vec::new();
327 for edge in &self.edges[source_node.edge_range.clone()] {
328 if edge.target != target {
329 continue;
330 }
331 symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
332 imported: imported_name_label(&symbol.imported_name),
333 local: symbol.local_name.clone(),
334 type_only: symbol.is_type_only,
335 }));
336 }
337 symbols.sort_by(|a, b| {
338 a.imported
339 .cmp(&b.imported)
340 .then_with(|| a.local.cmp(&b.local))
341 .then_with(|| a.type_only.cmp(&b.type_only))
342 });
343 symbols.dedup();
344 summaries.push(DirectImporterSummary { source, symbols });
345 }
346 summaries.sort_by_key(|summary| summary.source.0);
347 summaries
348 }
349
350 #[must_use]
357 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
358 let idx = source.0 as usize;
359 if idx >= self.modules.len() {
360 return None;
361 }
362 let range = &self.modules[idx].edge_range;
363 for edge in &self.edges[range.clone()] {
364 if edge.target == target {
365 return edge
366 .symbols
367 .iter()
368 .find(|s| !s.is_type_only)
369 .or_else(|| edge.symbols.first())
370 .map(|s| s.import_span.start);
371 }
372 }
373 None
374 }
375
376 pub fn outgoing_edge_summaries(
391 &self,
392 file_id: FileId,
393 ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
394 let idx = file_id.0 as usize;
395 let range = if idx < self.modules.len() {
396 self.modules[idx].edge_range.clone()
397 } else {
398 0..0
399 };
400 self.edges[range].iter().map(|edge| {
401 let all_type_only =
402 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
403 let span = edge
404 .symbols
405 .iter()
406 .find(|s| !s.is_type_only)
407 .or_else(|| edge.symbols.first())
408 .map(|s| s.import_span.start);
409 (edge.target, all_type_only, span)
410 })
411 }
412
413 pub fn outgoing_edge_summaries_with_exclusions<'a>(
424 &'a self,
425 file_id: FileId,
426 excluded_span_starts: &'a FxHashSet<u32>,
427 ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
428 let idx = file_id.0 as usize;
429 let range = if idx < self.modules.len() {
430 self.modules[idx].edge_range.clone()
431 } else {
432 0..0
433 };
434 self.edges[range].iter().map(move |edge| {
435 let all_type_only =
436 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
437 let span = edge
438 .symbols
439 .iter()
440 .find(|s| !s.is_type_only)
441 .or_else(|| edge.symbols.first())
442 .map(|s| s.import_span.start);
443 let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
447 let all_client_only = value_symbols.peek().is_some()
448 && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
449 (edge.target, all_type_only, span, all_client_only)
450 })
451 }
452}
453
454fn imported_name_label(name: &ImportedName) -> String {
455 match name {
456 ImportedName::Named(name) => name.clone(),
457 ImportedName::Default => "default".to_string(),
458 ImportedName::Namespace => "*".to_string(),
459 ImportedName::SideEffect => "side-effect".to_string(),
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
467 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
468 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
469 use std::path::PathBuf;
470
471 fn build_simple_graph() -> ModuleGraph {
472 let files = vec![
473 DiscoveredFile {
474 id: FileId(0),
475 path: PathBuf::from("/project/src/entry.ts"),
476 size_bytes: 100,
477 },
478 DiscoveredFile {
479 id: FileId(1),
480 path: PathBuf::from("/project/src/utils.ts"),
481 size_bytes: 50,
482 },
483 ];
484
485 let entry_points = vec![EntryPoint {
486 path: PathBuf::from("/project/src/entry.ts"),
487 source: EntryPointSource::PackageJsonMain,
488 }];
489
490 let resolved_modules = vec![
491 ResolvedModule {
492 file_id: FileId(0),
493 path: PathBuf::from("/project/src/entry.ts"),
494 resolved_imports: vec![ResolvedImport {
495 info: ImportInfo {
496 source: "./utils".to_string(),
497 imported_name: ImportedName::Named("foo".to_string()),
498 local_name: "foo".to_string(),
499 is_type_only: false,
500 from_style: false,
501 span: oxc_span::Span::new(0, 10),
502 source_span: oxc_span::Span::default(),
503 },
504 target: ResolveResult::InternalModule(FileId(1)),
505 }],
506 ..Default::default()
507 },
508 ResolvedModule {
509 file_id: FileId(1),
510 path: PathBuf::from("/project/src/utils.ts"),
511 exports: vec![
512 fallow_types::extract::ExportInfo {
513 name: ExportName::Named("foo".to_string()),
514 local_name: Some("foo".to_string()),
515 is_type_only: false,
516 visibility: VisibilityTag::None,
517 expected_unused_reason: None,
518 span: oxc_span::Span::new(0, 20),
519 members: vec![],
520 is_side_effect_used: false,
521 super_class: None,
522 },
523 fallow_types::extract::ExportInfo {
524 name: ExportName::Named("bar".to_string()),
525 local_name: Some("bar".to_string()),
526 is_type_only: false,
527 visibility: VisibilityTag::None,
528 expected_unused_reason: None,
529 span: oxc_span::Span::new(25, 45),
530 members: vec![],
531 is_side_effect_used: false,
532 super_class: None,
533 },
534 ],
535 ..Default::default()
536 },
537 ];
538
539 ModuleGraph::build(&resolved_modules, &entry_points, &files)
540 }
541
542 #[test]
543 fn graph_module_count() {
544 let graph = build_simple_graph();
545 assert_eq!(graph.module_count(), 2);
546 }
547
548 #[test]
549 fn graph_edge_count() {
550 let graph = build_simple_graph();
551 assert_eq!(graph.edge_count(), 1);
552 }
553
554 #[test]
555 fn graph_entry_point_is_reachable() {
556 let graph = build_simple_graph();
557 assert!(graph.modules[0].is_entry_point());
558 assert!(graph.modules[0].is_reachable());
559 }
560
561 #[test]
562 fn graph_imported_module_is_reachable() {
563 let graph = build_simple_graph();
564 assert!(!graph.modules[1].is_entry_point());
565 assert!(graph.modules[1].is_reachable());
566 }
567
568 #[test]
569 #[expect(
570 clippy::too_many_lines,
571 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
572 would obscure the cross-role assertions"
573 )]
574 fn graph_distinguishes_runtime_test_and_support_reachability() {
575 let files = vec![
576 DiscoveredFile {
577 id: FileId(0),
578 path: PathBuf::from("/project/src/main.ts"),
579 size_bytes: 100,
580 },
581 DiscoveredFile {
582 id: FileId(1),
583 path: PathBuf::from("/project/src/runtime-only.ts"),
584 size_bytes: 50,
585 },
586 DiscoveredFile {
587 id: FileId(2),
588 path: PathBuf::from("/project/tests/app.test.ts"),
589 size_bytes: 50,
590 },
591 DiscoveredFile {
592 id: FileId(3),
593 path: PathBuf::from("/project/tests/setup.ts"),
594 size_bytes: 50,
595 },
596 DiscoveredFile {
597 id: FileId(4),
598 path: PathBuf::from("/project/src/covered.ts"),
599 size_bytes: 50,
600 },
601 ];
602
603 let all_entry_points = vec![
604 EntryPoint {
605 path: PathBuf::from("/project/src/main.ts"),
606 source: EntryPointSource::PackageJsonMain,
607 },
608 EntryPoint {
609 path: PathBuf::from("/project/tests/app.test.ts"),
610 source: EntryPointSource::TestFile,
611 },
612 EntryPoint {
613 path: PathBuf::from("/project/tests/setup.ts"),
614 source: EntryPointSource::Plugin {
615 name: "vitest".to_string(),
616 },
617 },
618 ];
619 let runtime_entry_points = vec![EntryPoint {
620 path: PathBuf::from("/project/src/main.ts"),
621 source: EntryPointSource::PackageJsonMain,
622 }];
623 let test_entry_points = vec![EntryPoint {
624 path: PathBuf::from("/project/tests/app.test.ts"),
625 source: EntryPointSource::TestFile,
626 }];
627
628 let resolved_modules = vec![
629 ResolvedModule {
630 file_id: FileId(0),
631 path: PathBuf::from("/project/src/main.ts"),
632 resolved_imports: vec![ResolvedImport {
633 info: ImportInfo {
634 source: "./runtime-only".to_string(),
635 imported_name: ImportedName::Named("runtimeOnly".to_string()),
636 local_name: "runtimeOnly".to_string(),
637 is_type_only: false,
638 from_style: false,
639 span: oxc_span::Span::new(0, 10),
640 source_span: oxc_span::Span::default(),
641 },
642 target: ResolveResult::InternalModule(FileId(1)),
643 }],
644 ..Default::default()
645 },
646 ResolvedModule {
647 file_id: FileId(1),
648 path: PathBuf::from("/project/src/runtime-only.ts"),
649 exports: vec![fallow_types::extract::ExportInfo {
650 name: ExportName::Named("runtimeOnly".to_string()),
651 local_name: Some("runtimeOnly".to_string()),
652 is_type_only: false,
653 visibility: VisibilityTag::None,
654 expected_unused_reason: None,
655 span: oxc_span::Span::new(0, 20),
656 members: vec![],
657 is_side_effect_used: false,
658 super_class: None,
659 }],
660 ..Default::default()
661 },
662 ResolvedModule {
663 file_id: FileId(2),
664 path: PathBuf::from("/project/tests/app.test.ts"),
665 resolved_imports: vec![ResolvedImport {
666 info: ImportInfo {
667 source: "../src/covered".to_string(),
668 imported_name: ImportedName::Named("covered".to_string()),
669 local_name: "covered".to_string(),
670 is_type_only: false,
671 from_style: false,
672 span: oxc_span::Span::new(0, 10),
673 source_span: oxc_span::Span::default(),
674 },
675 target: ResolveResult::InternalModule(FileId(4)),
676 }],
677 ..Default::default()
678 },
679 ResolvedModule {
680 file_id: FileId(3),
681 path: PathBuf::from("/project/tests/setup.ts"),
682 resolved_imports: vec![ResolvedImport {
683 info: ImportInfo {
684 source: "../src/runtime-only".to_string(),
685 imported_name: ImportedName::Named("runtimeOnly".to_string()),
686 local_name: "runtimeOnly".to_string(),
687 is_type_only: false,
688 from_style: false,
689 span: oxc_span::Span::new(0, 10),
690 source_span: oxc_span::Span::default(),
691 },
692 target: ResolveResult::InternalModule(FileId(1)),
693 }],
694 ..Default::default()
695 },
696 ResolvedModule {
697 file_id: FileId(4),
698 path: PathBuf::from("/project/src/covered.ts"),
699 exports: vec![fallow_types::extract::ExportInfo {
700 name: ExportName::Named("covered".to_string()),
701 local_name: Some("covered".to_string()),
702 is_type_only: false,
703 visibility: VisibilityTag::None,
704 expected_unused_reason: None,
705 span: oxc_span::Span::new(0, 20),
706 members: vec![],
707 is_side_effect_used: false,
708 super_class: None,
709 }],
710 ..Default::default()
711 },
712 ];
713
714 let graph = ModuleGraph::build_with_reachability_roots(
715 &resolved_modules,
716 &all_entry_points,
717 &runtime_entry_points,
718 &test_entry_points,
719 &files,
720 );
721
722 assert!(graph.modules[1].is_reachable());
723 assert!(graph.modules[1].is_runtime_reachable());
724 assert!(
725 !graph.modules[1].is_test_reachable(),
726 "support roots should not make runtime-only modules test reachable"
727 );
728
729 assert!(graph.modules[4].is_reachable());
730 assert!(graph.modules[4].is_test_reachable());
731 assert!(
732 !graph.modules[4].is_runtime_reachable(),
733 "test-only reachability should stay separate from runtime roots"
734 );
735 }
736
737 #[test]
738 fn graph_export_has_reference() {
739 let graph = build_simple_graph();
740 let utils = &graph.modules[1];
741 let foo_export = utils
742 .exports
743 .iter()
744 .find(|e| e.name.to_string() == "foo")
745 .unwrap();
746 assert!(
747 !foo_export.references.is_empty(),
748 "foo should have references"
749 );
750 }
751
752 #[test]
753 fn graph_unused_export_no_reference() {
754 let graph = build_simple_graph();
755 let utils = &graph.modules[1];
756 let bar_export = utils
757 .exports
758 .iter()
759 .find(|e| e.name.to_string() == "bar")
760 .unwrap();
761 assert!(
762 bar_export.references.is_empty(),
763 "bar should have no references"
764 );
765 }
766
767 #[test]
768 fn graph_no_namespace_import() {
769 let graph = build_simple_graph();
770 assert!(!graph.has_namespace_import(FileId(0)));
771 assert!(!graph.has_namespace_import(FileId(1)));
772 }
773
774 #[test]
775 fn graph_has_namespace_import() {
776 let files = vec![
777 DiscoveredFile {
778 id: FileId(0),
779 path: PathBuf::from("/project/entry.ts"),
780 size_bytes: 100,
781 },
782 DiscoveredFile {
783 id: FileId(1),
784 path: PathBuf::from("/project/utils.ts"),
785 size_bytes: 50,
786 },
787 ];
788
789 let entry_points = vec![EntryPoint {
790 path: PathBuf::from("/project/entry.ts"),
791 source: EntryPointSource::PackageJsonMain,
792 }];
793
794 let resolved_modules = vec![
795 ResolvedModule {
796 file_id: FileId(0),
797 path: PathBuf::from("/project/entry.ts"),
798 resolved_imports: vec![ResolvedImport {
799 info: ImportInfo {
800 source: "./utils".to_string(),
801 imported_name: ImportedName::Namespace,
802 local_name: "utils".to_string(),
803 is_type_only: false,
804 from_style: false,
805 span: oxc_span::Span::new(0, 10),
806 source_span: oxc_span::Span::default(),
807 },
808 target: ResolveResult::InternalModule(FileId(1)),
809 }],
810 ..Default::default()
811 },
812 ResolvedModule {
813 file_id: FileId(1),
814 path: PathBuf::from("/project/utils.ts"),
815 exports: vec![fallow_types::extract::ExportInfo {
816 name: ExportName::Named("foo".to_string()),
817 local_name: Some("foo".to_string()),
818 is_type_only: false,
819 visibility: VisibilityTag::None,
820 expected_unused_reason: None,
821 span: oxc_span::Span::new(0, 20),
822 members: vec![],
823 is_side_effect_used: false,
824 super_class: None,
825 }],
826 ..Default::default()
827 },
828 ];
829
830 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
831 assert!(
832 graph.has_namespace_import(FileId(1)),
833 "utils should have namespace import"
834 );
835 }
836
837 #[test]
838 fn graph_has_namespace_import_out_of_bounds() {
839 let graph = build_simple_graph();
840 assert!(!graph.has_namespace_import(FileId(999)));
841 }
842
843 #[test]
844 fn graph_unreachable_module() {
845 let files = vec![
846 DiscoveredFile {
847 id: FileId(0),
848 path: PathBuf::from("/project/entry.ts"),
849 size_bytes: 100,
850 },
851 DiscoveredFile {
852 id: FileId(1),
853 path: PathBuf::from("/project/utils.ts"),
854 size_bytes: 50,
855 },
856 DiscoveredFile {
857 id: FileId(2),
858 path: PathBuf::from("/project/orphan.ts"),
859 size_bytes: 30,
860 },
861 ];
862
863 let entry_points = vec![EntryPoint {
864 path: PathBuf::from("/project/entry.ts"),
865 source: EntryPointSource::PackageJsonMain,
866 }];
867
868 let resolved_modules = vec![
869 ResolvedModule {
870 file_id: FileId(0),
871 path: PathBuf::from("/project/entry.ts"),
872 resolved_imports: vec![ResolvedImport {
873 info: ImportInfo {
874 source: "./utils".to_string(),
875 imported_name: ImportedName::Named("foo".to_string()),
876 local_name: "foo".to_string(),
877 is_type_only: false,
878 from_style: false,
879 span: oxc_span::Span::new(0, 10),
880 source_span: oxc_span::Span::default(),
881 },
882 target: ResolveResult::InternalModule(FileId(1)),
883 }],
884 ..Default::default()
885 },
886 ResolvedModule {
887 file_id: FileId(1),
888 path: PathBuf::from("/project/utils.ts"),
889 exports: vec![fallow_types::extract::ExportInfo {
890 name: ExportName::Named("foo".to_string()),
891 local_name: Some("foo".to_string()),
892 is_type_only: false,
893 visibility: VisibilityTag::None,
894 expected_unused_reason: None,
895 span: oxc_span::Span::new(0, 20),
896 members: vec![],
897 is_side_effect_used: false,
898 super_class: None,
899 }],
900 ..Default::default()
901 },
902 ResolvedModule {
903 file_id: FileId(2),
904 path: PathBuf::from("/project/orphan.ts"),
905 exports: vec![fallow_types::extract::ExportInfo {
906 name: ExportName::Named("orphan".to_string()),
907 local_name: Some("orphan".to_string()),
908 is_type_only: false,
909 visibility: VisibilityTag::None,
910 expected_unused_reason: None,
911 span: oxc_span::Span::new(0, 20),
912 members: vec![],
913 is_side_effect_used: false,
914 super_class: None,
915 }],
916 ..Default::default()
917 },
918 ];
919
920 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
921
922 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
923 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
924 assert!(
925 !graph.modules[2].is_reachable(),
926 "orphan should NOT be reachable"
927 );
928 }
929
930 #[test]
931 fn graph_package_usage_tracked() {
932 let files = vec![DiscoveredFile {
933 id: FileId(0),
934 path: PathBuf::from("/project/entry.ts"),
935 size_bytes: 100,
936 }];
937
938 let entry_points = vec![EntryPoint {
939 path: PathBuf::from("/project/entry.ts"),
940 source: EntryPointSource::PackageJsonMain,
941 }];
942
943 let resolved_modules = vec![ResolvedModule {
944 file_id: FileId(0),
945 path: PathBuf::from("/project/entry.ts"),
946 exports: vec![],
947 re_exports: vec![],
948 resolved_imports: vec![
949 ResolvedImport {
950 info: ImportInfo {
951 source: "react".to_string(),
952 imported_name: ImportedName::Default,
953 local_name: "React".to_string(),
954 is_type_only: false,
955 from_style: false,
956 span: oxc_span::Span::new(0, 10),
957 source_span: oxc_span::Span::default(),
958 },
959 target: ResolveResult::NpmPackage("react".to_string()),
960 },
961 ResolvedImport {
962 info: ImportInfo {
963 source: "lodash".to_string(),
964 imported_name: ImportedName::Named("merge".to_string()),
965 local_name: "merge".to_string(),
966 is_type_only: false,
967 from_style: false,
968 span: oxc_span::Span::new(15, 30),
969 source_span: oxc_span::Span::default(),
970 },
971 target: ResolveResult::NpmPackage("lodash".to_string()),
972 },
973 ],
974 ..Default::default()
975 }];
976
977 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
978 assert!(graph.package_usage.contains_key("react"));
979 assert!(graph.package_usage.contains_key("lodash"));
980 assert!(!graph.package_usage.contains_key("express"));
981 }
982
983 #[test]
984 fn graph_empty() {
985 let graph = ModuleGraph::build(&[], &[], &[]);
986 assert_eq!(graph.module_count(), 0);
987 assert_eq!(graph.edge_count(), 0);
988 }
989
990 #[test]
991 fn graph_cjs_exports_tracked() {
992 let files = vec![DiscoveredFile {
993 id: FileId(0),
994 path: PathBuf::from("/project/entry.ts"),
995 size_bytes: 100,
996 }];
997
998 let entry_points = vec![EntryPoint {
999 path: PathBuf::from("/project/entry.ts"),
1000 source: EntryPointSource::PackageJsonMain,
1001 }];
1002
1003 let resolved_modules = vec![ResolvedModule {
1004 file_id: FileId(0),
1005 path: PathBuf::from("/project/entry.ts"),
1006 has_cjs_exports: true,
1007 has_angular_component_template_url: false,
1008 ..Default::default()
1009 }];
1010
1011 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1012 assert!(graph.modules[0].has_cjs_exports());
1013 }
1014
1015 #[test]
1016 fn graph_edges_for_returns_targets() {
1017 let graph = build_simple_graph();
1018 let targets = graph.edges_for(FileId(0));
1019 assert_eq!(targets, vec![FileId(1)]);
1020 }
1021
1022 #[test]
1023 fn graph_edges_for_no_imports() {
1024 let graph = build_simple_graph();
1025 let targets = graph.edges_for(FileId(1));
1026 assert!(targets.is_empty());
1027 }
1028
1029 #[test]
1030 fn graph_edges_for_out_of_bounds() {
1031 let graph = build_simple_graph();
1032 let targets = graph.edges_for(FileId(999));
1033 assert!(targets.is_empty());
1034 }
1035
1036 #[test]
1037 fn graph_direct_importer_summaries_include_symbols() {
1038 let graph = build_simple_graph();
1039 let summaries = graph.direct_importer_summaries(FileId(1));
1040
1041 assert_eq!(
1042 summaries,
1043 vec![DirectImporterSummary {
1044 source: FileId(0),
1045 symbols: vec![ImportedSymbolSummary {
1046 imported: "foo".to_string(),
1047 local: "foo".to_string(),
1048 type_only: false,
1049 }],
1050 }]
1051 );
1052 }
1053
1054 #[test]
1055 fn graph_find_import_span_start_found() {
1056 let graph = build_simple_graph();
1057 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1058 assert!(span_start.is_some());
1059 assert_eq!(span_start.unwrap(), 0);
1060 }
1061
1062 #[test]
1063 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1064 let files = vec![
1065 DiscoveredFile {
1066 id: FileId(0),
1067 path: PathBuf::from("/project/entry.ts"),
1068 size_bytes: 100,
1069 },
1070 DiscoveredFile {
1071 id: FileId(1),
1072 path: PathBuf::from("/project/utils.ts"),
1073 size_bytes: 50,
1074 },
1075 ];
1076 let entry_points = vec![EntryPoint {
1077 path: PathBuf::from("/project/entry.ts"),
1078 source: EntryPointSource::PackageJsonMain,
1079 }];
1080 let resolved_modules = vec![
1081 ResolvedModule {
1082 file_id: FileId(0),
1083 path: PathBuf::from("/project/entry.ts"),
1084 resolved_imports: vec![
1085 ResolvedImport {
1086 info: ImportInfo {
1087 source: "./utils".to_string(),
1088 imported_name: ImportedName::Named("Foo".to_string()),
1089 local_name: "Foo".to_string(),
1090 is_type_only: true,
1091 from_style: false,
1092 span: oxc_span::Span::new(10, 20),
1093 source_span: oxc_span::Span::default(),
1094 },
1095 target: ResolveResult::InternalModule(FileId(1)),
1096 },
1097 ResolvedImport {
1098 info: ImportInfo {
1099 source: "./utils".to_string(),
1100 imported_name: ImportedName::Named("foo".to_string()),
1101 local_name: "foo".to_string(),
1102 is_type_only: false,
1103 from_style: false,
1104 span: oxc_span::Span::new(50, 60),
1105 source_span: oxc_span::Span::default(),
1106 },
1107 target: ResolveResult::InternalModule(FileId(1)),
1108 },
1109 ],
1110 ..Default::default()
1111 },
1112 ResolvedModule {
1113 file_id: FileId(1),
1114 path: PathBuf::from("/project/utils.ts"),
1115 ..Default::default()
1116 },
1117 ];
1118
1119 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1120 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1121 }
1122
1123 #[test]
1124 fn graph_find_import_span_start_wrong_target() {
1125 let graph = build_simple_graph();
1126 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1127 assert!(span_start.is_none());
1128 }
1129
1130 #[test]
1131 fn graph_find_import_span_start_source_out_of_bounds() {
1132 let graph = build_simple_graph();
1133 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1134 assert!(span_start.is_none());
1135 }
1136
1137 #[test]
1138 fn graph_find_import_span_start_no_edges() {
1139 let graph = build_simple_graph();
1140 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1141 assert!(span_start.is_none());
1142 }
1143
1144 #[test]
1145 fn graph_reverse_deps_populated() {
1146 let graph = build_simple_graph();
1147 assert!(graph.reverse_deps[1].contains(&FileId(0)));
1148 assert!(graph.reverse_deps[0].is_empty());
1149 }
1150
1151 #[test]
1152 fn graph_type_only_package_usage_tracked() {
1153 let files = vec![DiscoveredFile {
1154 id: FileId(0),
1155 path: PathBuf::from("/project/entry.ts"),
1156 size_bytes: 100,
1157 }];
1158 let entry_points = vec![EntryPoint {
1159 path: PathBuf::from("/project/entry.ts"),
1160 source: EntryPointSource::PackageJsonMain,
1161 }];
1162 let resolved_modules = vec![ResolvedModule {
1163 file_id: FileId(0),
1164 path: PathBuf::from("/project/entry.ts"),
1165 resolved_imports: vec![
1166 ResolvedImport {
1167 info: ImportInfo {
1168 source: "react".to_string(),
1169 imported_name: ImportedName::Named("FC".to_string()),
1170 local_name: "FC".to_string(),
1171 is_type_only: true,
1172 from_style: false,
1173 span: oxc_span::Span::new(0, 10),
1174 source_span: oxc_span::Span::default(),
1175 },
1176 target: ResolveResult::NpmPackage("react".to_string()),
1177 },
1178 ResolvedImport {
1179 info: ImportInfo {
1180 source: "react".to_string(),
1181 imported_name: ImportedName::Named("useState".to_string()),
1182 local_name: "useState".to_string(),
1183 is_type_only: false,
1184 from_style: false,
1185 span: oxc_span::Span::new(15, 30),
1186 source_span: oxc_span::Span::default(),
1187 },
1188 target: ResolveResult::NpmPackage("react".to_string()),
1189 },
1190 ],
1191 ..Default::default()
1192 }];
1193
1194 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1195 assert!(graph.package_usage.contains_key("react"));
1196 assert!(graph.type_only_package_usage.contains_key("react"));
1197 }
1198
1199 #[test]
1200 fn graph_default_import_reference() {
1201 let files = vec![
1202 DiscoveredFile {
1203 id: FileId(0),
1204 path: PathBuf::from("/project/entry.ts"),
1205 size_bytes: 100,
1206 },
1207 DiscoveredFile {
1208 id: FileId(1),
1209 path: PathBuf::from("/project/utils.ts"),
1210 size_bytes: 50,
1211 },
1212 ];
1213 let entry_points = vec![EntryPoint {
1214 path: PathBuf::from("/project/entry.ts"),
1215 source: EntryPointSource::PackageJsonMain,
1216 }];
1217 let resolved_modules = vec![
1218 ResolvedModule {
1219 file_id: FileId(0),
1220 path: PathBuf::from("/project/entry.ts"),
1221 resolved_imports: vec![ResolvedImport {
1222 info: ImportInfo {
1223 source: "./utils".to_string(),
1224 imported_name: ImportedName::Default,
1225 local_name: "Utils".to_string(),
1226 is_type_only: false,
1227 from_style: false,
1228 span: oxc_span::Span::new(0, 10),
1229 source_span: oxc_span::Span::default(),
1230 },
1231 target: ResolveResult::InternalModule(FileId(1)),
1232 }],
1233 ..Default::default()
1234 },
1235 ResolvedModule {
1236 file_id: FileId(1),
1237 path: PathBuf::from("/project/utils.ts"),
1238 exports: vec![fallow_types::extract::ExportInfo {
1239 name: ExportName::Default,
1240 local_name: None,
1241 is_type_only: false,
1242 visibility: VisibilityTag::None,
1243 expected_unused_reason: None,
1244 span: oxc_span::Span::new(0, 20),
1245 members: vec![],
1246 is_side_effect_used: false,
1247 super_class: None,
1248 }],
1249 ..Default::default()
1250 },
1251 ];
1252
1253 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1254 let utils = &graph.modules[1];
1255 let default_export = utils
1256 .exports
1257 .iter()
1258 .find(|e| matches!(e.name, ExportName::Default))
1259 .unwrap();
1260 assert!(!default_export.references.is_empty());
1261 assert_eq!(
1262 default_export.references[0].kind,
1263 ReferenceKind::DefaultImport
1264 );
1265 }
1266
1267 #[test]
1268 fn graph_side_effect_import_no_export_reference() {
1269 let files = vec![
1270 DiscoveredFile {
1271 id: FileId(0),
1272 path: PathBuf::from("/project/entry.ts"),
1273 size_bytes: 100,
1274 },
1275 DiscoveredFile {
1276 id: FileId(1),
1277 path: PathBuf::from("/project/styles.ts"),
1278 size_bytes: 50,
1279 },
1280 ];
1281 let entry_points = vec![EntryPoint {
1282 path: PathBuf::from("/project/entry.ts"),
1283 source: EntryPointSource::PackageJsonMain,
1284 }];
1285 let resolved_modules = vec![
1286 ResolvedModule {
1287 file_id: FileId(0),
1288 path: PathBuf::from("/project/entry.ts"),
1289 resolved_imports: vec![ResolvedImport {
1290 info: ImportInfo {
1291 source: "./styles".to_string(),
1292 imported_name: ImportedName::SideEffect,
1293 local_name: String::new(),
1294 is_type_only: false,
1295 from_style: false,
1296 span: oxc_span::Span::new(0, 10),
1297 source_span: oxc_span::Span::default(),
1298 },
1299 target: ResolveResult::InternalModule(FileId(1)),
1300 }],
1301 ..Default::default()
1302 },
1303 ResolvedModule {
1304 file_id: FileId(1),
1305 path: PathBuf::from("/project/styles.ts"),
1306 exports: vec![fallow_types::extract::ExportInfo {
1307 name: ExportName::Named("primaryColor".to_string()),
1308 local_name: Some("primaryColor".to_string()),
1309 is_type_only: false,
1310 visibility: VisibilityTag::None,
1311 expected_unused_reason: None,
1312 span: oxc_span::Span::new(0, 20),
1313 members: vec![],
1314 is_side_effect_used: false,
1315 super_class: None,
1316 }],
1317 ..Default::default()
1318 },
1319 ];
1320
1321 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1322 assert_eq!(graph.edge_count(), 1);
1323 let styles = &graph.modules[1];
1324 let export = &styles.exports[0];
1325 assert!(
1326 export.references.is_empty(),
1327 "side-effect import should not reference named exports"
1328 );
1329 }
1330
1331 #[test]
1332 fn graph_multiple_entry_points() {
1333 let files = vec![
1334 DiscoveredFile {
1335 id: FileId(0),
1336 path: PathBuf::from("/project/main.ts"),
1337 size_bytes: 100,
1338 },
1339 DiscoveredFile {
1340 id: FileId(1),
1341 path: PathBuf::from("/project/worker.ts"),
1342 size_bytes: 100,
1343 },
1344 DiscoveredFile {
1345 id: FileId(2),
1346 path: PathBuf::from("/project/shared.ts"),
1347 size_bytes: 50,
1348 },
1349 ];
1350 let entry_points = vec![
1351 EntryPoint {
1352 path: PathBuf::from("/project/main.ts"),
1353 source: EntryPointSource::PackageJsonMain,
1354 },
1355 EntryPoint {
1356 path: PathBuf::from("/project/worker.ts"),
1357 source: EntryPointSource::PackageJsonMain,
1358 },
1359 ];
1360 let resolved_modules = vec![
1361 ResolvedModule {
1362 file_id: FileId(0),
1363 path: PathBuf::from("/project/main.ts"),
1364 resolved_imports: vec![ResolvedImport {
1365 info: ImportInfo {
1366 source: "./shared".to_string(),
1367 imported_name: ImportedName::Named("helper".to_string()),
1368 local_name: "helper".to_string(),
1369 is_type_only: false,
1370 from_style: false,
1371 span: oxc_span::Span::new(0, 10),
1372 source_span: oxc_span::Span::default(),
1373 },
1374 target: ResolveResult::InternalModule(FileId(2)),
1375 }],
1376 ..Default::default()
1377 },
1378 ResolvedModule {
1379 file_id: FileId(1),
1380 path: PathBuf::from("/project/worker.ts"),
1381 ..Default::default()
1382 },
1383 ResolvedModule {
1384 file_id: FileId(2),
1385 path: PathBuf::from("/project/shared.ts"),
1386 exports: vec![fallow_types::extract::ExportInfo {
1387 name: ExportName::Named("helper".to_string()),
1388 local_name: Some("helper".to_string()),
1389 is_type_only: false,
1390 visibility: VisibilityTag::None,
1391 expected_unused_reason: None,
1392 span: oxc_span::Span::new(0, 20),
1393 members: vec![],
1394 is_side_effect_used: false,
1395 super_class: None,
1396 }],
1397 ..Default::default()
1398 },
1399 ];
1400
1401 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1402 assert!(graph.modules[0].is_entry_point());
1403 assert!(graph.modules[1].is_entry_point());
1404 assert!(!graph.modules[2].is_entry_point());
1405 assert!(graph.modules[0].is_reachable());
1406 assert!(graph.modules[1].is_reachable());
1407 assert!(graph.modules[2].is_reachable());
1408 }
1409}