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