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