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 span: oxc_span::Span::new(0, 20),
466 members: vec![],
467 is_side_effect_used: false,
468 super_class: None,
469 },
470 fallow_types::extract::ExportInfo {
471 name: ExportName::Named("bar".to_string()),
472 local_name: Some("bar".to_string()),
473 is_type_only: false,
474 visibility: VisibilityTag::None,
475 span: oxc_span::Span::new(25, 45),
476 members: vec![],
477 is_side_effect_used: false,
478 super_class: None,
479 },
480 ],
481 ..Default::default()
482 },
483 ];
484
485 ModuleGraph::build(&resolved_modules, &entry_points, &files)
486 }
487
488 #[test]
489 fn graph_module_count() {
490 let graph = build_simple_graph();
491 assert_eq!(graph.module_count(), 2);
492 }
493
494 #[test]
495 fn graph_edge_count() {
496 let graph = build_simple_graph();
497 assert_eq!(graph.edge_count(), 1);
498 }
499
500 #[test]
501 fn graph_entry_point_is_reachable() {
502 let graph = build_simple_graph();
503 assert!(graph.modules[0].is_entry_point());
504 assert!(graph.modules[0].is_reachable());
505 }
506
507 #[test]
508 fn graph_imported_module_is_reachable() {
509 let graph = build_simple_graph();
510 assert!(!graph.modules[1].is_entry_point());
511 assert!(graph.modules[1].is_reachable());
512 }
513
514 #[test]
515 #[expect(
516 clippy::too_many_lines,
517 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
518 would obscure the cross-role assertions"
519 )]
520 fn graph_distinguishes_runtime_test_and_support_reachability() {
521 let files = vec![
522 DiscoveredFile {
523 id: FileId(0),
524 path: PathBuf::from("/project/src/main.ts"),
525 size_bytes: 100,
526 },
527 DiscoveredFile {
528 id: FileId(1),
529 path: PathBuf::from("/project/src/runtime-only.ts"),
530 size_bytes: 50,
531 },
532 DiscoveredFile {
533 id: FileId(2),
534 path: PathBuf::from("/project/tests/app.test.ts"),
535 size_bytes: 50,
536 },
537 DiscoveredFile {
538 id: FileId(3),
539 path: PathBuf::from("/project/tests/setup.ts"),
540 size_bytes: 50,
541 },
542 DiscoveredFile {
543 id: FileId(4),
544 path: PathBuf::from("/project/src/covered.ts"),
545 size_bytes: 50,
546 },
547 ];
548
549 let all_entry_points = vec![
550 EntryPoint {
551 path: PathBuf::from("/project/src/main.ts"),
552 source: EntryPointSource::PackageJsonMain,
553 },
554 EntryPoint {
555 path: PathBuf::from("/project/tests/app.test.ts"),
556 source: EntryPointSource::TestFile,
557 },
558 EntryPoint {
559 path: PathBuf::from("/project/tests/setup.ts"),
560 source: EntryPointSource::Plugin {
561 name: "vitest".to_string(),
562 },
563 },
564 ];
565 let runtime_entry_points = vec![EntryPoint {
566 path: PathBuf::from("/project/src/main.ts"),
567 source: EntryPointSource::PackageJsonMain,
568 }];
569 let test_entry_points = vec![EntryPoint {
570 path: PathBuf::from("/project/tests/app.test.ts"),
571 source: EntryPointSource::TestFile,
572 }];
573
574 let resolved_modules = vec![
575 ResolvedModule {
576 file_id: FileId(0),
577 path: PathBuf::from("/project/src/main.ts"),
578 resolved_imports: vec![ResolvedImport {
579 info: ImportInfo {
580 source: "./runtime-only".to_string(),
581 imported_name: ImportedName::Named("runtimeOnly".to_string()),
582 local_name: "runtimeOnly".to_string(),
583 is_type_only: false,
584 from_style: false,
585 span: oxc_span::Span::new(0, 10),
586 source_span: oxc_span::Span::default(),
587 },
588 target: ResolveResult::InternalModule(FileId(1)),
589 }],
590 ..Default::default()
591 },
592 ResolvedModule {
593 file_id: FileId(1),
594 path: PathBuf::from("/project/src/runtime-only.ts"),
595 exports: vec![fallow_types::extract::ExportInfo {
596 name: ExportName::Named("runtimeOnly".to_string()),
597 local_name: Some("runtimeOnly".to_string()),
598 is_type_only: false,
599 visibility: VisibilityTag::None,
600 span: oxc_span::Span::new(0, 20),
601 members: vec![],
602 is_side_effect_used: false,
603 super_class: None,
604 }],
605 ..Default::default()
606 },
607 ResolvedModule {
608 file_id: FileId(2),
609 path: PathBuf::from("/project/tests/app.test.ts"),
610 resolved_imports: vec![ResolvedImport {
611 info: ImportInfo {
612 source: "../src/covered".to_string(),
613 imported_name: ImportedName::Named("covered".to_string()),
614 local_name: "covered".to_string(),
615 is_type_only: false,
616 from_style: false,
617 span: oxc_span::Span::new(0, 10),
618 source_span: oxc_span::Span::default(),
619 },
620 target: ResolveResult::InternalModule(FileId(4)),
621 }],
622 ..Default::default()
623 },
624 ResolvedModule {
625 file_id: FileId(3),
626 path: PathBuf::from("/project/tests/setup.ts"),
627 resolved_imports: vec![ResolvedImport {
628 info: ImportInfo {
629 source: "../src/runtime-only".to_string(),
630 imported_name: ImportedName::Named("runtimeOnly".to_string()),
631 local_name: "runtimeOnly".to_string(),
632 is_type_only: false,
633 from_style: false,
634 span: oxc_span::Span::new(0, 10),
635 source_span: oxc_span::Span::default(),
636 },
637 target: ResolveResult::InternalModule(FileId(1)),
638 }],
639 ..Default::default()
640 },
641 ResolvedModule {
642 file_id: FileId(4),
643 path: PathBuf::from("/project/src/covered.ts"),
644 exports: vec![fallow_types::extract::ExportInfo {
645 name: ExportName::Named("covered".to_string()),
646 local_name: Some("covered".to_string()),
647 is_type_only: false,
648 visibility: VisibilityTag::None,
649 span: oxc_span::Span::new(0, 20),
650 members: vec![],
651 is_side_effect_used: false,
652 super_class: None,
653 }],
654 ..Default::default()
655 },
656 ];
657
658 let graph = ModuleGraph::build_with_reachability_roots(
659 &resolved_modules,
660 &all_entry_points,
661 &runtime_entry_points,
662 &test_entry_points,
663 &files,
664 );
665
666 assert!(graph.modules[1].is_reachable());
667 assert!(graph.modules[1].is_runtime_reachable());
668 assert!(
669 !graph.modules[1].is_test_reachable(),
670 "support roots should not make runtime-only modules test reachable"
671 );
672
673 assert!(graph.modules[4].is_reachable());
674 assert!(graph.modules[4].is_test_reachable());
675 assert!(
676 !graph.modules[4].is_runtime_reachable(),
677 "test-only reachability should stay separate from runtime roots"
678 );
679 }
680
681 #[test]
682 fn graph_export_has_reference() {
683 let graph = build_simple_graph();
684 let utils = &graph.modules[1];
685 let foo_export = utils
686 .exports
687 .iter()
688 .find(|e| e.name.to_string() == "foo")
689 .unwrap();
690 assert!(
691 !foo_export.references.is_empty(),
692 "foo should have references"
693 );
694 }
695
696 #[test]
697 fn graph_unused_export_no_reference() {
698 let graph = build_simple_graph();
699 let utils = &graph.modules[1];
700 let bar_export = utils
701 .exports
702 .iter()
703 .find(|e| e.name.to_string() == "bar")
704 .unwrap();
705 assert!(
706 bar_export.references.is_empty(),
707 "bar should have no references"
708 );
709 }
710
711 #[test]
712 fn graph_no_namespace_import() {
713 let graph = build_simple_graph();
714 assert!(!graph.has_namespace_import(FileId(0)));
715 assert!(!graph.has_namespace_import(FileId(1)));
716 }
717
718 #[test]
719 fn graph_has_namespace_import() {
720 let files = vec![
721 DiscoveredFile {
722 id: FileId(0),
723 path: PathBuf::from("/project/entry.ts"),
724 size_bytes: 100,
725 },
726 DiscoveredFile {
727 id: FileId(1),
728 path: PathBuf::from("/project/utils.ts"),
729 size_bytes: 50,
730 },
731 ];
732
733 let entry_points = vec![EntryPoint {
734 path: PathBuf::from("/project/entry.ts"),
735 source: EntryPointSource::PackageJsonMain,
736 }];
737
738 let resolved_modules = vec![
739 ResolvedModule {
740 file_id: FileId(0),
741 path: PathBuf::from("/project/entry.ts"),
742 resolved_imports: vec![ResolvedImport {
743 info: ImportInfo {
744 source: "./utils".to_string(),
745 imported_name: ImportedName::Namespace,
746 local_name: "utils".to_string(),
747 is_type_only: false,
748 from_style: false,
749 span: oxc_span::Span::new(0, 10),
750 source_span: oxc_span::Span::default(),
751 },
752 target: ResolveResult::InternalModule(FileId(1)),
753 }],
754 ..Default::default()
755 },
756 ResolvedModule {
757 file_id: FileId(1),
758 path: PathBuf::from("/project/utils.ts"),
759 exports: vec![fallow_types::extract::ExportInfo {
760 name: ExportName::Named("foo".to_string()),
761 local_name: Some("foo".to_string()),
762 is_type_only: false,
763 visibility: VisibilityTag::None,
764 span: oxc_span::Span::new(0, 20),
765 members: vec![],
766 is_side_effect_used: false,
767 super_class: None,
768 }],
769 ..Default::default()
770 },
771 ];
772
773 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
774 assert!(
775 graph.has_namespace_import(FileId(1)),
776 "utils should have namespace import"
777 );
778 }
779
780 #[test]
781 fn graph_has_namespace_import_out_of_bounds() {
782 let graph = build_simple_graph();
783 assert!(!graph.has_namespace_import(FileId(999)));
784 }
785
786 #[test]
787 fn graph_unreachable_module() {
788 let files = vec![
789 DiscoveredFile {
790 id: FileId(0),
791 path: PathBuf::from("/project/entry.ts"),
792 size_bytes: 100,
793 },
794 DiscoveredFile {
795 id: FileId(1),
796 path: PathBuf::from("/project/utils.ts"),
797 size_bytes: 50,
798 },
799 DiscoveredFile {
800 id: FileId(2),
801 path: PathBuf::from("/project/orphan.ts"),
802 size_bytes: 30,
803 },
804 ];
805
806 let entry_points = vec![EntryPoint {
807 path: PathBuf::from("/project/entry.ts"),
808 source: EntryPointSource::PackageJsonMain,
809 }];
810
811 let resolved_modules = vec![
812 ResolvedModule {
813 file_id: FileId(0),
814 path: PathBuf::from("/project/entry.ts"),
815 resolved_imports: vec![ResolvedImport {
816 info: ImportInfo {
817 source: "./utils".to_string(),
818 imported_name: ImportedName::Named("foo".to_string()),
819 local_name: "foo".to_string(),
820 is_type_only: false,
821 from_style: false,
822 span: oxc_span::Span::new(0, 10),
823 source_span: oxc_span::Span::default(),
824 },
825 target: ResolveResult::InternalModule(FileId(1)),
826 }],
827 ..Default::default()
828 },
829 ResolvedModule {
830 file_id: FileId(1),
831 path: PathBuf::from("/project/utils.ts"),
832 exports: vec![fallow_types::extract::ExportInfo {
833 name: ExportName::Named("foo".to_string()),
834 local_name: Some("foo".to_string()),
835 is_type_only: false,
836 visibility: VisibilityTag::None,
837 span: oxc_span::Span::new(0, 20),
838 members: vec![],
839 is_side_effect_used: false,
840 super_class: None,
841 }],
842 ..Default::default()
843 },
844 ResolvedModule {
845 file_id: FileId(2),
846 path: PathBuf::from("/project/orphan.ts"),
847 exports: vec![fallow_types::extract::ExportInfo {
848 name: ExportName::Named("orphan".to_string()),
849 local_name: Some("orphan".to_string()),
850 is_type_only: false,
851 visibility: VisibilityTag::None,
852 span: oxc_span::Span::new(0, 20),
853 members: vec![],
854 is_side_effect_used: false,
855 super_class: None,
856 }],
857 ..Default::default()
858 },
859 ];
860
861 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
862
863 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
864 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
865 assert!(
866 !graph.modules[2].is_reachable(),
867 "orphan should NOT be reachable"
868 );
869 }
870
871 #[test]
872 fn graph_package_usage_tracked() {
873 let files = vec![DiscoveredFile {
874 id: FileId(0),
875 path: PathBuf::from("/project/entry.ts"),
876 size_bytes: 100,
877 }];
878
879 let entry_points = vec![EntryPoint {
880 path: PathBuf::from("/project/entry.ts"),
881 source: EntryPointSource::PackageJsonMain,
882 }];
883
884 let resolved_modules = vec![ResolvedModule {
885 file_id: FileId(0),
886 path: PathBuf::from("/project/entry.ts"),
887 exports: vec![],
888 re_exports: vec![],
889 resolved_imports: vec![
890 ResolvedImport {
891 info: ImportInfo {
892 source: "react".to_string(),
893 imported_name: ImportedName::Default,
894 local_name: "React".to_string(),
895 is_type_only: false,
896 from_style: false,
897 span: oxc_span::Span::new(0, 10),
898 source_span: oxc_span::Span::default(),
899 },
900 target: ResolveResult::NpmPackage("react".to_string()),
901 },
902 ResolvedImport {
903 info: ImportInfo {
904 source: "lodash".to_string(),
905 imported_name: ImportedName::Named("merge".to_string()),
906 local_name: "merge".to_string(),
907 is_type_only: false,
908 from_style: false,
909 span: oxc_span::Span::new(15, 30),
910 source_span: oxc_span::Span::default(),
911 },
912 target: ResolveResult::NpmPackage("lodash".to_string()),
913 },
914 ],
915 ..Default::default()
916 }];
917
918 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
919 assert!(graph.package_usage.contains_key("react"));
920 assert!(graph.package_usage.contains_key("lodash"));
921 assert!(!graph.package_usage.contains_key("express"));
922 }
923
924 #[test]
925 fn graph_empty() {
926 let graph = ModuleGraph::build(&[], &[], &[]);
927 assert_eq!(graph.module_count(), 0);
928 assert_eq!(graph.edge_count(), 0);
929 }
930
931 #[test]
932 fn graph_cjs_exports_tracked() {
933 let files = vec![DiscoveredFile {
934 id: FileId(0),
935 path: PathBuf::from("/project/entry.ts"),
936 size_bytes: 100,
937 }];
938
939 let entry_points = vec![EntryPoint {
940 path: PathBuf::from("/project/entry.ts"),
941 source: EntryPointSource::PackageJsonMain,
942 }];
943
944 let resolved_modules = vec![ResolvedModule {
945 file_id: FileId(0),
946 path: PathBuf::from("/project/entry.ts"),
947 has_cjs_exports: true,
948 has_angular_component_template_url: false,
949 ..Default::default()
950 }];
951
952 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
953 assert!(graph.modules[0].has_cjs_exports());
954 }
955
956 #[test]
957 fn graph_edges_for_returns_targets() {
958 let graph = build_simple_graph();
959 let targets = graph.edges_for(FileId(0));
960 assert_eq!(targets, vec![FileId(1)]);
961 }
962
963 #[test]
964 fn graph_edges_for_no_imports() {
965 let graph = build_simple_graph();
966 let targets = graph.edges_for(FileId(1));
967 assert!(targets.is_empty());
968 }
969
970 #[test]
971 fn graph_edges_for_out_of_bounds() {
972 let graph = build_simple_graph();
973 let targets = graph.edges_for(FileId(999));
974 assert!(targets.is_empty());
975 }
976
977 #[test]
978 fn graph_direct_importer_summaries_include_symbols() {
979 let graph = build_simple_graph();
980 let summaries = graph.direct_importer_summaries(FileId(1));
981
982 assert_eq!(
983 summaries,
984 vec![DirectImporterSummary {
985 source: FileId(0),
986 symbols: vec![ImportedSymbolSummary {
987 imported: "foo".to_string(),
988 local: "foo".to_string(),
989 type_only: false,
990 }],
991 }]
992 );
993 }
994
995 #[test]
996 fn graph_find_import_span_start_found() {
997 let graph = build_simple_graph();
998 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
999 assert!(span_start.is_some());
1000 assert_eq!(span_start.unwrap(), 0);
1001 }
1002
1003 #[test]
1004 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1005 let files = vec![
1006 DiscoveredFile {
1007 id: FileId(0),
1008 path: PathBuf::from("/project/entry.ts"),
1009 size_bytes: 100,
1010 },
1011 DiscoveredFile {
1012 id: FileId(1),
1013 path: PathBuf::from("/project/utils.ts"),
1014 size_bytes: 50,
1015 },
1016 ];
1017 let entry_points = vec![EntryPoint {
1018 path: PathBuf::from("/project/entry.ts"),
1019 source: EntryPointSource::PackageJsonMain,
1020 }];
1021 let resolved_modules = vec![
1022 ResolvedModule {
1023 file_id: FileId(0),
1024 path: PathBuf::from("/project/entry.ts"),
1025 resolved_imports: vec![
1026 ResolvedImport {
1027 info: ImportInfo {
1028 source: "./utils".to_string(),
1029 imported_name: ImportedName::Named("Foo".to_string()),
1030 local_name: "Foo".to_string(),
1031 is_type_only: true,
1032 from_style: false,
1033 span: oxc_span::Span::new(10, 20),
1034 source_span: oxc_span::Span::default(),
1035 },
1036 target: ResolveResult::InternalModule(FileId(1)),
1037 },
1038 ResolvedImport {
1039 info: ImportInfo {
1040 source: "./utils".to_string(),
1041 imported_name: ImportedName::Named("foo".to_string()),
1042 local_name: "foo".to_string(),
1043 is_type_only: false,
1044 from_style: false,
1045 span: oxc_span::Span::new(50, 60),
1046 source_span: oxc_span::Span::default(),
1047 },
1048 target: ResolveResult::InternalModule(FileId(1)),
1049 },
1050 ],
1051 ..Default::default()
1052 },
1053 ResolvedModule {
1054 file_id: FileId(1),
1055 path: PathBuf::from("/project/utils.ts"),
1056 ..Default::default()
1057 },
1058 ];
1059
1060 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1061 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1062 }
1063
1064 #[test]
1065 fn graph_find_import_span_start_wrong_target() {
1066 let graph = build_simple_graph();
1067 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1068 assert!(span_start.is_none());
1069 }
1070
1071 #[test]
1072 fn graph_find_import_span_start_source_out_of_bounds() {
1073 let graph = build_simple_graph();
1074 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1075 assert!(span_start.is_none());
1076 }
1077
1078 #[test]
1079 fn graph_find_import_span_start_no_edges() {
1080 let graph = build_simple_graph();
1081 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1082 assert!(span_start.is_none());
1083 }
1084
1085 #[test]
1086 fn graph_reverse_deps_populated() {
1087 let graph = build_simple_graph();
1088 assert!(graph.reverse_deps[1].contains(&FileId(0)));
1089 assert!(graph.reverse_deps[0].is_empty());
1090 }
1091
1092 #[test]
1093 fn graph_type_only_package_usage_tracked() {
1094 let files = vec![DiscoveredFile {
1095 id: FileId(0),
1096 path: PathBuf::from("/project/entry.ts"),
1097 size_bytes: 100,
1098 }];
1099 let entry_points = vec![EntryPoint {
1100 path: PathBuf::from("/project/entry.ts"),
1101 source: EntryPointSource::PackageJsonMain,
1102 }];
1103 let resolved_modules = vec![ResolvedModule {
1104 file_id: FileId(0),
1105 path: PathBuf::from("/project/entry.ts"),
1106 resolved_imports: vec![
1107 ResolvedImport {
1108 info: ImportInfo {
1109 source: "react".to_string(),
1110 imported_name: ImportedName::Named("FC".to_string()),
1111 local_name: "FC".to_string(),
1112 is_type_only: true,
1113 from_style: false,
1114 span: oxc_span::Span::new(0, 10),
1115 source_span: oxc_span::Span::default(),
1116 },
1117 target: ResolveResult::NpmPackage("react".to_string()),
1118 },
1119 ResolvedImport {
1120 info: ImportInfo {
1121 source: "react".to_string(),
1122 imported_name: ImportedName::Named("useState".to_string()),
1123 local_name: "useState".to_string(),
1124 is_type_only: false,
1125 from_style: false,
1126 span: oxc_span::Span::new(15, 30),
1127 source_span: oxc_span::Span::default(),
1128 },
1129 target: ResolveResult::NpmPackage("react".to_string()),
1130 },
1131 ],
1132 ..Default::default()
1133 }];
1134
1135 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1136 assert!(graph.package_usage.contains_key("react"));
1137 assert!(graph.type_only_package_usage.contains_key("react"));
1138 }
1139
1140 #[test]
1141 fn graph_default_import_reference() {
1142 let files = vec![
1143 DiscoveredFile {
1144 id: FileId(0),
1145 path: PathBuf::from("/project/entry.ts"),
1146 size_bytes: 100,
1147 },
1148 DiscoveredFile {
1149 id: FileId(1),
1150 path: PathBuf::from("/project/utils.ts"),
1151 size_bytes: 50,
1152 },
1153 ];
1154 let entry_points = vec![EntryPoint {
1155 path: PathBuf::from("/project/entry.ts"),
1156 source: EntryPointSource::PackageJsonMain,
1157 }];
1158 let resolved_modules = vec![
1159 ResolvedModule {
1160 file_id: FileId(0),
1161 path: PathBuf::from("/project/entry.ts"),
1162 resolved_imports: vec![ResolvedImport {
1163 info: ImportInfo {
1164 source: "./utils".to_string(),
1165 imported_name: ImportedName::Default,
1166 local_name: "Utils".to_string(),
1167 is_type_only: false,
1168 from_style: false,
1169 span: oxc_span::Span::new(0, 10),
1170 source_span: oxc_span::Span::default(),
1171 },
1172 target: ResolveResult::InternalModule(FileId(1)),
1173 }],
1174 ..Default::default()
1175 },
1176 ResolvedModule {
1177 file_id: FileId(1),
1178 path: PathBuf::from("/project/utils.ts"),
1179 exports: vec![fallow_types::extract::ExportInfo {
1180 name: ExportName::Default,
1181 local_name: None,
1182 is_type_only: false,
1183 visibility: VisibilityTag::None,
1184 span: oxc_span::Span::new(0, 20),
1185 members: vec![],
1186 is_side_effect_used: false,
1187 super_class: None,
1188 }],
1189 ..Default::default()
1190 },
1191 ];
1192
1193 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1194 let utils = &graph.modules[1];
1195 let default_export = utils
1196 .exports
1197 .iter()
1198 .find(|e| matches!(e.name, ExportName::Default))
1199 .unwrap();
1200 assert!(!default_export.references.is_empty());
1201 assert_eq!(
1202 default_export.references[0].kind,
1203 ReferenceKind::DefaultImport
1204 );
1205 }
1206
1207 #[test]
1208 fn graph_side_effect_import_no_export_reference() {
1209 let files = vec![
1210 DiscoveredFile {
1211 id: FileId(0),
1212 path: PathBuf::from("/project/entry.ts"),
1213 size_bytes: 100,
1214 },
1215 DiscoveredFile {
1216 id: FileId(1),
1217 path: PathBuf::from("/project/styles.ts"),
1218 size_bytes: 50,
1219 },
1220 ];
1221 let entry_points = vec![EntryPoint {
1222 path: PathBuf::from("/project/entry.ts"),
1223 source: EntryPointSource::PackageJsonMain,
1224 }];
1225 let resolved_modules = vec![
1226 ResolvedModule {
1227 file_id: FileId(0),
1228 path: PathBuf::from("/project/entry.ts"),
1229 resolved_imports: vec![ResolvedImport {
1230 info: ImportInfo {
1231 source: "./styles".to_string(),
1232 imported_name: ImportedName::SideEffect,
1233 local_name: String::new(),
1234 is_type_only: false,
1235 from_style: false,
1236 span: oxc_span::Span::new(0, 10),
1237 source_span: oxc_span::Span::default(),
1238 },
1239 target: ResolveResult::InternalModule(FileId(1)),
1240 }],
1241 ..Default::default()
1242 },
1243 ResolvedModule {
1244 file_id: FileId(1),
1245 path: PathBuf::from("/project/styles.ts"),
1246 exports: vec![fallow_types::extract::ExportInfo {
1247 name: ExportName::Named("primaryColor".to_string()),
1248 local_name: Some("primaryColor".to_string()),
1249 is_type_only: false,
1250 visibility: VisibilityTag::None,
1251 span: oxc_span::Span::new(0, 20),
1252 members: vec![],
1253 is_side_effect_used: false,
1254 super_class: None,
1255 }],
1256 ..Default::default()
1257 },
1258 ];
1259
1260 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1261 assert_eq!(graph.edge_count(), 1);
1262 let styles = &graph.modules[1];
1263 let export = &styles.exports[0];
1264 assert!(
1265 export.references.is_empty(),
1266 "side-effect import should not reference named exports"
1267 );
1268 }
1269
1270 #[test]
1271 fn graph_multiple_entry_points() {
1272 let files = vec![
1273 DiscoveredFile {
1274 id: FileId(0),
1275 path: PathBuf::from("/project/main.ts"),
1276 size_bytes: 100,
1277 },
1278 DiscoveredFile {
1279 id: FileId(1),
1280 path: PathBuf::from("/project/worker.ts"),
1281 size_bytes: 100,
1282 },
1283 DiscoveredFile {
1284 id: FileId(2),
1285 path: PathBuf::from("/project/shared.ts"),
1286 size_bytes: 50,
1287 },
1288 ];
1289 let entry_points = vec![
1290 EntryPoint {
1291 path: PathBuf::from("/project/main.ts"),
1292 source: EntryPointSource::PackageJsonMain,
1293 },
1294 EntryPoint {
1295 path: PathBuf::from("/project/worker.ts"),
1296 source: EntryPointSource::PackageJsonMain,
1297 },
1298 ];
1299 let resolved_modules = vec![
1300 ResolvedModule {
1301 file_id: FileId(0),
1302 path: PathBuf::from("/project/main.ts"),
1303 resolved_imports: vec![ResolvedImport {
1304 info: ImportInfo {
1305 source: "./shared".to_string(),
1306 imported_name: ImportedName::Named("helper".to_string()),
1307 local_name: "helper".to_string(),
1308 is_type_only: false,
1309 from_style: false,
1310 span: oxc_span::Span::new(0, 10),
1311 source_span: oxc_span::Span::default(),
1312 },
1313 target: ResolveResult::InternalModule(FileId(2)),
1314 }],
1315 ..Default::default()
1316 },
1317 ResolvedModule {
1318 file_id: FileId(1),
1319 path: PathBuf::from("/project/worker.ts"),
1320 ..Default::default()
1321 },
1322 ResolvedModule {
1323 file_id: FileId(2),
1324 path: PathBuf::from("/project/shared.ts"),
1325 exports: vec![fallow_types::extract::ExportInfo {
1326 name: ExportName::Named("helper".to_string()),
1327 local_name: Some("helper".to_string()),
1328 is_type_only: false,
1329 visibility: VisibilityTag::None,
1330 span: oxc_span::Span::new(0, 20),
1331 members: vec![],
1332 is_side_effect_used: false,
1333 super_class: None,
1334 }],
1335 ..Default::default()
1336 },
1337 ];
1338
1339 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1340 assert!(graph.modules[0].is_entry_point());
1341 assert!(graph.modules[1].is_entry_point());
1342 assert!(!graph.modules[2].is_entry_point());
1343 assert!(graph.modules[0].is_reachable());
1344 assert!(graph.modules[1].is_reachable());
1345 assert!(graph.modules[2].is_reachable());
1346 }
1347}