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