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