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