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