1mod ambiguity;
7mod build;
8mod cycles;
9mod effective_exports;
10mod effective_re_exports;
11mod fan_io;
12mod impact_closure;
13mod namespace_aliases;
14mod namespace_indexes;
15mod namespace_re_exports;
16mod narrowing;
17mod partition_order;
18mod public_exports;
19mod re_exports;
20mod reachability;
21pub mod types;
22
23use std::path::Path;
24
25use fixedbitset::FixedBitSet;
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use crate::resolve::{ResolvedModule, ResolvedReplacedModuleTarget};
29use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
30use fallow_types::extract::{ImportedName, ModuleLoadMechanism};
31use types::{ReferencePathInterner, ReferencePathNode, ReferenceRouteNodeId, ReferenceRoutes};
32
33pub use ambiguity::{AmbiguityParticipants, AmbiguousStarExport};
34pub use effective_exports::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace};
35pub use effective_re_exports::EffectiveReExportRoute;
36pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
37pub use impact_closure::{
38 CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
39};
40pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
41pub use public_exports::PublicExportOrigin;
42pub use re_exports::GraphReExportCycle;
43pub use types::{
44 ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, ReferencePathId, SymbolReference,
45};
46
47#[derive(Debug, Clone, Copy)]
49pub struct EffectiveExportOrigin<'graph> {
50 file_id: FileId,
51 export: &'graph ExportSymbol,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct EffectiveExportSurface<'graph> {
57 binding: EffectiveExportBinding,
58 namespace: ExportNamespace,
59 export: Option<&'graph ExportSymbol>,
60 origin: Option<EffectiveExportOrigin<'graph>>,
61 local_export: bool,
62}
63
64impl<'graph> EffectiveExportSurface<'graph> {
65 #[must_use]
67 pub const fn binding(self) -> EffectiveExportBinding {
68 self.binding
69 }
70
71 #[must_use]
73 pub const fn namespace(self) -> ExportNamespace {
74 self.namespace
75 }
76
77 #[must_use]
83 pub const fn export(self) -> Option<&'graph ExportSymbol> {
84 self.export
85 }
86
87 #[must_use]
89 pub const fn origin(self) -> Option<EffectiveExportOrigin<'graph>> {
90 self.origin
91 }
92
93 #[must_use]
98 pub const fn has_local_export(self) -> bool {
99 self.local_export
100 }
101}
102
103impl<'graph> EffectiveExportOrigin<'graph> {
104 #[must_use]
106 pub const fn file_id(self) -> FileId {
107 self.file_id
108 }
109
110 #[must_use]
112 pub const fn export(self) -> &'graph ExportSymbol {
113 self.export
114 }
115}
116
117fn is_declaration_file_path(path: &Path) -> bool {
124 path.file_name()
125 .and_then(|n| n.to_str())
126 .is_some_and(|name| {
127 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
128 })
129}
130
131#[derive(Debug, serde::Serialize, serde::Deserialize)]
139pub struct ModuleGraph {
140 pub modules: Vec<ModuleNode>,
150 edges: Vec<Edge>,
152 pub package_usage: FxHashMap<String, Vec<FileId>>,
154 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
158 pub entry_points: FxHashSet<FileId>,
160 pub runtime_entry_points: FxHashSet<FileId>,
162 pub test_entry_points: FxHashSet<FileId>,
164 test_reachability_index: TestReachabilityIndex,
169 reference_paths: Vec<ReferencePathNode>,
171 reference_routes: ReferenceRoutes,
173 pub reverse_deps: Vec<Vec<FileId>>,
175 #[serde(skip, default)]
183 namespace_imported: FixedBitSet,
184 pub re_export_cycles: Vec<GraphReExportCycle>,
191 effective_exports: effective_exports::EffectiveExportIndex,
193}
194
195#[derive(Debug, serde::Serialize, serde::Deserialize)]
202pub struct Edge {
203 source: FileId,
205 target: FileId,
207 symbols: Vec<ImportedSymbol>,
209}
210
211#[derive(Debug, serde::Serialize, serde::Deserialize)]
213pub struct ImportedSymbol {
214 pub imported_name: ImportedName,
217 pub local_name: String,
219 #[serde(with = "crate::cache::span_serde")]
221 pub import_span: oxc_span::Span,
222 pub is_type_only: bool,
225 mechanism: ModuleLoadMechanism,
227}
228
229#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
237struct TestReachabilityIndex {
238 profile_count: usize,
239 words_per_file: usize,
240 reachable_profiles: Vec<u64>,
241 masked_profiles: Vec<MaskedTestProfiles>,
242}
243
244#[derive(Debug, serde::Serialize, serde::Deserialize)]
246struct MaskedTestProfiles {
247 target: FileId,
248 profiles: Vec<u64>,
249}
250
251impl TestReachabilityIndex {
252 fn new(file_capacity: usize, profile_count: usize) -> Self {
253 let words_per_file = profile_count.div_ceil(u64::BITS as usize);
254 let storage_len = file_capacity.saturating_mul(words_per_file);
255 Self {
256 profile_count,
257 words_per_file,
258 reachable_profiles: vec![0; storage_len],
259 masked_profiles: Vec::new(),
260 }
261 }
262
263 fn set_sparse_masks(&mut self, masks: FxHashMap<FileId, Vec<u64>>) {
264 let mut rows: Vec<_> = masks
265 .into_iter()
266 .map(|(target, profiles)| MaskedTestProfiles { target, profiles })
267 .collect();
268 rows.sort_unstable_by_key(|row| row.target.0);
269 self.masked_profiles = rows;
270 }
271
272 fn profiles_for<'a>(&self, storage: &'a [u64], file_id: FileId) -> Option<&'a [u64]> {
273 let start = (file_id.0 as usize).checked_mul(self.words_per_file)?;
274 let end = start.checked_add(self.words_per_file)?;
275 storage.get(start..end)
276 }
277
278 fn masked_profiles_for(&self, file_id: FileId) -> Option<&[u64]> {
279 self.masked_profiles
280 .binary_search_by_key(&file_id.0, |row| row.target.0)
281 .ok()
282 .map(|index| self.masked_profiles[index].profiles.as_slice())
283 }
284
285 fn covers_reference_path(
286 &self,
287 source: FileId,
288 path: types::ReferencePathId,
289 paths: &[ReferencePathNode],
290 routes: &ReferenceRoutes,
291 ) -> bool {
292 let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
293 return false;
294 };
295
296 for (word_index, &source_word) in source_profiles.iter().enumerate() {
297 let mut active_profiles = source_word;
298 if active_profiles == 0 {
299 continue;
300 }
301
302 let mut next = Some(path);
303 while let Some(path_id) = next {
304 let Some(path_node) = paths.get(path_id.index()) else {
305 return false;
306 };
307 next = path_node.parent();
308 active_profiles = match *path_node {
309 ReferencePathNode::Hop {
310 target, mechanism, ..
311 } => self.active_hop_profiles(target, mechanism, word_index, active_profiles),
312 ReferencePathNode::Route {
313 graph,
314 start,
315 terminal,
316 start_mechanism,
317 ..
318 } => self.active_route_profiles(
319 routes,
320 graph,
321 start,
322 terminal,
323 start_mechanism,
324 word_index,
325 active_profiles,
326 ),
327 };
328 if active_profiles == 0 {
329 break;
330 }
331 }
332
333 if active_profiles != 0 {
334 return true;
335 }
336 }
337
338 false
339 }
340
341 #[cfg(test)]
342 fn covers_path<I>(&self, source: FileId, hops: &I) -> bool
343 where
344 I: Iterator<Item = (FileId, ModuleLoadMechanism)> + Clone,
345 {
346 let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
347 return false;
348 };
349 for (word_index, &source_word) in source_profiles.iter().enumerate() {
350 let mut active_profiles = source_word;
351 for (target, mechanism) in (*hops).clone() {
352 active_profiles =
353 self.active_hop_profiles(target, mechanism, word_index, active_profiles);
354 if active_profiles == 0 {
355 break;
356 }
357 }
358 if active_profiles != 0 {
359 return true;
360 }
361 }
362 false
363 }
364
365 fn active_hop_profiles(
366 &self,
367 target: FileId,
368 mechanism: ModuleLoadMechanism,
369 word_index: usize,
370 mut active_profiles: u64,
371 ) -> u64 {
372 let Some(target_word) = self
373 .profiles_for(&self.reachable_profiles, target)
374 .and_then(|profiles| profiles.get(word_index))
375 else {
376 return 0;
377 };
378 active_profiles &= target_word;
379 if matches!(mechanism, ModuleLoadMechanism::EsModule)
380 && let Some(masked_profiles) = self.masked_profiles_for(target)
381 {
382 let Some(masked_word) = masked_profiles.get(word_index) else {
383 return 0;
384 };
385 active_profiles &= !masked_word;
386 }
387 active_profiles
388 }
389
390 #[expect(
394 clippy::too_many_arguments,
395 reason = "the route identity and profile word form one evaluation contract"
396 )]
397 fn active_route_profiles(
398 &self,
399 routes: &ReferenceRoutes,
400 graph_id: types::ReferenceRouteGraphId,
401 start: ReferenceRouteNodeId,
402 terminal: ReferenceRouteNodeId,
403 start_mechanism: Option<ModuleLoadMechanism>,
404 word_index: usize,
405 candidate_profiles: u64,
406 ) -> u64 {
407 let Some(graph) = routes.graphs.get(graph_id.0 as usize) else {
408 return 0;
409 };
410 let node_count = graph.nodes.end.saturating_sub(graph.nodes.start) as usize;
411 let start_index = start.0 as usize;
412 let terminal_index = terminal.0 as usize;
413 if start_index >= node_count || terminal_index >= node_count {
414 return 0;
415 }
416
417 let mut attempted = vec![0_u64; node_count];
418 let mut pending = vec![0_u64; node_count];
419 let mut queued = vec![false; node_count];
420 let mut queue = std::collections::VecDeque::from([start_index]);
421 pending[start_index] = candidate_profiles;
422 queued[start_index] = true;
423 let mut successful_profiles = 0_u64;
424
425 while let Some(local_index) = queue.pop_front() {
426 queued[local_index] = false;
427 let incoming = pending[local_index] & !attempted[local_index];
428 pending[local_index] = 0;
429 attempted[local_index] |= incoming;
430 if incoming == 0 {
431 continue;
432 }
433
434 let Some(node) = routes.nodes.get(graph.nodes.start as usize + local_index) else {
435 return 0;
436 };
437 let active = if local_index == start_index {
438 start_mechanism.map_or(incoming, |mechanism| {
439 self.active_hop_profiles(node.target, mechanism, word_index, incoming)
440 })
441 } else {
442 self.active_hop_profiles(node.target, node.mechanism, word_index, incoming)
443 };
444 if active == 0 {
445 continue;
446 }
447 if local_index == terminal_index {
448 successful_profiles |= active;
449 continue;
450 }
451
452 let Some(successors) = routes
453 .edges
454 .get(node.successors.start as usize..node.successors.end as usize)
455 else {
456 return 0;
457 };
458 for successor in successors {
459 let successor_index = successor.0 as usize;
460 if successor_index >= node_count {
461 return 0;
462 }
463 let new_profiles = active & !attempted[successor_index] & !pending[successor_index];
464 if new_profiles == 0 {
465 continue;
466 }
467 pending[successor_index] |= new_profiles;
468 if !queued[successor_index] {
469 queued[successor_index] = true;
470 queue.push_back(successor_index);
471 }
472 }
473 }
474
475 successful_profiles
476 }
477
478 #[cfg(test)]
479 fn profile_contains(&self, storage: &[u64], file_id: FileId, profile: usize) -> bool {
480 self.profiles_for(storage, file_id)
481 .and_then(|words| words.get(profile / u64::BITS as usize))
482 .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
483 }
484
485 #[cfg(test)]
486 fn profile_reaches(&self, file_id: FileId, profile: usize) -> bool {
487 self.profile_contains(&self.reachable_profiles, file_id, profile)
488 }
489
490 #[cfg(test)]
491 fn profile_masks(&self, file_id: FileId, profile: usize) -> bool {
492 self.masked_profiles_for(file_id)
493 .and_then(|words| words.get(profile / u64::BITS as usize))
494 .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
495 }
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct DirectImporterSummary {
501 pub source: FileId,
503 pub symbols: Vec<ImportedSymbolSummary>,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct ImportedSymbolSummary {
510 pub imported: String,
513 pub local: String,
515 pub type_only: bool,
517}
518
519#[cfg(target_pointer_width = "64")]
520const _: () = assert!(std::mem::size_of::<Edge>() == 32);
521#[cfg(target_pointer_width = "64")]
522const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
523
524#[cold]
525#[inline(never)]
526fn propagate_namespace_references(
527 graph: &mut ModuleGraph,
528 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
529 features: build::NamespaceFeatures,
530 reference_paths: &mut ReferencePathInterner,
531) {
532 let indexes = namespace_indexes::NamespacePropagationIndexes::new(graph, module_by_id);
533 if features.has_aliases {
534 namespace_aliases::propagate_cross_package_aliases(
535 graph,
536 module_by_id,
537 &indexes,
538 reference_paths,
539 );
540 }
541 if features.has_re_exports {
542 namespace_re_exports::propagate_namespace_re_exports(graph, &indexes, reference_paths);
543 }
544}
545
546impl ModuleGraph {
547 fn resolve_entry_point_ids(
548 entry_points: &[EntryPoint],
549 path_to_id: &FxHashMap<&Path, FileId>,
550 ) -> FxHashSet<FileId> {
551 entry_points
552 .iter()
553 .filter_map(|ep| {
554 path_to_id.get(ep.path.as_path()).copied().or_else(|| {
555 dunce::canonicalize(&ep.path)
556 .ok()
557 .and_then(|path| path_to_id.get(path.as_path()).copied())
558 })
559 })
560 .collect()
561 }
562
563 pub fn build(
565 resolved_modules: &[ResolvedModule],
566 entry_points: &[EntryPoint],
567 files: &[DiscoveredFile],
568 ) -> Self {
569 Self::build_with_reachability_roots(
570 resolved_modules,
571 entry_points,
572 entry_points,
573 &[],
574 files,
575 )
576 }
577
578 pub fn build_with_reachability_roots(
580 resolved_modules: &[ResolvedModule],
581 entry_points: &[EntryPoint],
582 runtime_entry_points: &[EntryPoint],
583 test_entry_points: &[EntryPoint],
584 files: &[DiscoveredFile],
585 ) -> Self {
586 Self::build_with_reachability_roots_and_replacements(
587 resolved_modules,
588 &[],
589 entry_points,
590 runtime_entry_points,
591 test_entry_points,
592 files,
593 )
594 }
595
596 pub fn build_with_reachability_roots_and_replacements(
598 resolved_modules: &[ResolvedModule],
599 replaced_module_targets: &[ResolvedReplacedModuleTarget],
600 entry_points: &[EntryPoint],
601 runtime_entry_points: &[EntryPoint],
602 test_entry_points: &[EntryPoint],
603 files: &[DiscoveredFile],
604 ) -> Self {
605 let _span = tracing::info_span!("build_graph").entered();
606
607 let module_count = files.len();
608
609 let max_file_id = files
610 .iter()
611 .map(|f| f.id.0 as usize)
612 .max()
613 .map_or(0, |m| m + 1);
614 let total_capacity = max_file_id.max(module_count);
615
616 let path_to_id: FxHashMap<&Path, FileId> =
617 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
618
619 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
620 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
621
622 let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
623 let runtime_entry_point_ids =
624 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
625 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
626
627 for file in files {
628 if is_declaration_file_path(&file.path) {
629 entry_point_ids.insert(file.id);
630 }
631 }
632
633 let (mut graph, namespace_features) = Self::populate_edges(&build::PopulateEdgesInput {
634 files,
635 module_by_id: &module_by_id,
636 entry_point_ids: &entry_point_ids,
637 runtime_entry_point_ids: &runtime_entry_point_ids,
638 test_entry_point_ids: &test_entry_point_ids,
639 module_count,
640 total_capacity,
641 });
642 graph.effective_exports = effective_exports::EffectiveExportIndex::build(resolved_modules);
643
644 let test_reachability_plan = reachability::TestReachabilityPlan::new(
645 &test_entry_point_ids,
646 replaced_module_targets,
647 total_capacity,
648 );
649
650 let mut reference_paths =
651 ReferencePathInterner::new(test_reachability_plan.requires_reference_provenance());
652 graph.populate_references(&module_by_id, &entry_point_ids, &mut reference_paths);
653
654 if namespace_features.has_aliases || namespace_features.has_re_exports {
655 propagate_namespace_references(
656 &mut graph,
657 &module_by_id,
658 namespace_features,
659 &mut reference_paths,
660 );
661 }
662
663 graph.mark_reachable(
664 &entry_point_ids,
665 &runtime_entry_point_ids,
666 test_reachability_plan,
667 total_capacity,
668 );
669
670 graph.re_export_cycles =
671 graph.resolve_re_export_chains(&module_by_id, &mut reference_paths);
672 let finalized_paths = reference_paths.finalize(&mut graph.modules);
673 graph.reference_paths = finalized_paths.paths;
674 graph.reference_routes = finalized_paths.routes;
675
676 graph
677 }
678
679 #[must_use]
681 pub const fn module_count(&self) -> usize {
682 self.modules.len()
683 }
684
685 #[must_use]
687 pub const fn edge_count(&self) -> usize {
688 self.edges.len()
689 }
690
691 #[must_use]
693 pub fn is_test_reachable(&self, file_id: FileId) -> bool {
694 self.modules
695 .get(file_id.0 as usize)
696 .is_some_and(ModuleNode::is_test_reachable)
697 }
698
699 #[must_use]
707 pub fn is_test_reference_covered(&self, export: &ExportSymbol, reference_index: usize) -> bool {
708 let Some(reference) = export.references.get(reference_index) else {
709 return false;
710 };
711 if self.test_reachability_index.profile_count == 0 {
712 return self.is_test_reachable(reference.from_file);
713 }
714
715 let Some(path) = export.reference_path(reference_index) else {
716 return false;
717 };
718
719 self.test_reachability_index.covers_reference_path(
720 reference.from_file,
721 path,
722 &self.reference_paths,
723 &self.reference_routes,
724 )
725 }
726
727 #[must_use]
730 pub fn is_any_test_reference_covered(&self, export: &ExportSymbol) -> bool {
731 (0..export.references.len())
732 .any(|reference_index| self.is_test_reference_covered(export, reference_index))
733 }
734
735 #[cfg(test)]
736 fn reference_path_hops(
737 &self,
738 export: &ExportSymbol,
739 reference_index: usize,
740 ) -> Vec<(FileId, ModuleLoadMechanism)> {
741 let mut hops = Vec::new();
742 let mut next = export.reference_path(reference_index);
743 while let Some(path_id) = next {
744 let Some(node) = self.reference_paths.get(path_id.index()) else {
745 return Vec::new();
746 };
747 next = node.parent();
748 match *node {
749 ReferencePathNode::Hop {
750 target, mechanism, ..
751 } => hops.push((target, mechanism)),
752 ReferencePathNode::Route {
753 graph,
754 start,
755 terminal,
756 start_mechanism,
757 ..
758 } => hops.extend(self.reference_routes.canonical_hops(
759 graph,
760 start,
761 terminal,
762 start_mechanism,
763 )),
764 }
765 }
766 hops
767 }
768
769 pub(crate) fn reconstruct_namespace_imported(&mut self) {
784 let capacity = self
785 .edges
786 .iter()
787 .map(|edge| edge.target.0 as usize + 1)
788 .max()
789 .unwrap_or(0)
790 .max(self.modules.len());
791 let mut bitset = FixedBitSet::with_capacity(capacity);
792 for edge in &self.edges {
793 if edge
794 .symbols
795 .iter()
796 .any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
797 {
798 let idx = edge.target.0 as usize;
799 if idx < capacity {
800 bitset.insert(idx);
801 }
802 }
803 }
804 self.namespace_imported = bitset;
805 }
806
807 #[must_use]
813 pub fn resolve_export(
814 &self,
815 file_id: FileId,
816 name: &str,
817 namespace: ExportNamespace,
818 ) -> EffectiveExportResolution {
819 self.effective_exports.resolve(file_id, name, namespace)
820 }
821
822 #[must_use]
828 pub fn resolve_export_origin(
829 &self,
830 file_id: FileId,
831 name: &str,
832 namespace: ExportNamespace,
833 ) -> Option<EffectiveExportOrigin<'_>> {
834 let EffectiveExportResolution::Unique(binding) =
835 self.resolve_export(file_id, name, namespace)
836 else {
837 return None;
838 };
839 self.export_binding_origin(binding)
840 }
841
842 #[must_use]
845 pub fn effective_export_surface(
846 &self,
847 file_id: FileId,
848 name: &str,
849 namespace: ExportNamespace,
850 ) -> Option<EffectiveExportSurface<'_>> {
851 let EffectiveExportResolution::Unique(binding) =
852 self.resolve_export(file_id, name, namespace)
853 else {
854 return None;
855 };
856 let module = self.modules.get(file_id.0 as usize)?;
857 let exact_surface = module.exports.iter().find(|export| {
858 export.name.matches_str(name)
859 && match namespace {
860 ExportNamespace::Type => export.is_type_only,
861 ExportNamespace::Value => !export.is_type_only,
862 }
863 });
864 let surface_export = exact_surface.or_else(|| {
865 module
866 .exports
867 .iter()
868 .find(|export| export.name.matches_str(name))
869 });
870 let origin = self.export_binding_origin(binding);
871 let export = surface_export.or_else(|| origin.map(|o| o.export));
872 Some(EffectiveExportSurface {
873 binding,
874 namespace,
875 export,
876 origin,
877 local_export: surface_export.is_some(),
878 })
879 }
880
881 #[must_use]
887 pub fn effective_export_surface_re_export(
888 &self,
889 file_id: FileId,
890 name: &str,
891 namespace: ExportNamespace,
892 ) -> Option<&ReExportEdge> {
893 let EffectiveExportResolution::Unique(binding) =
894 self.resolve_export(file_id, name, namespace)
895 else {
896 return None;
897 };
898 self.modules
899 .get(file_id.0 as usize)?
900 .re_exports
901 .iter()
902 .find(|re_export| {
903 re_export.exported_name == name
904 && (namespace == ExportNamespace::Type || !re_export.is_type_only)
905 && if re_export.imported_name == "*" {
906 binding.namespace_source() == Some(re_export.source_file)
907 } else {
908 self.resolve_export(
909 re_export.source_file,
910 &re_export.imported_name,
911 namespace,
912 ) == EffectiveExportResolution::Unique(binding)
913 }
914 })
915 }
916
917 #[must_use]
923 pub fn effective_export_surface_references(
924 &self,
925 file_id: FileId,
926 name: &str,
927 namespace: ExportNamespace,
928 ) -> Vec<&SymbolReference> {
929 let Some(surface) = self.effective_export_surface(file_id, name, namespace) else {
930 return Vec::new();
931 };
932 let Some(export) = surface.export() else {
933 return Vec::new();
934 };
935 if surface.local_export
936 || surface
937 .origin()
938 .is_none_or(|origin| origin.file_id() == file_id)
939 {
940 return export.references_in(namespace).collect();
941 }
942 let mut exposed: FxHashMap<FileId, FxHashSet<String>> = FxHashMap::default();
943 exposed.entry(file_id).or_default().insert(name.to_string());
944 for route in self.effective_re_export_routes(file_id, name, namespace) {
945 exposed
946 .entry(route.barrel_file())
947 .or_default()
948 .insert(route.exported_name().to_string());
949 }
950 export
951 .references
952 .iter()
953 .filter(|reference| {
954 reference.namespace == namespace
955 && self.reference_reaches_surface(reference, &exposed, namespace)
956 })
957 .collect()
958 }
959
960 fn reference_reaches_surface(
961 &self,
962 reference: &SymbolReference,
963 exposed: &FxHashMap<FileId, FxHashSet<String>>,
964 namespace: ExportNamespace,
965 ) -> bool {
966 if reference.kind == ReferenceKind::ReExport && exposed.contains_key(&reference.from_file) {
967 return true;
968 }
969 self.outgoing_symbol_edges(reference.from_file)
970 .any(|(target, symbols)| {
971 let Some(names) = exposed.get(&target) else {
972 return false;
973 };
974 symbols.iter().any(|symbol| {
975 symbol.import_span == reference.import_span
976 && (namespace == ExportNamespace::Type || !symbol.is_type_only)
977 && match &symbol.imported_name {
978 ImportedName::Named(imported) => names.contains(imported.as_str()),
979 ImportedName::Default => names.contains("default"),
980 ImportedName::Namespace => true,
981 ImportedName::SideEffect => false,
982 }
983 })
984 })
985 }
986
987 #[must_use]
989 pub fn export_binding_origin(
990 &self,
991 binding: EffectiveExportBinding,
992 ) -> Option<EffectiveExportOrigin<'_>> {
993 let origin_file = binding.origin_file();
994 let export = self
995 .modules
996 .get(origin_file.0 as usize)?
997 .exports
998 .get(binding.origin_slot()?)?;
999 Some(EffectiveExportOrigin {
1000 file_id: origin_file,
1001 export,
1002 })
1003 }
1004
1005 #[must_use]
1010 pub fn unique_export_bindings(
1011 &self,
1012 file_id: FileId,
1013 namespace: ExportNamespace,
1014 ) -> FxHashSet<EffectiveExportBinding> {
1015 self.effective_exports.unique_bindings(file_id, namespace)
1016 }
1017
1018 #[must_use]
1026 pub fn importer_connects_export_origin(
1027 &self,
1028 importer: FileId,
1029 source: FileId,
1030 name: &str,
1031 namespace: ExportNamespace,
1032 ) -> bool {
1033 let Some(importer_module) = self.modules.get(importer.0 as usize) else {
1034 return false;
1035 };
1036 let re_export_count = importer_module
1037 .re_exports
1038 .iter()
1039 .filter(|re_export| re_export.source_file == source)
1040 .count();
1041 if self.edges[importer_module.edge_range.clone()]
1042 .iter()
1043 .any(|edge| edge.target == source && edge.symbols.len() > re_export_count)
1044 {
1045 return true;
1046 }
1047
1048 importer_module.re_exports.iter().any(|re_export| {
1049 if re_export.source_file != source
1050 || (namespace == ExportNamespace::Value && re_export.is_type_only)
1051 {
1052 return false;
1053 }
1054 let exported_name = if re_export.imported_name == "*" {
1055 if re_export.exported_name != "*" || name == "default" {
1056 return false;
1057 }
1058 name
1059 } else {
1060 if re_export.imported_name != name {
1061 return false;
1062 }
1063 &re_export.exported_name
1064 };
1065 self.effective_exports.contributes_through(
1066 importer,
1067 exported_name,
1068 source,
1069 name,
1070 namespace,
1071 )
1072 })
1073 }
1074
1075 #[must_use]
1078 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
1079 let idx = file_id.0 as usize;
1080 if idx >= self.namespace_imported.len() {
1081 return false;
1082 }
1083 self.namespace_imported.contains(idx)
1084 }
1085
1086 #[must_use]
1088 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
1089 let idx = file_id.0 as usize;
1090 if idx >= self.modules.len() {
1091 return Vec::new();
1092 }
1093 let range = &self.modules[idx].edge_range;
1094 self.edges[range.clone()].iter().map(|e| e.target).collect()
1095 }
1096
1097 pub fn outgoing_symbol_edges(
1103 &self,
1104 file_id: FileId,
1105 ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
1106 let idx = file_id.0 as usize;
1107 let range = if idx < self.modules.len() {
1108 self.modules[idx].edge_range.clone()
1109 } else {
1110 0..0
1111 };
1112 self.edges[range]
1113 .iter()
1114 .map(|edge| (edge.target, edge.symbols.as_slice()))
1115 }
1116
1117 #[must_use]
1121 pub fn importers_of(&self, target: FileId) -> &[FileId] {
1122 self.reverse_deps
1123 .get(target.0 as usize)
1124 .map_or(&[], Vec::as_slice)
1125 }
1126
1127 #[must_use]
1132 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
1133 let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
1134 return Vec::new();
1135 };
1136
1137 let mut summaries = Vec::new();
1138 for &source in importers {
1139 let idx = source.0 as usize;
1140 let Some(source_node) = self.modules.get(idx) else {
1141 continue;
1142 };
1143 let mut symbols = Vec::new();
1144 for edge in &self.edges[source_node.edge_range.clone()] {
1145 if edge.target != target {
1146 continue;
1147 }
1148 symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
1149 imported: imported_name_label(&symbol.imported_name),
1150 local: symbol.local_name.clone(),
1151 type_only: symbol.is_type_only,
1152 }));
1153 }
1154 symbols.sort_by(|a, b| {
1155 a.imported
1156 .cmp(&b.imported)
1157 .then_with(|| a.local.cmp(&b.local))
1158 .then_with(|| a.type_only.cmp(&b.type_only))
1159 });
1160 symbols.dedup();
1161 summaries.push(DirectImporterSummary { source, symbols });
1162 }
1163 summaries.sort_by_key(|summary| summary.source.0);
1164 summaries
1165 }
1166
1167 #[must_use]
1174 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
1175 let idx = source.0 as usize;
1176 if idx >= self.modules.len() {
1177 return None;
1178 }
1179 let range = &self.modules[idx].edge_range;
1180 for edge in &self.edges[range.clone()] {
1181 if edge.target == target {
1182 return edge
1183 .symbols
1184 .iter()
1185 .find(|s| !s.is_type_only)
1186 .or_else(|| edge.symbols.first())
1187 .map(|s| s.import_span.start);
1188 }
1189 }
1190 None
1191 }
1192
1193 pub fn outgoing_edge_summaries(
1208 &self,
1209 file_id: FileId,
1210 ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
1211 let idx = file_id.0 as usize;
1212 let range = if idx < self.modules.len() {
1213 self.modules[idx].edge_range.clone()
1214 } else {
1215 0..0
1216 };
1217 self.edges[range].iter().map(|edge| {
1218 let all_type_only =
1219 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
1220 let span = edge
1221 .symbols
1222 .iter()
1223 .find(|s| !s.is_type_only)
1224 .or_else(|| edge.symbols.first())
1225 .map(|s| s.import_span.start);
1226 (edge.target, all_type_only, span)
1227 })
1228 }
1229
1230 pub fn outgoing_edge_summaries_with_exclusions<'a>(
1241 &'a self,
1242 file_id: FileId,
1243 excluded_span_starts: &'a FxHashSet<u32>,
1244 ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
1245 let idx = file_id.0 as usize;
1246 let range = if idx < self.modules.len() {
1247 self.modules[idx].edge_range.clone()
1248 } else {
1249 0..0
1250 };
1251 self.edges[range].iter().map(move |edge| {
1252 let all_type_only =
1253 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
1254 let span = edge
1255 .symbols
1256 .iter()
1257 .find(|s| !s.is_type_only)
1258 .or_else(|| edge.symbols.first())
1259 .map(|s| s.import_span.start);
1260 let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
1264 let all_client_only = value_symbols.peek().is_some()
1265 && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
1266 (edge.target, all_type_only, span, all_client_only)
1267 })
1268 }
1269}
1270
1271fn imported_name_label(name: &ImportedName) -> String {
1272 match name {
1273 ImportedName::Named(name) => name.clone(),
1274 ImportedName::Default => "default".to_string(),
1275 ImportedName::Namespace => "*".to_string(),
1276 ImportedName::SideEffect => "side-effect".to_string(),
1277 }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 use super::*;
1283 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
1284 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1285 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
1286 use std::path::PathBuf;
1287
1288 fn build_simple_graph() -> ModuleGraph {
1289 let files = vec![
1290 DiscoveredFile {
1291 id: FileId(0),
1292 path: PathBuf::from("/project/src/entry.ts"),
1293 size_bytes: 100,
1294 },
1295 DiscoveredFile {
1296 id: FileId(1),
1297 path: PathBuf::from("/project/src/utils.ts"),
1298 size_bytes: 50,
1299 },
1300 ];
1301
1302 let entry_points = vec![EntryPoint {
1303 path: PathBuf::from("/project/src/entry.ts"),
1304 source: EntryPointSource::PackageJsonMain,
1305 }];
1306
1307 let resolved_modules = vec![
1308 ResolvedModule {
1309 file_id: FileId(0),
1310 path: PathBuf::from("/project/src/entry.ts"),
1311 resolved_imports: vec![ResolvedImport {
1312 info: ImportInfo {
1313 source: "./utils".to_string(),
1314 imported_name: ImportedName::Named("foo".to_string()),
1315 local_name: "foo".to_string(),
1316 is_type_only: false,
1317 from_style: false,
1318 span: oxc_span::Span::new(0, 10),
1319 source_span: oxc_span::Span::default(),
1320 },
1321 target: ResolveResult::InternalModule(FileId(1)),
1322 }],
1323 ..Default::default()
1324 },
1325 ResolvedModule {
1326 file_id: FileId(1),
1327 path: PathBuf::from("/project/src/utils.ts"),
1328 exports: vec![
1329 fallow_types::extract::ExportInfo {
1330 name: ExportName::Named("foo".to_string()),
1331 local_name: Some("foo".to_string()),
1332 is_type_only: false,
1333 visibility: VisibilityTag::None,
1334 expected_unused_reason: None,
1335 span: oxc_span::Span::new(0, 20),
1336 members: vec![],
1337 is_side_effect_used: false,
1338 super_class: None,
1339 },
1340 fallow_types::extract::ExportInfo {
1341 name: ExportName::Named("bar".to_string()),
1342 local_name: Some("bar".to_string()),
1343 is_type_only: false,
1344 visibility: VisibilityTag::None,
1345 expected_unused_reason: None,
1346 span: oxc_span::Span::new(25, 45),
1347 members: vec![],
1348 is_side_effect_used: false,
1349 super_class: None,
1350 },
1351 ]
1352 .into(),
1353 ..Default::default()
1354 },
1355 ];
1356
1357 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1358 }
1359
1360 #[test]
1361 fn graph_module_count() {
1362 let graph = build_simple_graph();
1363 assert_eq!(graph.module_count(), 2);
1364 }
1365
1366 #[test]
1367 fn graph_edge_count() {
1368 let graph = build_simple_graph();
1369 assert_eq!(graph.edge_count(), 1);
1370 }
1371
1372 #[test]
1373 fn graph_entry_point_is_reachable() {
1374 let graph = build_simple_graph();
1375 assert!(graph.modules[0].is_entry_point());
1376 assert!(graph.modules[0].is_reachable());
1377 }
1378
1379 #[test]
1380 fn graph_imported_module_is_reachable() {
1381 let graph = build_simple_graph();
1382 assert!(!graph.modules[1].is_entry_point());
1383 assert!(graph.modules[1].is_reachable());
1384 }
1385
1386 #[test]
1387 #[expect(
1388 clippy::too_many_lines,
1389 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
1390 would obscure the cross-role assertions"
1391 )]
1392 fn graph_distinguishes_runtime_test_and_support_reachability() {
1393 let files = vec![
1394 DiscoveredFile {
1395 id: FileId(0),
1396 path: PathBuf::from("/project/src/main.ts"),
1397 size_bytes: 100,
1398 },
1399 DiscoveredFile {
1400 id: FileId(1),
1401 path: PathBuf::from("/project/src/runtime-only.ts"),
1402 size_bytes: 50,
1403 },
1404 DiscoveredFile {
1405 id: FileId(2),
1406 path: PathBuf::from("/project/tests/app.test.ts"),
1407 size_bytes: 50,
1408 },
1409 DiscoveredFile {
1410 id: FileId(3),
1411 path: PathBuf::from("/project/tests/setup.ts"),
1412 size_bytes: 50,
1413 },
1414 DiscoveredFile {
1415 id: FileId(4),
1416 path: PathBuf::from("/project/src/covered.ts"),
1417 size_bytes: 50,
1418 },
1419 ];
1420
1421 let all_entry_points = vec![
1422 EntryPoint {
1423 path: PathBuf::from("/project/src/main.ts"),
1424 source: EntryPointSource::PackageJsonMain,
1425 },
1426 EntryPoint {
1427 path: PathBuf::from("/project/tests/app.test.ts"),
1428 source: EntryPointSource::TestFile,
1429 },
1430 EntryPoint {
1431 path: PathBuf::from("/project/tests/setup.ts"),
1432 source: EntryPointSource::Plugin {
1433 name: "vitest".to_string(),
1434 },
1435 },
1436 ];
1437 let runtime_entry_points = vec![EntryPoint {
1438 path: PathBuf::from("/project/src/main.ts"),
1439 source: EntryPointSource::PackageJsonMain,
1440 }];
1441 let test_entry_points = vec![EntryPoint {
1442 path: PathBuf::from("/project/tests/app.test.ts"),
1443 source: EntryPointSource::TestFile,
1444 }];
1445
1446 let resolved_modules = vec![
1447 ResolvedModule {
1448 file_id: FileId(0),
1449 path: PathBuf::from("/project/src/main.ts"),
1450 resolved_imports: vec![ResolvedImport {
1451 info: ImportInfo {
1452 source: "./runtime-only".to_string(),
1453 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1454 local_name: "runtimeOnly".to_string(),
1455 is_type_only: false,
1456 from_style: false,
1457 span: oxc_span::Span::new(0, 10),
1458 source_span: oxc_span::Span::default(),
1459 },
1460 target: ResolveResult::InternalModule(FileId(1)),
1461 }],
1462 ..Default::default()
1463 },
1464 ResolvedModule {
1465 file_id: FileId(1),
1466 path: PathBuf::from("/project/src/runtime-only.ts"),
1467 exports: vec![fallow_types::extract::ExportInfo {
1468 name: ExportName::Named("runtimeOnly".to_string()),
1469 local_name: Some("runtimeOnly".to_string()),
1470 is_type_only: false,
1471 visibility: VisibilityTag::None,
1472 expected_unused_reason: None,
1473 span: oxc_span::Span::new(0, 20),
1474 members: vec![],
1475 is_side_effect_used: false,
1476 super_class: None,
1477 }]
1478 .into(),
1479 ..Default::default()
1480 },
1481 ResolvedModule {
1482 file_id: FileId(2),
1483 path: PathBuf::from("/project/tests/app.test.ts"),
1484 resolved_imports: vec![ResolvedImport {
1485 info: ImportInfo {
1486 source: "../src/covered".to_string(),
1487 imported_name: ImportedName::Named("covered".to_string()),
1488 local_name: "covered".to_string(),
1489 is_type_only: false,
1490 from_style: false,
1491 span: oxc_span::Span::new(0, 10),
1492 source_span: oxc_span::Span::default(),
1493 },
1494 target: ResolveResult::InternalModule(FileId(4)),
1495 }],
1496 ..Default::default()
1497 },
1498 ResolvedModule {
1499 file_id: FileId(3),
1500 path: PathBuf::from("/project/tests/setup.ts"),
1501 resolved_imports: vec![ResolvedImport {
1502 info: ImportInfo {
1503 source: "../src/runtime-only".to_string(),
1504 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1505 local_name: "runtimeOnly".to_string(),
1506 is_type_only: false,
1507 from_style: false,
1508 span: oxc_span::Span::new(0, 10),
1509 source_span: oxc_span::Span::default(),
1510 },
1511 target: ResolveResult::InternalModule(FileId(1)),
1512 }],
1513 ..Default::default()
1514 },
1515 ResolvedModule {
1516 file_id: FileId(4),
1517 path: PathBuf::from("/project/src/covered.ts"),
1518 exports: vec![fallow_types::extract::ExportInfo {
1519 name: ExportName::Named("covered".to_string()),
1520 local_name: Some("covered".to_string()),
1521 is_type_only: false,
1522 visibility: VisibilityTag::None,
1523 expected_unused_reason: None,
1524 span: oxc_span::Span::new(0, 20),
1525 members: vec![],
1526 is_side_effect_used: false,
1527 super_class: None,
1528 }]
1529 .into(),
1530 ..Default::default()
1531 },
1532 ];
1533
1534 let graph = ModuleGraph::build_with_reachability_roots(
1535 &resolved_modules,
1536 &all_entry_points,
1537 &runtime_entry_points,
1538 &test_entry_points,
1539 &files,
1540 );
1541
1542 assert!(graph.modules[1].is_reachable());
1543 assert!(graph.modules[1].is_runtime_reachable());
1544 assert!(
1545 !graph.modules[1].is_test_reachable(),
1546 "support roots should not make runtime-only modules test reachable"
1547 );
1548
1549 assert!(graph.modules[4].is_reachable());
1550 assert!(graph.modules[4].is_test_reachable());
1551 assert!(
1552 !graph.modules[4].is_runtime_reachable(),
1553 "test-only reachability should stay separate from runtime roots"
1554 );
1555 }
1556
1557 #[test]
1558 fn graph_export_has_reference() {
1559 let graph = build_simple_graph();
1560 let utils = &graph.modules[1];
1561 let foo_export = utils
1562 .exports
1563 .iter()
1564 .find(|e| e.name.to_string() == "foo")
1565 .unwrap();
1566 assert!(
1567 !foo_export.references.is_empty(),
1568 "foo should have references"
1569 );
1570 }
1571
1572 #[test]
1573 fn graph_unused_export_no_reference() {
1574 let graph = build_simple_graph();
1575 let utils = &graph.modules[1];
1576 let bar_export = utils
1577 .exports
1578 .iter()
1579 .find(|e| e.name.to_string() == "bar")
1580 .unwrap();
1581 assert!(
1582 bar_export.references.is_empty(),
1583 "bar should have no references"
1584 );
1585 }
1586
1587 #[test]
1588 fn graph_no_namespace_import() {
1589 let graph = build_simple_graph();
1590 assert!(!graph.has_namespace_import(FileId(0)));
1591 assert!(!graph.has_namespace_import(FileId(1)));
1592 }
1593
1594 #[test]
1595 fn graph_has_namespace_import() {
1596 let files = vec![
1597 DiscoveredFile {
1598 id: FileId(0),
1599 path: PathBuf::from("/project/entry.ts"),
1600 size_bytes: 100,
1601 },
1602 DiscoveredFile {
1603 id: FileId(1),
1604 path: PathBuf::from("/project/utils.ts"),
1605 size_bytes: 50,
1606 },
1607 ];
1608
1609 let entry_points = vec![EntryPoint {
1610 path: PathBuf::from("/project/entry.ts"),
1611 source: EntryPointSource::PackageJsonMain,
1612 }];
1613
1614 let resolved_modules = vec![
1615 ResolvedModule {
1616 file_id: FileId(0),
1617 path: PathBuf::from("/project/entry.ts"),
1618 resolved_imports: vec![ResolvedImport {
1619 info: ImportInfo {
1620 source: "./utils".to_string(),
1621 imported_name: ImportedName::Namespace,
1622 local_name: "utils".to_string(),
1623 is_type_only: false,
1624 from_style: false,
1625 span: oxc_span::Span::new(0, 10),
1626 source_span: oxc_span::Span::default(),
1627 },
1628 target: ResolveResult::InternalModule(FileId(1)),
1629 }],
1630 ..Default::default()
1631 },
1632 ResolvedModule {
1633 file_id: FileId(1),
1634 path: PathBuf::from("/project/utils.ts"),
1635 exports: vec![fallow_types::extract::ExportInfo {
1636 name: ExportName::Named("foo".to_string()),
1637 local_name: Some("foo".to_string()),
1638 is_type_only: false,
1639 visibility: VisibilityTag::None,
1640 expected_unused_reason: None,
1641 span: oxc_span::Span::new(0, 20),
1642 members: vec![],
1643 is_side_effect_used: false,
1644 super_class: None,
1645 }]
1646 .into(),
1647 ..Default::default()
1648 },
1649 ];
1650
1651 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1652 assert!(
1653 graph.has_namespace_import(FileId(1)),
1654 "utils should have namespace import"
1655 );
1656 }
1657
1658 #[test]
1659 fn graph_has_namespace_import_out_of_bounds() {
1660 let graph = build_simple_graph();
1661 assert!(!graph.has_namespace_import(FileId(999)));
1662 }
1663
1664 #[test]
1669 fn reconstruct_namespace_imported_matches_fresh_build() {
1670 let files = vec![
1671 DiscoveredFile {
1672 id: FileId(0),
1673 path: PathBuf::from("/project/entry.ts"),
1674 size_bytes: 100,
1675 },
1676 DiscoveredFile {
1677 id: FileId(1),
1678 path: PathBuf::from("/project/utils.ts"),
1679 size_bytes: 50,
1680 },
1681 DiscoveredFile {
1682 id: FileId(2),
1683 path: PathBuf::from("/project/named-only.ts"),
1684 size_bytes: 50,
1685 },
1686 ];
1687 let entry_points = vec![EntryPoint {
1688 path: PathBuf::from("/project/entry.ts"),
1689 source: EntryPointSource::PackageJsonMain,
1690 }];
1691 let resolved_modules = vec![
1692 ResolvedModule {
1693 file_id: FileId(0),
1694 path: PathBuf::from("/project/entry.ts"),
1695 resolved_imports: vec![
1696 ResolvedImport {
1697 info: ImportInfo {
1698 source: "./utils".to_string(),
1699 imported_name: ImportedName::Namespace,
1700 local_name: "utils".to_string(),
1701 is_type_only: false,
1702 from_style: false,
1703 span: oxc_span::Span::new(0, 10),
1704 source_span: oxc_span::Span::default(),
1705 },
1706 target: ResolveResult::InternalModule(FileId(1)),
1707 },
1708 ResolvedImport {
1709 info: ImportInfo {
1710 source: "./named-only".to_string(),
1711 imported_name: ImportedName::Named("foo".to_string()),
1712 local_name: "foo".to_string(),
1713 is_type_only: false,
1714 from_style: false,
1715 span: oxc_span::Span::new(11, 20),
1716 source_span: oxc_span::Span::default(),
1717 },
1718 target: ResolveResult::InternalModule(FileId(2)),
1719 },
1720 ],
1721 ..Default::default()
1722 },
1723 ResolvedModule {
1724 file_id: FileId(1),
1725 path: PathBuf::from("/project/utils.ts"),
1726 ..Default::default()
1727 },
1728 ResolvedModule {
1729 file_id: FileId(2),
1730 path: PathBuf::from("/project/named-only.ts"),
1731 exports: vec![fallow_types::extract::ExportInfo {
1732 name: ExportName::Named("foo".to_string()),
1733 local_name: Some("foo".to_string()),
1734 is_type_only: false,
1735 visibility: VisibilityTag::None,
1736 expected_unused_reason: None,
1737 span: oxc_span::Span::new(0, 20),
1738 members: vec![],
1739 is_side_effect_used: false,
1740 super_class: None,
1741 }]
1742 .into(),
1743 ..Default::default()
1744 },
1745 ];
1746
1747 let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1748 let fresh = graph.namespace_imported.clone();
1749
1750 assert!(graph.has_namespace_import(FileId(1)));
1752 assert!(!graph.has_namespace_import(FileId(2)));
1753
1754 graph.namespace_imported = FixedBitSet::default();
1757 graph.reconstruct_namespace_imported();
1758
1759 assert_eq!(
1760 graph.namespace_imported, fresh,
1761 "reconstructed namespace_imported must equal the fresh-built bitset"
1762 );
1763 assert!(graph.has_namespace_import(FileId(1)));
1764 assert!(!graph.has_namespace_import(FileId(2)));
1765 }
1766
1767 #[test]
1768 fn graph_unreachable_module() {
1769 let files = vec![
1770 DiscoveredFile {
1771 id: FileId(0),
1772 path: PathBuf::from("/project/entry.ts"),
1773 size_bytes: 100,
1774 },
1775 DiscoveredFile {
1776 id: FileId(1),
1777 path: PathBuf::from("/project/utils.ts"),
1778 size_bytes: 50,
1779 },
1780 DiscoveredFile {
1781 id: FileId(2),
1782 path: PathBuf::from("/project/orphan.ts"),
1783 size_bytes: 30,
1784 },
1785 ];
1786
1787 let entry_points = vec![EntryPoint {
1788 path: PathBuf::from("/project/entry.ts"),
1789 source: EntryPointSource::PackageJsonMain,
1790 }];
1791
1792 let resolved_modules = vec![
1793 ResolvedModule {
1794 file_id: FileId(0),
1795 path: PathBuf::from("/project/entry.ts"),
1796 resolved_imports: vec![ResolvedImport {
1797 info: ImportInfo {
1798 source: "./utils".to_string(),
1799 imported_name: ImportedName::Named("foo".to_string()),
1800 local_name: "foo".to_string(),
1801 is_type_only: false,
1802 from_style: false,
1803 span: oxc_span::Span::new(0, 10),
1804 source_span: oxc_span::Span::default(),
1805 },
1806 target: ResolveResult::InternalModule(FileId(1)),
1807 }],
1808 ..Default::default()
1809 },
1810 ResolvedModule {
1811 file_id: FileId(1),
1812 path: PathBuf::from("/project/utils.ts"),
1813 exports: vec![fallow_types::extract::ExportInfo {
1814 name: ExportName::Named("foo".to_string()),
1815 local_name: Some("foo".to_string()),
1816 is_type_only: false,
1817 visibility: VisibilityTag::None,
1818 expected_unused_reason: None,
1819 span: oxc_span::Span::new(0, 20),
1820 members: vec![],
1821 is_side_effect_used: false,
1822 super_class: None,
1823 }]
1824 .into(),
1825 ..Default::default()
1826 },
1827 ResolvedModule {
1828 file_id: FileId(2),
1829 path: PathBuf::from("/project/orphan.ts"),
1830 exports: vec![fallow_types::extract::ExportInfo {
1831 name: ExportName::Named("orphan".to_string()),
1832 local_name: Some("orphan".to_string()),
1833 is_type_only: false,
1834 visibility: VisibilityTag::None,
1835 expected_unused_reason: None,
1836 span: oxc_span::Span::new(0, 20),
1837 members: vec![],
1838 is_side_effect_used: false,
1839 super_class: None,
1840 }]
1841 .into(),
1842 ..Default::default()
1843 },
1844 ];
1845
1846 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1847
1848 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1849 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1850 assert!(
1851 !graph.modules[2].is_reachable(),
1852 "orphan should NOT be reachable"
1853 );
1854 }
1855
1856 #[test]
1857 fn graph_package_usage_tracked() {
1858 let files = vec![DiscoveredFile {
1859 id: FileId(0),
1860 path: PathBuf::from("/project/entry.ts"),
1861 size_bytes: 100,
1862 }];
1863
1864 let entry_points = vec![EntryPoint {
1865 path: PathBuf::from("/project/entry.ts"),
1866 source: EntryPointSource::PackageJsonMain,
1867 }];
1868
1869 let resolved_modules = vec![ResolvedModule {
1870 file_id: FileId(0),
1871 path: PathBuf::from("/project/entry.ts"),
1872 exports: vec![].into(),
1873 re_exports: vec![],
1874 resolved_imports: vec![
1875 ResolvedImport {
1876 info: ImportInfo {
1877 source: "react".to_string(),
1878 imported_name: ImportedName::Default,
1879 local_name: "React".to_string(),
1880 is_type_only: false,
1881 from_style: false,
1882 span: oxc_span::Span::new(0, 10),
1883 source_span: oxc_span::Span::default(),
1884 },
1885 target: ResolveResult::NpmPackage("react".to_string()),
1886 },
1887 ResolvedImport {
1888 info: ImportInfo {
1889 source: "lodash".to_string(),
1890 imported_name: ImportedName::Named("merge".to_string()),
1891 local_name: "merge".to_string(),
1892 is_type_only: false,
1893 from_style: false,
1894 span: oxc_span::Span::new(15, 30),
1895 source_span: oxc_span::Span::default(),
1896 },
1897 target: ResolveResult::NpmPackage("lodash".to_string()),
1898 },
1899 ],
1900 ..Default::default()
1901 }];
1902
1903 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1904 assert!(graph.package_usage.contains_key("react"));
1905 assert!(graph.package_usage.contains_key("lodash"));
1906 assert!(!graph.package_usage.contains_key("express"));
1907 }
1908
1909 #[test]
1910 fn graph_empty() {
1911 let graph = ModuleGraph::build(&[], &[], &[]);
1912 assert_eq!(graph.module_count(), 0);
1913 assert_eq!(graph.edge_count(), 0);
1914 }
1915
1916 #[test]
1922 fn graph_postcard_round_trip_is_lossless() {
1923 let graph = build_simple_graph();
1924
1925 let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1926 let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1927 decoded.reconstruct_namespace_imported();
1929
1930 assert_eq!(decoded.module_count(), graph.module_count());
1931 assert_eq!(decoded.edge_count(), graph.edge_count());
1932 assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1933
1934 let utils = &decoded.modules[1];
1936 let foo = utils
1937 .exports
1938 .iter()
1939 .find(|e| e.name.to_string() == "foo")
1940 .expect("foo export survives round-trip");
1941 assert!(!foo.references.is_empty());
1942 let bar = utils
1943 .exports
1944 .iter()
1945 .find(|e| e.name.to_string() == "bar")
1946 .expect("bar export survives round-trip");
1947 assert!(bar.references.is_empty());
1948
1949 assert!(decoded.modules[0].is_entry_point());
1951 assert!(decoded.modules[0].is_reachable());
1952 assert!(decoded.modules[1].is_reachable());
1953 assert_eq!(decoded.entry_points, graph.entry_points);
1954 }
1955
1956 #[test]
1957 fn graph_cjs_exports_tracked() {
1958 let files = vec![DiscoveredFile {
1959 id: FileId(0),
1960 path: PathBuf::from("/project/entry.ts"),
1961 size_bytes: 100,
1962 }];
1963
1964 let entry_points = vec![EntryPoint {
1965 path: PathBuf::from("/project/entry.ts"),
1966 source: EntryPointSource::PackageJsonMain,
1967 }];
1968
1969 let resolved_modules = vec![ResolvedModule {
1970 file_id: FileId(0),
1971 path: PathBuf::from("/project/entry.ts"),
1972 has_cjs_exports: true,
1973 has_angular_component_template_url: false,
1974 ..Default::default()
1975 }];
1976
1977 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1978 assert!(graph.modules[0].has_cjs_exports());
1979 }
1980
1981 #[test]
1982 fn graph_edges_for_returns_targets() {
1983 let graph = build_simple_graph();
1984 let targets = graph.edges_for(FileId(0));
1985 assert_eq!(targets, vec![FileId(1)]);
1986 }
1987
1988 #[test]
1989 fn graph_edges_for_no_imports() {
1990 let graph = build_simple_graph();
1991 let targets = graph.edges_for(FileId(1));
1992 assert!(targets.is_empty());
1993 }
1994
1995 #[test]
1996 fn graph_edges_for_out_of_bounds() {
1997 let graph = build_simple_graph();
1998 let targets = graph.edges_for(FileId(999));
1999 assert!(targets.is_empty());
2000 }
2001
2002 #[test]
2003 fn graph_direct_importer_summaries_include_symbols() {
2004 let graph = build_simple_graph();
2005 let summaries = graph.direct_importer_summaries(FileId(1));
2006
2007 assert_eq!(
2008 summaries,
2009 vec![DirectImporterSummary {
2010 source: FileId(0),
2011 symbols: vec![ImportedSymbolSummary {
2012 imported: "foo".to_string(),
2013 local: "foo".to_string(),
2014 type_only: false,
2015 }],
2016 }]
2017 );
2018 }
2019
2020 #[test]
2021 fn graph_find_import_span_start_found() {
2022 let graph = build_simple_graph();
2023 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
2024 assert!(span_start.is_some());
2025 assert_eq!(span_start.unwrap(), 0);
2026 }
2027
2028 #[test]
2029 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
2030 let files = vec![
2031 DiscoveredFile {
2032 id: FileId(0),
2033 path: PathBuf::from("/project/entry.ts"),
2034 size_bytes: 100,
2035 },
2036 DiscoveredFile {
2037 id: FileId(1),
2038 path: PathBuf::from("/project/utils.ts"),
2039 size_bytes: 50,
2040 },
2041 ];
2042 let entry_points = vec![EntryPoint {
2043 path: PathBuf::from("/project/entry.ts"),
2044 source: EntryPointSource::PackageJsonMain,
2045 }];
2046 let resolved_modules = vec![
2047 ResolvedModule {
2048 file_id: FileId(0),
2049 path: PathBuf::from("/project/entry.ts"),
2050 resolved_imports: vec![
2051 ResolvedImport {
2052 info: ImportInfo {
2053 source: "./utils".to_string(),
2054 imported_name: ImportedName::Named("Foo".to_string()),
2055 local_name: "Foo".to_string(),
2056 is_type_only: true,
2057 from_style: false,
2058 span: oxc_span::Span::new(10, 20),
2059 source_span: oxc_span::Span::default(),
2060 },
2061 target: ResolveResult::InternalModule(FileId(1)),
2062 },
2063 ResolvedImport {
2064 info: ImportInfo {
2065 source: "./utils".to_string(),
2066 imported_name: ImportedName::Named("foo".to_string()),
2067 local_name: "foo".to_string(),
2068 is_type_only: false,
2069 from_style: false,
2070 span: oxc_span::Span::new(50, 60),
2071 source_span: oxc_span::Span::default(),
2072 },
2073 target: ResolveResult::InternalModule(FileId(1)),
2074 },
2075 ],
2076 ..Default::default()
2077 },
2078 ResolvedModule {
2079 file_id: FileId(1),
2080 path: PathBuf::from("/project/utils.ts"),
2081 ..Default::default()
2082 },
2083 ];
2084
2085 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2086 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
2087 }
2088
2089 #[test]
2090 fn graph_find_import_span_start_wrong_target() {
2091 let graph = build_simple_graph();
2092 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
2093 assert!(span_start.is_none());
2094 }
2095
2096 #[test]
2097 fn graph_find_import_span_start_source_out_of_bounds() {
2098 let graph = build_simple_graph();
2099 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
2100 assert!(span_start.is_none());
2101 }
2102
2103 #[test]
2104 fn graph_find_import_span_start_no_edges() {
2105 let graph = build_simple_graph();
2106 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
2107 assert!(span_start.is_none());
2108 }
2109
2110 #[test]
2111 fn graph_reverse_deps_populated() {
2112 let graph = build_simple_graph();
2113 assert!(graph.reverse_deps[1].contains(&FileId(0)));
2114 assert!(graph.reverse_deps[0].is_empty());
2115 }
2116
2117 #[test]
2118 fn graph_type_only_package_usage_tracked() {
2119 let files = vec![DiscoveredFile {
2120 id: FileId(0),
2121 path: PathBuf::from("/project/entry.ts"),
2122 size_bytes: 100,
2123 }];
2124 let entry_points = vec![EntryPoint {
2125 path: PathBuf::from("/project/entry.ts"),
2126 source: EntryPointSource::PackageJsonMain,
2127 }];
2128 let resolved_modules = vec![ResolvedModule {
2129 file_id: FileId(0),
2130 path: PathBuf::from("/project/entry.ts"),
2131 resolved_imports: vec![
2132 ResolvedImport {
2133 info: ImportInfo {
2134 source: "react".to_string(),
2135 imported_name: ImportedName::Named("FC".to_string()),
2136 local_name: "FC".to_string(),
2137 is_type_only: true,
2138 from_style: false,
2139 span: oxc_span::Span::new(0, 10),
2140 source_span: oxc_span::Span::default(),
2141 },
2142 target: ResolveResult::NpmPackage("react".to_string()),
2143 },
2144 ResolvedImport {
2145 info: ImportInfo {
2146 source: "react".to_string(),
2147 imported_name: ImportedName::Named("useState".to_string()),
2148 local_name: "useState".to_string(),
2149 is_type_only: false,
2150 from_style: false,
2151 span: oxc_span::Span::new(15, 30),
2152 source_span: oxc_span::Span::default(),
2153 },
2154 target: ResolveResult::NpmPackage("react".to_string()),
2155 },
2156 ],
2157 ..Default::default()
2158 }];
2159
2160 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2161 assert!(graph.package_usage.contains_key("react"));
2162 assert!(graph.type_only_package_usage.contains_key("react"));
2163 }
2164
2165 #[test]
2166 fn graph_default_import_reference() {
2167 let files = vec![
2168 DiscoveredFile {
2169 id: FileId(0),
2170 path: PathBuf::from("/project/entry.ts"),
2171 size_bytes: 100,
2172 },
2173 DiscoveredFile {
2174 id: FileId(1),
2175 path: PathBuf::from("/project/utils.ts"),
2176 size_bytes: 50,
2177 },
2178 ];
2179 let entry_points = vec![EntryPoint {
2180 path: PathBuf::from("/project/entry.ts"),
2181 source: EntryPointSource::PackageJsonMain,
2182 }];
2183 let resolved_modules = vec![
2184 ResolvedModule {
2185 file_id: FileId(0),
2186 path: PathBuf::from("/project/entry.ts"),
2187 resolved_imports: vec![ResolvedImport {
2188 info: ImportInfo {
2189 source: "./utils".to_string(),
2190 imported_name: ImportedName::Default,
2191 local_name: "Utils".to_string(),
2192 is_type_only: false,
2193 from_style: false,
2194 span: oxc_span::Span::new(0, 10),
2195 source_span: oxc_span::Span::default(),
2196 },
2197 target: ResolveResult::InternalModule(FileId(1)),
2198 }],
2199 ..Default::default()
2200 },
2201 ResolvedModule {
2202 file_id: FileId(1),
2203 path: PathBuf::from("/project/utils.ts"),
2204 exports: vec![fallow_types::extract::ExportInfo {
2205 name: ExportName::Default,
2206 local_name: None,
2207 is_type_only: false,
2208 visibility: VisibilityTag::None,
2209 expected_unused_reason: None,
2210 span: oxc_span::Span::new(0, 20),
2211 members: vec![],
2212 is_side_effect_used: false,
2213 super_class: None,
2214 }]
2215 .into(),
2216 ..Default::default()
2217 },
2218 ];
2219
2220 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2221 let utils = &graph.modules[1];
2222 let default_export = utils
2223 .exports
2224 .iter()
2225 .find(|e| matches!(e.name, ExportName::Default))
2226 .unwrap();
2227 assert!(!default_export.references.is_empty());
2228 assert_eq!(
2229 default_export.references[0].kind,
2230 ReferenceKind::DefaultImport
2231 );
2232 }
2233
2234 #[test]
2235 fn graph_side_effect_import_no_export_reference() {
2236 let files = vec![
2237 DiscoveredFile {
2238 id: FileId(0),
2239 path: PathBuf::from("/project/entry.ts"),
2240 size_bytes: 100,
2241 },
2242 DiscoveredFile {
2243 id: FileId(1),
2244 path: PathBuf::from("/project/styles.ts"),
2245 size_bytes: 50,
2246 },
2247 ];
2248 let entry_points = vec![EntryPoint {
2249 path: PathBuf::from("/project/entry.ts"),
2250 source: EntryPointSource::PackageJsonMain,
2251 }];
2252 let resolved_modules = vec![
2253 ResolvedModule {
2254 file_id: FileId(0),
2255 path: PathBuf::from("/project/entry.ts"),
2256 resolved_imports: vec![ResolvedImport {
2257 info: ImportInfo {
2258 source: "./styles".to_string(),
2259 imported_name: ImportedName::SideEffect,
2260 local_name: String::new(),
2261 is_type_only: false,
2262 from_style: false,
2263 span: oxc_span::Span::new(0, 10),
2264 source_span: oxc_span::Span::default(),
2265 },
2266 target: ResolveResult::InternalModule(FileId(1)),
2267 }],
2268 ..Default::default()
2269 },
2270 ResolvedModule {
2271 file_id: FileId(1),
2272 path: PathBuf::from("/project/styles.ts"),
2273 exports: vec![fallow_types::extract::ExportInfo {
2274 name: ExportName::Named("primaryColor".to_string()),
2275 local_name: Some("primaryColor".to_string()),
2276 is_type_only: false,
2277 visibility: VisibilityTag::None,
2278 expected_unused_reason: None,
2279 span: oxc_span::Span::new(0, 20),
2280 members: vec![],
2281 is_side_effect_used: false,
2282 super_class: None,
2283 }]
2284 .into(),
2285 ..Default::default()
2286 },
2287 ];
2288
2289 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2290 assert_eq!(graph.edge_count(), 1);
2291 let styles = &graph.modules[1];
2292 assert!(styles.is_reachable());
2293 let export = &styles.exports[0];
2294 assert!(
2295 export.references.is_empty(),
2296 "side-effect import should not reference named exports"
2297 );
2298
2299 let encoded = postcard::to_allocvec(&graph).expect("encode graph");
2300 let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
2301 assert_eq!(decoded.edge_count(), 1);
2302 assert!(decoded.modules[1].is_reachable());
2303 assert!(decoded.modules[1].exports[0].references.is_empty());
2304 }
2305
2306 #[test]
2307 fn graph_multiple_entry_points() {
2308 let files = vec![
2309 DiscoveredFile {
2310 id: FileId(0),
2311 path: PathBuf::from("/project/main.ts"),
2312 size_bytes: 100,
2313 },
2314 DiscoveredFile {
2315 id: FileId(1),
2316 path: PathBuf::from("/project/worker.ts"),
2317 size_bytes: 100,
2318 },
2319 DiscoveredFile {
2320 id: FileId(2),
2321 path: PathBuf::from("/project/shared.ts"),
2322 size_bytes: 50,
2323 },
2324 ];
2325 let entry_points = vec![
2326 EntryPoint {
2327 path: PathBuf::from("/project/main.ts"),
2328 source: EntryPointSource::PackageJsonMain,
2329 },
2330 EntryPoint {
2331 path: PathBuf::from("/project/worker.ts"),
2332 source: EntryPointSource::PackageJsonMain,
2333 },
2334 ];
2335 let resolved_modules = vec![
2336 ResolvedModule {
2337 file_id: FileId(0),
2338 path: PathBuf::from("/project/main.ts"),
2339 resolved_imports: vec![ResolvedImport {
2340 info: ImportInfo {
2341 source: "./shared".to_string(),
2342 imported_name: ImportedName::Named("helper".to_string()),
2343 local_name: "helper".to_string(),
2344 is_type_only: false,
2345 from_style: false,
2346 span: oxc_span::Span::new(0, 10),
2347 source_span: oxc_span::Span::default(),
2348 },
2349 target: ResolveResult::InternalModule(FileId(2)),
2350 }],
2351 ..Default::default()
2352 },
2353 ResolvedModule {
2354 file_id: FileId(1),
2355 path: PathBuf::from("/project/worker.ts"),
2356 ..Default::default()
2357 },
2358 ResolvedModule {
2359 file_id: FileId(2),
2360 path: PathBuf::from("/project/shared.ts"),
2361 exports: vec![fallow_types::extract::ExportInfo {
2362 name: ExportName::Named("helper".to_string()),
2363 local_name: Some("helper".to_string()),
2364 is_type_only: false,
2365 visibility: VisibilityTag::None,
2366 expected_unused_reason: None,
2367 span: oxc_span::Span::new(0, 20),
2368 members: vec![],
2369 is_side_effect_used: false,
2370 super_class: None,
2371 }]
2372 .into(),
2373 ..Default::default()
2374 },
2375 ];
2376
2377 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2378 assert!(graph.modules[0].is_entry_point());
2379 assert!(graph.modules[1].is_entry_point());
2380 assert!(!graph.modules[2].is_entry_point());
2381 assert!(graph.modules[0].is_reachable());
2382 assert!(graph.modules[1].is_reachable());
2383 assert!(graph.modules[2].is_reachable());
2384 }
2385}