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