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