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 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}
73
74#[derive(Debug)]
76pub(super) struct Edge {
77 pub(super) source: FileId,
78 pub(super) target: FileId,
79 pub(super) symbols: Vec<ImportedSymbol>,
80}
81
82#[derive(Debug)]
84pub(super) struct ImportedSymbol {
85 pub(super) imported_name: ImportedName,
86 pub(super) local_name: String,
87 pub(super) import_span: oxc_span::Span,
89 pub(super) is_type_only: bool,
92}
93
94#[cfg(target_pointer_width = "64")]
98const _: () = assert!(std::mem::size_of::<Edge>() == 32);
99#[cfg(target_pointer_width = "64")]
100const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
101
102impl ModuleGraph {
103 fn resolve_entry_point_ids(
104 entry_points: &[EntryPoint],
105 path_to_id: &FxHashMap<&Path, FileId>,
106 ) -> FxHashSet<FileId> {
107 entry_points
108 .iter()
109 .filter_map(|ep| {
110 path_to_id.get(ep.path.as_path()).copied().or_else(|| {
111 dunce::canonicalize(&ep.path)
112 .ok()
113 .and_then(|path| path_to_id.get(path.as_path()).copied())
114 })
115 })
116 .collect()
117 }
118
119 pub fn build(
121 resolved_modules: &[ResolvedModule],
122 entry_points: &[EntryPoint],
123 files: &[DiscoveredFile],
124 ) -> Self {
125 Self::build_with_reachability_roots(
126 resolved_modules,
127 entry_points,
128 entry_points,
129 &[],
130 files,
131 )
132 }
133
134 pub fn build_with_reachability_roots(
136 resolved_modules: &[ResolvedModule],
137 entry_points: &[EntryPoint],
138 runtime_entry_points: &[EntryPoint],
139 test_entry_points: &[EntryPoint],
140 files: &[DiscoveredFile],
141 ) -> Self {
142 let _span = tracing::info_span!("build_graph").entered();
143
144 let module_count = files.len();
145
146 let max_file_id = files
149 .iter()
150 .map(|f| f.id.0 as usize)
151 .max()
152 .map_or(0, |m| m + 1);
153 let total_capacity = max_file_id.max(module_count);
154
155 let path_to_id: FxHashMap<&Path, FileId> =
157 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
158
159 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
161 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
162
163 let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
165 let runtime_entry_point_ids =
166 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
167 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
168
169 for file in files {
178 if is_declaration_file_path(&file.path) {
179 entry_point_ids.insert(file.id);
180 }
181 }
182
183 let mut graph = Self::populate_edges(
185 files,
186 &module_by_id,
187 &entry_point_ids,
188 &runtime_entry_point_ids,
189 &test_entry_point_ids,
190 module_count,
191 total_capacity,
192 );
193
194 graph.populate_references(&module_by_id, &entry_point_ids);
196
197 namespace_aliases::propagate_cross_package_aliases(&mut graph, &module_by_id);
202
203 namespace_re_exports::propagate_namespace_re_exports(&mut graph, &module_by_id);
209
210 graph.mark_reachable(
212 &entry_point_ids,
213 &runtime_entry_point_ids,
214 &test_entry_point_ids,
215 total_capacity,
216 );
217
218 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]
261 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
262 let idx = source.0 as usize;
263 if idx >= self.modules.len() {
264 return None;
265 }
266 let range = &self.modules[idx].edge_range;
267 for edge in &self.edges[range.clone()] {
268 if edge.target == target {
269 return edge.symbols.first().map(|s| s.import_span.start);
270 }
271 }
272 None
273 }
274
275 pub fn outgoing_edge_summaries(
294 &self,
295 file_id: FileId,
296 ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
297 let idx = file_id.0 as usize;
298 let range = if idx < self.modules.len() {
299 self.modules[idx].edge_range.clone()
300 } else {
301 0..0
302 };
303 self.edges[range].iter().map(|edge| {
304 let all_type_only =
305 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
306 let span = edge
307 .symbols
308 .iter()
309 .find(|s| !s.is_type_only)
310 .or_else(|| edge.symbols.first())
311 .map(|s| s.import_span.start);
312 (edge.target, all_type_only, span)
313 })
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
321 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
322 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
323 use std::path::PathBuf;
324
325 fn build_simple_graph() -> ModuleGraph {
327 let files = vec![
329 DiscoveredFile {
330 id: FileId(0),
331 path: PathBuf::from("/project/src/entry.ts"),
332 size_bytes: 100,
333 },
334 DiscoveredFile {
335 id: FileId(1),
336 path: PathBuf::from("/project/src/utils.ts"),
337 size_bytes: 50,
338 },
339 ];
340
341 let entry_points = vec![EntryPoint {
342 path: PathBuf::from("/project/src/entry.ts"),
343 source: EntryPointSource::PackageJsonMain,
344 }];
345
346 let resolved_modules = vec![
347 ResolvedModule {
348 file_id: FileId(0),
349 path: PathBuf::from("/project/src/entry.ts"),
350 resolved_imports: vec![ResolvedImport {
351 info: ImportInfo {
352 source: "./utils".to_string(),
353 imported_name: ImportedName::Named("foo".to_string()),
354 local_name: "foo".to_string(),
355 is_type_only: false,
356 from_style: false,
357 span: oxc_span::Span::new(0, 10),
358 source_span: oxc_span::Span::default(),
359 },
360 target: ResolveResult::InternalModule(FileId(1)),
361 }],
362 ..Default::default()
363 },
364 ResolvedModule {
365 file_id: FileId(1),
366 path: PathBuf::from("/project/src/utils.ts"),
367 exports: vec![
368 fallow_types::extract::ExportInfo {
369 name: ExportName::Named("foo".to_string()),
370 local_name: Some("foo".to_string()),
371 is_type_only: false,
372 visibility: VisibilityTag::None,
373 span: oxc_span::Span::new(0, 20),
374 members: vec![],
375 is_side_effect_used: false,
376 super_class: None,
377 },
378 fallow_types::extract::ExportInfo {
379 name: ExportName::Named("bar".to_string()),
380 local_name: Some("bar".to_string()),
381 is_type_only: false,
382 visibility: VisibilityTag::None,
383 span: oxc_span::Span::new(25, 45),
384 members: vec![],
385 is_side_effect_used: false,
386 super_class: None,
387 },
388 ],
389 ..Default::default()
390 },
391 ];
392
393 ModuleGraph::build(&resolved_modules, &entry_points, &files)
394 }
395
396 #[test]
397 fn graph_module_count() {
398 let graph = build_simple_graph();
399 assert_eq!(graph.module_count(), 2);
400 }
401
402 #[test]
403 fn graph_edge_count() {
404 let graph = build_simple_graph();
405 assert_eq!(graph.edge_count(), 1);
406 }
407
408 #[test]
409 fn graph_entry_point_is_reachable() {
410 let graph = build_simple_graph();
411 assert!(graph.modules[0].is_entry_point());
412 assert!(graph.modules[0].is_reachable());
413 }
414
415 #[test]
416 fn graph_imported_module_is_reachable() {
417 let graph = build_simple_graph();
418 assert!(!graph.modules[1].is_entry_point());
419 assert!(graph.modules[1].is_reachable());
420 }
421
422 #[test]
423 #[expect(
424 clippy::too_many_lines,
425 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
426 would obscure the cross-role assertions"
427 )]
428 fn graph_distinguishes_runtime_test_and_support_reachability() {
429 let files = vec![
430 DiscoveredFile {
431 id: FileId(0),
432 path: PathBuf::from("/project/src/main.ts"),
433 size_bytes: 100,
434 },
435 DiscoveredFile {
436 id: FileId(1),
437 path: PathBuf::from("/project/src/runtime-only.ts"),
438 size_bytes: 50,
439 },
440 DiscoveredFile {
441 id: FileId(2),
442 path: PathBuf::from("/project/tests/app.test.ts"),
443 size_bytes: 50,
444 },
445 DiscoveredFile {
446 id: FileId(3),
447 path: PathBuf::from("/project/tests/setup.ts"),
448 size_bytes: 50,
449 },
450 DiscoveredFile {
451 id: FileId(4),
452 path: PathBuf::from("/project/src/covered.ts"),
453 size_bytes: 50,
454 },
455 ];
456
457 let all_entry_points = vec![
458 EntryPoint {
459 path: PathBuf::from("/project/src/main.ts"),
460 source: EntryPointSource::PackageJsonMain,
461 },
462 EntryPoint {
463 path: PathBuf::from("/project/tests/app.test.ts"),
464 source: EntryPointSource::TestFile,
465 },
466 EntryPoint {
467 path: PathBuf::from("/project/tests/setup.ts"),
468 source: EntryPointSource::Plugin {
469 name: "vitest".to_string(),
470 },
471 },
472 ];
473 let runtime_entry_points = vec![EntryPoint {
474 path: PathBuf::from("/project/src/main.ts"),
475 source: EntryPointSource::PackageJsonMain,
476 }];
477 let test_entry_points = vec![EntryPoint {
478 path: PathBuf::from("/project/tests/app.test.ts"),
479 source: EntryPointSource::TestFile,
480 }];
481
482 let resolved_modules = vec![
483 ResolvedModule {
484 file_id: FileId(0),
485 path: PathBuf::from("/project/src/main.ts"),
486 resolved_imports: vec![ResolvedImport {
487 info: ImportInfo {
488 source: "./runtime-only".to_string(),
489 imported_name: ImportedName::Named("runtimeOnly".to_string()),
490 local_name: "runtimeOnly".to_string(),
491 is_type_only: false,
492 from_style: false,
493 span: oxc_span::Span::new(0, 10),
494 source_span: oxc_span::Span::default(),
495 },
496 target: ResolveResult::InternalModule(FileId(1)),
497 }],
498 ..Default::default()
499 },
500 ResolvedModule {
501 file_id: FileId(1),
502 path: PathBuf::from("/project/src/runtime-only.ts"),
503 exports: vec![fallow_types::extract::ExportInfo {
504 name: ExportName::Named("runtimeOnly".to_string()),
505 local_name: Some("runtimeOnly".to_string()),
506 is_type_only: false,
507 visibility: VisibilityTag::None,
508 span: oxc_span::Span::new(0, 20),
509 members: vec![],
510 is_side_effect_used: false,
511 super_class: None,
512 }],
513 ..Default::default()
514 },
515 ResolvedModule {
516 file_id: FileId(2),
517 path: PathBuf::from("/project/tests/app.test.ts"),
518 resolved_imports: vec![ResolvedImport {
519 info: ImportInfo {
520 source: "../src/covered".to_string(),
521 imported_name: ImportedName::Named("covered".to_string()),
522 local_name: "covered".to_string(),
523 is_type_only: false,
524 from_style: false,
525 span: oxc_span::Span::new(0, 10),
526 source_span: oxc_span::Span::default(),
527 },
528 target: ResolveResult::InternalModule(FileId(4)),
529 }],
530 ..Default::default()
531 },
532 ResolvedModule {
533 file_id: FileId(3),
534 path: PathBuf::from("/project/tests/setup.ts"),
535 resolved_imports: vec![ResolvedImport {
536 info: ImportInfo {
537 source: "../src/runtime-only".to_string(),
538 imported_name: ImportedName::Named("runtimeOnly".to_string()),
539 local_name: "runtimeOnly".to_string(),
540 is_type_only: false,
541 from_style: false,
542 span: oxc_span::Span::new(0, 10),
543 source_span: oxc_span::Span::default(),
544 },
545 target: ResolveResult::InternalModule(FileId(1)),
546 }],
547 ..Default::default()
548 },
549 ResolvedModule {
550 file_id: FileId(4),
551 path: PathBuf::from("/project/src/covered.ts"),
552 exports: vec![fallow_types::extract::ExportInfo {
553 name: ExportName::Named("covered".to_string()),
554 local_name: Some("covered".to_string()),
555 is_type_only: false,
556 visibility: VisibilityTag::None,
557 span: oxc_span::Span::new(0, 20),
558 members: vec![],
559 is_side_effect_used: false,
560 super_class: None,
561 }],
562 ..Default::default()
563 },
564 ];
565
566 let graph = ModuleGraph::build_with_reachability_roots(
567 &resolved_modules,
568 &all_entry_points,
569 &runtime_entry_points,
570 &test_entry_points,
571 &files,
572 );
573
574 assert!(graph.modules[1].is_reachable());
575 assert!(graph.modules[1].is_runtime_reachable());
576 assert!(
577 !graph.modules[1].is_test_reachable(),
578 "support roots should not make runtime-only modules test reachable"
579 );
580
581 assert!(graph.modules[4].is_reachable());
582 assert!(graph.modules[4].is_test_reachable());
583 assert!(
584 !graph.modules[4].is_runtime_reachable(),
585 "test-only reachability should stay separate from runtime roots"
586 );
587 }
588
589 #[test]
590 fn graph_export_has_reference() {
591 let graph = build_simple_graph();
592 let utils = &graph.modules[1];
593 let foo_export = utils
594 .exports
595 .iter()
596 .find(|e| e.name.to_string() == "foo")
597 .unwrap();
598 assert!(
599 !foo_export.references.is_empty(),
600 "foo should have references"
601 );
602 }
603
604 #[test]
605 fn graph_unused_export_no_reference() {
606 let graph = build_simple_graph();
607 let utils = &graph.modules[1];
608 let bar_export = utils
609 .exports
610 .iter()
611 .find(|e| e.name.to_string() == "bar")
612 .unwrap();
613 assert!(
614 bar_export.references.is_empty(),
615 "bar should have no references"
616 );
617 }
618
619 #[test]
620 fn graph_no_namespace_import() {
621 let graph = build_simple_graph();
622 assert!(!graph.has_namespace_import(FileId(0)));
623 assert!(!graph.has_namespace_import(FileId(1)));
624 }
625
626 #[test]
627 fn graph_has_namespace_import() {
628 let files = vec![
629 DiscoveredFile {
630 id: FileId(0),
631 path: PathBuf::from("/project/entry.ts"),
632 size_bytes: 100,
633 },
634 DiscoveredFile {
635 id: FileId(1),
636 path: PathBuf::from("/project/utils.ts"),
637 size_bytes: 50,
638 },
639 ];
640
641 let entry_points = vec![EntryPoint {
642 path: PathBuf::from("/project/entry.ts"),
643 source: EntryPointSource::PackageJsonMain,
644 }];
645
646 let resolved_modules = vec![
647 ResolvedModule {
648 file_id: FileId(0),
649 path: PathBuf::from("/project/entry.ts"),
650 resolved_imports: vec![ResolvedImport {
651 info: ImportInfo {
652 source: "./utils".to_string(),
653 imported_name: ImportedName::Namespace,
654 local_name: "utils".to_string(),
655 is_type_only: false,
656 from_style: false,
657 span: oxc_span::Span::new(0, 10),
658 source_span: oxc_span::Span::default(),
659 },
660 target: ResolveResult::InternalModule(FileId(1)),
661 }],
662 ..Default::default()
663 },
664 ResolvedModule {
665 file_id: FileId(1),
666 path: PathBuf::from("/project/utils.ts"),
667 exports: vec![fallow_types::extract::ExportInfo {
668 name: ExportName::Named("foo".to_string()),
669 local_name: Some("foo".to_string()),
670 is_type_only: false,
671 visibility: VisibilityTag::None,
672 span: oxc_span::Span::new(0, 20),
673 members: vec![],
674 is_side_effect_used: false,
675 super_class: None,
676 }],
677 ..Default::default()
678 },
679 ];
680
681 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
682 assert!(
683 graph.has_namespace_import(FileId(1)),
684 "utils should have namespace import"
685 );
686 }
687
688 #[test]
689 fn graph_has_namespace_import_out_of_bounds() {
690 let graph = build_simple_graph();
691 assert!(!graph.has_namespace_import(FileId(999)));
692 }
693
694 #[test]
695 fn graph_unreachable_module() {
696 let files = vec![
698 DiscoveredFile {
699 id: FileId(0),
700 path: PathBuf::from("/project/entry.ts"),
701 size_bytes: 100,
702 },
703 DiscoveredFile {
704 id: FileId(1),
705 path: PathBuf::from("/project/utils.ts"),
706 size_bytes: 50,
707 },
708 DiscoveredFile {
709 id: FileId(2),
710 path: PathBuf::from("/project/orphan.ts"),
711 size_bytes: 30,
712 },
713 ];
714
715 let entry_points = vec![EntryPoint {
716 path: PathBuf::from("/project/entry.ts"),
717 source: EntryPointSource::PackageJsonMain,
718 }];
719
720 let resolved_modules = vec![
721 ResolvedModule {
722 file_id: FileId(0),
723 path: PathBuf::from("/project/entry.ts"),
724 resolved_imports: vec![ResolvedImport {
725 info: ImportInfo {
726 source: "./utils".to_string(),
727 imported_name: ImportedName::Named("foo".to_string()),
728 local_name: "foo".to_string(),
729 is_type_only: false,
730 from_style: false,
731 span: oxc_span::Span::new(0, 10),
732 source_span: oxc_span::Span::default(),
733 },
734 target: ResolveResult::InternalModule(FileId(1)),
735 }],
736 ..Default::default()
737 },
738 ResolvedModule {
739 file_id: FileId(1),
740 path: PathBuf::from("/project/utils.ts"),
741 exports: vec![fallow_types::extract::ExportInfo {
742 name: ExportName::Named("foo".to_string()),
743 local_name: Some("foo".to_string()),
744 is_type_only: false,
745 visibility: VisibilityTag::None,
746 span: oxc_span::Span::new(0, 20),
747 members: vec![],
748 is_side_effect_used: false,
749 super_class: None,
750 }],
751 ..Default::default()
752 },
753 ResolvedModule {
754 file_id: FileId(2),
755 path: PathBuf::from("/project/orphan.ts"),
756 exports: vec![fallow_types::extract::ExportInfo {
757 name: ExportName::Named("orphan".to_string()),
758 local_name: Some("orphan".to_string()),
759 is_type_only: false,
760 visibility: VisibilityTag::None,
761 span: oxc_span::Span::new(0, 20),
762 members: vec![],
763 is_side_effect_used: false,
764 super_class: None,
765 }],
766 ..Default::default()
767 },
768 ];
769
770 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
771
772 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
773 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
774 assert!(
775 !graph.modules[2].is_reachable(),
776 "orphan should NOT be reachable"
777 );
778 }
779
780 #[test]
781 fn graph_package_usage_tracked() {
782 let files = vec![DiscoveredFile {
783 id: FileId(0),
784 path: PathBuf::from("/project/entry.ts"),
785 size_bytes: 100,
786 }];
787
788 let entry_points = vec![EntryPoint {
789 path: PathBuf::from("/project/entry.ts"),
790 source: EntryPointSource::PackageJsonMain,
791 }];
792
793 let resolved_modules = vec![ResolvedModule {
794 file_id: FileId(0),
795 path: PathBuf::from("/project/entry.ts"),
796 exports: vec![],
797 re_exports: vec![],
798 resolved_imports: vec![
799 ResolvedImport {
800 info: ImportInfo {
801 source: "react".to_string(),
802 imported_name: ImportedName::Default,
803 local_name: "React".to_string(),
804 is_type_only: false,
805 from_style: false,
806 span: oxc_span::Span::new(0, 10),
807 source_span: oxc_span::Span::default(),
808 },
809 target: ResolveResult::NpmPackage("react".to_string()),
810 },
811 ResolvedImport {
812 info: ImportInfo {
813 source: "lodash".to_string(),
814 imported_name: ImportedName::Named("merge".to_string()),
815 local_name: "merge".to_string(),
816 is_type_only: false,
817 from_style: false,
818 span: oxc_span::Span::new(15, 30),
819 source_span: oxc_span::Span::default(),
820 },
821 target: ResolveResult::NpmPackage("lodash".to_string()),
822 },
823 ],
824 ..Default::default()
825 }];
826
827 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
828 assert!(graph.package_usage.contains_key("react"));
829 assert!(graph.package_usage.contains_key("lodash"));
830 assert!(!graph.package_usage.contains_key("express"));
831 }
832
833 #[test]
834 fn graph_empty() {
835 let graph = ModuleGraph::build(&[], &[], &[]);
836 assert_eq!(graph.module_count(), 0);
837 assert_eq!(graph.edge_count(), 0);
838 }
839
840 #[test]
841 fn graph_cjs_exports_tracked() {
842 let files = vec![DiscoveredFile {
843 id: FileId(0),
844 path: PathBuf::from("/project/entry.ts"),
845 size_bytes: 100,
846 }];
847
848 let entry_points = vec![EntryPoint {
849 path: PathBuf::from("/project/entry.ts"),
850 source: EntryPointSource::PackageJsonMain,
851 }];
852
853 let resolved_modules = vec![ResolvedModule {
854 file_id: FileId(0),
855 path: PathBuf::from("/project/entry.ts"),
856 has_cjs_exports: true,
857 has_angular_component_template_url: false,
858 ..Default::default()
859 }];
860
861 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
862 assert!(graph.modules[0].has_cjs_exports());
863 }
864
865 #[test]
866 fn graph_edges_for_returns_targets() {
867 let graph = build_simple_graph();
868 let targets = graph.edges_for(FileId(0));
869 assert_eq!(targets, vec![FileId(1)]);
870 }
871
872 #[test]
873 fn graph_edges_for_no_imports() {
874 let graph = build_simple_graph();
875 let targets = graph.edges_for(FileId(1));
877 assert!(targets.is_empty());
878 }
879
880 #[test]
881 fn graph_edges_for_out_of_bounds() {
882 let graph = build_simple_graph();
883 let targets = graph.edges_for(FileId(999));
884 assert!(targets.is_empty());
885 }
886
887 #[test]
888 fn graph_find_import_span_start_found() {
889 let graph = build_simple_graph();
890 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
891 assert!(span_start.is_some());
892 assert_eq!(span_start.unwrap(), 0);
893 }
894
895 #[test]
896 fn graph_find_import_span_start_wrong_target() {
897 let graph = build_simple_graph();
898 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
900 assert!(span_start.is_none());
901 }
902
903 #[test]
904 fn graph_find_import_span_start_source_out_of_bounds() {
905 let graph = build_simple_graph();
906 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
907 assert!(span_start.is_none());
908 }
909
910 #[test]
911 fn graph_find_import_span_start_no_edges() {
912 let graph = build_simple_graph();
913 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
915 assert!(span_start.is_none());
916 }
917
918 #[test]
919 fn graph_reverse_deps_populated() {
920 let graph = build_simple_graph();
921 assert!(graph.reverse_deps[1].contains(&FileId(0)));
923 assert!(graph.reverse_deps[0].is_empty());
925 }
926
927 #[test]
928 fn graph_type_only_package_usage_tracked() {
929 let files = vec![DiscoveredFile {
930 id: FileId(0),
931 path: PathBuf::from("/project/entry.ts"),
932 size_bytes: 100,
933 }];
934 let entry_points = vec![EntryPoint {
935 path: PathBuf::from("/project/entry.ts"),
936 source: EntryPointSource::PackageJsonMain,
937 }];
938 let resolved_modules = vec![ResolvedModule {
939 file_id: FileId(0),
940 path: PathBuf::from("/project/entry.ts"),
941 resolved_imports: vec![
942 ResolvedImport {
943 info: ImportInfo {
944 source: "react".to_string(),
945 imported_name: ImportedName::Named("FC".to_string()),
946 local_name: "FC".to_string(),
947 is_type_only: true,
948 from_style: false,
949 span: oxc_span::Span::new(0, 10),
950 source_span: oxc_span::Span::default(),
951 },
952 target: ResolveResult::NpmPackage("react".to_string()),
953 },
954 ResolvedImport {
955 info: ImportInfo {
956 source: "react".to_string(),
957 imported_name: ImportedName::Named("useState".to_string()),
958 local_name: "useState".to_string(),
959 is_type_only: false,
960 from_style: false,
961 span: oxc_span::Span::new(15, 30),
962 source_span: oxc_span::Span::default(),
963 },
964 target: ResolveResult::NpmPackage("react".to_string()),
965 },
966 ],
967 ..Default::default()
968 }];
969
970 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
971 assert!(graph.package_usage.contains_key("react"));
972 assert!(graph.type_only_package_usage.contains_key("react"));
973 }
974
975 #[test]
976 fn graph_default_import_reference() {
977 let files = vec![
978 DiscoveredFile {
979 id: FileId(0),
980 path: PathBuf::from("/project/entry.ts"),
981 size_bytes: 100,
982 },
983 DiscoveredFile {
984 id: FileId(1),
985 path: PathBuf::from("/project/utils.ts"),
986 size_bytes: 50,
987 },
988 ];
989 let entry_points = vec![EntryPoint {
990 path: PathBuf::from("/project/entry.ts"),
991 source: EntryPointSource::PackageJsonMain,
992 }];
993 let resolved_modules = vec![
994 ResolvedModule {
995 file_id: FileId(0),
996 path: PathBuf::from("/project/entry.ts"),
997 resolved_imports: vec![ResolvedImport {
998 info: ImportInfo {
999 source: "./utils".to_string(),
1000 imported_name: ImportedName::Default,
1001 local_name: "Utils".to_string(),
1002 is_type_only: false,
1003 from_style: false,
1004 span: oxc_span::Span::new(0, 10),
1005 source_span: oxc_span::Span::default(),
1006 },
1007 target: ResolveResult::InternalModule(FileId(1)),
1008 }],
1009 ..Default::default()
1010 },
1011 ResolvedModule {
1012 file_id: FileId(1),
1013 path: PathBuf::from("/project/utils.ts"),
1014 exports: vec![fallow_types::extract::ExportInfo {
1015 name: ExportName::Default,
1016 local_name: None,
1017 is_type_only: false,
1018 visibility: VisibilityTag::None,
1019 span: oxc_span::Span::new(0, 20),
1020 members: vec![],
1021 is_side_effect_used: false,
1022 super_class: None,
1023 }],
1024 ..Default::default()
1025 },
1026 ];
1027
1028 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1029 let utils = &graph.modules[1];
1030 let default_export = utils
1031 .exports
1032 .iter()
1033 .find(|e| matches!(e.name, ExportName::Default))
1034 .unwrap();
1035 assert!(!default_export.references.is_empty());
1036 assert_eq!(
1037 default_export.references[0].kind,
1038 ReferenceKind::DefaultImport
1039 );
1040 }
1041
1042 #[test]
1043 fn graph_side_effect_import_no_export_reference() {
1044 let files = vec![
1045 DiscoveredFile {
1046 id: FileId(0),
1047 path: PathBuf::from("/project/entry.ts"),
1048 size_bytes: 100,
1049 },
1050 DiscoveredFile {
1051 id: FileId(1),
1052 path: PathBuf::from("/project/styles.ts"),
1053 size_bytes: 50,
1054 },
1055 ];
1056 let entry_points = vec![EntryPoint {
1057 path: PathBuf::from("/project/entry.ts"),
1058 source: EntryPointSource::PackageJsonMain,
1059 }];
1060 let resolved_modules = vec![
1061 ResolvedModule {
1062 file_id: FileId(0),
1063 path: PathBuf::from("/project/entry.ts"),
1064 resolved_imports: vec![ResolvedImport {
1065 info: ImportInfo {
1066 source: "./styles".to_string(),
1067 imported_name: ImportedName::SideEffect,
1068 local_name: String::new(),
1069 is_type_only: false,
1070 from_style: false,
1071 span: oxc_span::Span::new(0, 10),
1072 source_span: oxc_span::Span::default(),
1073 },
1074 target: ResolveResult::InternalModule(FileId(1)),
1075 }],
1076 ..Default::default()
1077 },
1078 ResolvedModule {
1079 file_id: FileId(1),
1080 path: PathBuf::from("/project/styles.ts"),
1081 exports: vec![fallow_types::extract::ExportInfo {
1082 name: ExportName::Named("primaryColor".to_string()),
1083 local_name: Some("primaryColor".to_string()),
1084 is_type_only: false,
1085 visibility: VisibilityTag::None,
1086 span: oxc_span::Span::new(0, 20),
1087 members: vec![],
1088 is_side_effect_used: false,
1089 super_class: None,
1090 }],
1091 ..Default::default()
1092 },
1093 ];
1094
1095 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1096 assert_eq!(graph.edge_count(), 1);
1098 let styles = &graph.modules[1];
1099 let export = &styles.exports[0];
1100 assert!(
1102 export.references.is_empty(),
1103 "side-effect import should not reference named exports"
1104 );
1105 }
1106
1107 #[test]
1108 fn graph_multiple_entry_points() {
1109 let files = vec![
1110 DiscoveredFile {
1111 id: FileId(0),
1112 path: PathBuf::from("/project/main.ts"),
1113 size_bytes: 100,
1114 },
1115 DiscoveredFile {
1116 id: FileId(1),
1117 path: PathBuf::from("/project/worker.ts"),
1118 size_bytes: 100,
1119 },
1120 DiscoveredFile {
1121 id: FileId(2),
1122 path: PathBuf::from("/project/shared.ts"),
1123 size_bytes: 50,
1124 },
1125 ];
1126 let entry_points = vec![
1127 EntryPoint {
1128 path: PathBuf::from("/project/main.ts"),
1129 source: EntryPointSource::PackageJsonMain,
1130 },
1131 EntryPoint {
1132 path: PathBuf::from("/project/worker.ts"),
1133 source: EntryPointSource::PackageJsonMain,
1134 },
1135 ];
1136 let resolved_modules = vec![
1137 ResolvedModule {
1138 file_id: FileId(0),
1139 path: PathBuf::from("/project/main.ts"),
1140 resolved_imports: vec![ResolvedImport {
1141 info: ImportInfo {
1142 source: "./shared".to_string(),
1143 imported_name: ImportedName::Named("helper".to_string()),
1144 local_name: "helper".to_string(),
1145 is_type_only: false,
1146 from_style: false,
1147 span: oxc_span::Span::new(0, 10),
1148 source_span: oxc_span::Span::default(),
1149 },
1150 target: ResolveResult::InternalModule(FileId(2)),
1151 }],
1152 ..Default::default()
1153 },
1154 ResolvedModule {
1155 file_id: FileId(1),
1156 path: PathBuf::from("/project/worker.ts"),
1157 ..Default::default()
1158 },
1159 ResolvedModule {
1160 file_id: FileId(2),
1161 path: PathBuf::from("/project/shared.ts"),
1162 exports: vec![fallow_types::extract::ExportInfo {
1163 name: ExportName::Named("helper".to_string()),
1164 local_name: Some("helper".to_string()),
1165 is_type_only: false,
1166 visibility: VisibilityTag::None,
1167 span: oxc_span::Span::new(0, 20),
1168 members: vec![],
1169 is_side_effect_used: false,
1170 super_class: None,
1171 }],
1172 ..Default::default()
1173 },
1174 ];
1175
1176 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1177 assert!(graph.modules[0].is_entry_point());
1178 assert!(graph.modules[1].is_entry_point());
1179 assert!(!graph.modules[2].is_entry_point());
1180 assert!(graph.modules[0].is_reachable());
1182 assert!(graph.modules[1].is_reachable());
1183 assert!(graph.modules[2].is_reachable());
1184 }
1185}