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