1mod build;
7mod cycles;
8mod fan_io;
9mod impact_closure;
10mod namespace_aliases;
11mod namespace_indexes;
12mod namespace_re_exports;
13mod narrowing;
14mod partition_order;
15mod public_exports;
16mod re_exports;
17mod reachability;
18pub mod types;
19
20use std::path::Path;
21
22use fixedbitset::FixedBitSet;
23use rustc_hash::{FxHashMap, FxHashSet};
24
25use crate::resolve::{ResolvedModule, ResolvedReplacedModuleTarget};
26use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
27use fallow_types::extract::{ImportedName, ModuleLoadMechanism};
28use types::{ReferencePathInterner, ReferencePathNode, ReferenceRouteNodeId, ReferenceRoutes};
29
30pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
31pub use impact_closure::{
32 CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
33};
34pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
35pub use re_exports::GraphReExportCycle;
36pub use types::{
37 ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, ReferencePathId, SymbolReference,
38};
39
40fn is_declaration_file_path(path: &Path) -> bool {
47 path.file_name()
48 .and_then(|n| n.to_str())
49 .is_some_and(|name| {
50 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
51 })
52}
53
54#[derive(Debug, serde::Serialize, serde::Deserialize)]
62pub struct ModuleGraph {
63 pub modules: Vec<ModuleNode>,
73 edges: Vec<Edge>,
75 pub package_usage: FxHashMap<String, Vec<FileId>>,
77 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
81 pub entry_points: FxHashSet<FileId>,
83 pub runtime_entry_points: FxHashSet<FileId>,
85 pub test_entry_points: FxHashSet<FileId>,
87 test_reachability_index: TestReachabilityIndex,
92 reference_paths: Vec<ReferencePathNode>,
94 reference_routes: ReferenceRoutes,
96 pub reverse_deps: Vec<Vec<FileId>>,
98 #[serde(skip, default)]
106 namespace_imported: FixedBitSet,
107 pub re_export_cycles: Vec<GraphReExportCycle>,
114}
115
116#[derive(Debug, serde::Serialize, serde::Deserialize)]
123pub struct Edge {
124 source: FileId,
126 target: FileId,
128 symbols: Vec<ImportedSymbol>,
130}
131
132#[derive(Debug, serde::Serialize, serde::Deserialize)]
134pub struct ImportedSymbol {
135 pub imported_name: ImportedName,
138 pub local_name: String,
140 #[serde(with = "crate::cache::span_serde")]
142 pub import_span: oxc_span::Span,
143 pub is_type_only: bool,
146 mechanism: ModuleLoadMechanism,
148}
149
150#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
158struct TestReachabilityIndex {
159 profile_count: usize,
160 words_per_file: usize,
161 reachable_profiles: Vec<u64>,
162 masked_profiles: Vec<MaskedTestProfiles>,
163}
164
165#[derive(Debug, serde::Serialize, serde::Deserialize)]
167struct MaskedTestProfiles {
168 target: FileId,
169 profiles: Vec<u64>,
170}
171
172impl TestReachabilityIndex {
173 fn new(file_capacity: usize, profile_count: usize) -> Self {
174 let words_per_file = profile_count.div_ceil(u64::BITS as usize);
175 let storage_len = file_capacity.saturating_mul(words_per_file);
176 Self {
177 profile_count,
178 words_per_file,
179 reachable_profiles: vec![0; storage_len],
180 masked_profiles: Vec::new(),
181 }
182 }
183
184 fn set_sparse_masks(&mut self, masks: FxHashMap<FileId, Vec<u64>>) {
185 let mut rows: Vec<_> = masks
186 .into_iter()
187 .map(|(target, profiles)| MaskedTestProfiles { target, profiles })
188 .collect();
189 rows.sort_unstable_by_key(|row| row.target.0);
190 self.masked_profiles = rows;
191 }
192
193 fn profiles_for<'a>(&self, storage: &'a [u64], file_id: FileId) -> Option<&'a [u64]> {
194 let start = (file_id.0 as usize).checked_mul(self.words_per_file)?;
195 let end = start.checked_add(self.words_per_file)?;
196 storage.get(start..end)
197 }
198
199 fn masked_profiles_for(&self, file_id: FileId) -> Option<&[u64]> {
200 self.masked_profiles
201 .binary_search_by_key(&file_id.0, |row| row.target.0)
202 .ok()
203 .map(|index| self.masked_profiles[index].profiles.as_slice())
204 }
205
206 fn covers_reference_path(
207 &self,
208 source: FileId,
209 path: types::ReferencePathId,
210 paths: &[ReferencePathNode],
211 routes: &ReferenceRoutes,
212 ) -> bool {
213 let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
214 return false;
215 };
216
217 for (word_index, &source_word) in source_profiles.iter().enumerate() {
218 let mut active_profiles = source_word;
219 if active_profiles == 0 {
220 continue;
221 }
222
223 let mut next = Some(path);
224 while let Some(path_id) = next {
225 let Some(path_node) = paths.get(path_id.index()) else {
226 return false;
227 };
228 next = path_node.parent();
229 active_profiles = match *path_node {
230 ReferencePathNode::Hop {
231 target, mechanism, ..
232 } => self.active_hop_profiles(target, mechanism, word_index, active_profiles),
233 ReferencePathNode::Route {
234 graph,
235 start,
236 terminal,
237 start_mechanism,
238 ..
239 } => self.active_route_profiles(
240 routes,
241 graph,
242 start,
243 terminal,
244 start_mechanism,
245 word_index,
246 active_profiles,
247 ),
248 };
249 if active_profiles == 0 {
250 break;
251 }
252 }
253
254 if active_profiles != 0 {
255 return true;
256 }
257 }
258
259 false
260 }
261
262 #[cfg(test)]
263 fn covers_path<I>(&self, source: FileId, hops: &I) -> bool
264 where
265 I: Iterator<Item = (FileId, ModuleLoadMechanism)> + Clone,
266 {
267 let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
268 return false;
269 };
270 for (word_index, &source_word) in source_profiles.iter().enumerate() {
271 let mut active_profiles = source_word;
272 for (target, mechanism) in (*hops).clone() {
273 active_profiles =
274 self.active_hop_profiles(target, mechanism, word_index, active_profiles);
275 if active_profiles == 0 {
276 break;
277 }
278 }
279 if active_profiles != 0 {
280 return true;
281 }
282 }
283 false
284 }
285
286 fn active_hop_profiles(
287 &self,
288 target: FileId,
289 mechanism: ModuleLoadMechanism,
290 word_index: usize,
291 mut active_profiles: u64,
292 ) -> u64 {
293 let Some(target_word) = self
294 .profiles_for(&self.reachable_profiles, target)
295 .and_then(|profiles| profiles.get(word_index))
296 else {
297 return 0;
298 };
299 active_profiles &= target_word;
300 if matches!(mechanism, ModuleLoadMechanism::EsModule)
301 && let Some(masked_profiles) = self.masked_profiles_for(target)
302 {
303 let Some(masked_word) = masked_profiles.get(word_index) else {
304 return 0;
305 };
306 active_profiles &= !masked_word;
307 }
308 active_profiles
309 }
310
311 #[expect(
315 clippy::too_many_arguments,
316 reason = "the route identity and profile word form one evaluation contract"
317 )]
318 fn active_route_profiles(
319 &self,
320 routes: &ReferenceRoutes,
321 graph_id: types::ReferenceRouteGraphId,
322 start: ReferenceRouteNodeId,
323 terminal: ReferenceRouteNodeId,
324 start_mechanism: Option<ModuleLoadMechanism>,
325 word_index: usize,
326 candidate_profiles: u64,
327 ) -> u64 {
328 let Some(graph) = routes.graphs.get(graph_id.0 as usize) else {
329 return 0;
330 };
331 let node_count = graph.nodes.end.saturating_sub(graph.nodes.start) as usize;
332 let start_index = start.0 as usize;
333 let terminal_index = terminal.0 as usize;
334 if start_index >= node_count || terminal_index >= node_count {
335 return 0;
336 }
337
338 let mut attempted = vec![0_u64; node_count];
339 let mut pending = vec![0_u64; node_count];
340 let mut queued = vec![false; node_count];
341 let mut queue = std::collections::VecDeque::from([start_index]);
342 pending[start_index] = candidate_profiles;
343 queued[start_index] = true;
344 let mut successful_profiles = 0_u64;
345
346 while let Some(local_index) = queue.pop_front() {
347 queued[local_index] = false;
348 let incoming = pending[local_index] & !attempted[local_index];
349 pending[local_index] = 0;
350 attempted[local_index] |= incoming;
351 if incoming == 0 {
352 continue;
353 }
354
355 let Some(node) = routes.nodes.get(graph.nodes.start as usize + local_index) else {
356 return 0;
357 };
358 let active = if local_index == start_index {
359 start_mechanism.map_or(incoming, |mechanism| {
360 self.active_hop_profiles(node.target, mechanism, word_index, incoming)
361 })
362 } else {
363 self.active_hop_profiles(node.target, node.mechanism, word_index, incoming)
364 };
365 if active == 0 {
366 continue;
367 }
368 if local_index == terminal_index {
369 successful_profiles |= active;
370 continue;
371 }
372
373 let Some(successors) = routes
374 .edges
375 .get(node.successors.start as usize..node.successors.end as usize)
376 else {
377 return 0;
378 };
379 for successor in successors {
380 let successor_index = successor.0 as usize;
381 if successor_index >= node_count {
382 return 0;
383 }
384 let new_profiles = active & !attempted[successor_index] & !pending[successor_index];
385 if new_profiles == 0 {
386 continue;
387 }
388 pending[successor_index] |= new_profiles;
389 if !queued[successor_index] {
390 queued[successor_index] = true;
391 queue.push_back(successor_index);
392 }
393 }
394 }
395
396 successful_profiles
397 }
398
399 #[cfg(test)]
400 fn profile_contains(&self, storage: &[u64], file_id: FileId, profile: usize) -> bool {
401 self.profiles_for(storage, file_id)
402 .and_then(|words| words.get(profile / u64::BITS as usize))
403 .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
404 }
405
406 #[cfg(test)]
407 fn profile_reaches(&self, file_id: FileId, profile: usize) -> bool {
408 self.profile_contains(&self.reachable_profiles, file_id, profile)
409 }
410
411 #[cfg(test)]
412 fn profile_masks(&self, file_id: FileId, profile: usize) -> bool {
413 self.masked_profiles_for(file_id)
414 .and_then(|words| words.get(profile / u64::BITS as usize))
415 .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct DirectImporterSummary {
422 pub source: FileId,
424 pub symbols: Vec<ImportedSymbolSummary>,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct ImportedSymbolSummary {
431 pub imported: String,
434 pub local: String,
436 pub type_only: bool,
438}
439
440#[cfg(target_pointer_width = "64")]
441const _: () = assert!(std::mem::size_of::<Edge>() == 32);
442#[cfg(target_pointer_width = "64")]
443const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
444
445#[cold]
446#[inline(never)]
447fn propagate_namespace_references(
448 graph: &mut ModuleGraph,
449 module_by_id: &FxHashMap<FileId, &ResolvedModule>,
450 features: build::NamespaceFeatures,
451 reference_paths: &mut ReferencePathInterner,
452) {
453 let indexes = namespace_indexes::NamespacePropagationIndexes::new(graph, module_by_id);
454 if features.has_aliases {
455 namespace_aliases::propagate_cross_package_aliases(
456 graph,
457 module_by_id,
458 &indexes,
459 reference_paths,
460 );
461 }
462 if features.has_re_exports {
463 namespace_re_exports::propagate_namespace_re_exports(graph, &indexes, reference_paths);
464 }
465}
466
467impl ModuleGraph {
468 fn resolve_entry_point_ids(
469 entry_points: &[EntryPoint],
470 path_to_id: &FxHashMap<&Path, FileId>,
471 ) -> FxHashSet<FileId> {
472 entry_points
473 .iter()
474 .filter_map(|ep| {
475 path_to_id.get(ep.path.as_path()).copied().or_else(|| {
476 dunce::canonicalize(&ep.path)
477 .ok()
478 .and_then(|path| path_to_id.get(path.as_path()).copied())
479 })
480 })
481 .collect()
482 }
483
484 pub fn build(
486 resolved_modules: &[ResolvedModule],
487 entry_points: &[EntryPoint],
488 files: &[DiscoveredFile],
489 ) -> Self {
490 Self::build_with_reachability_roots(
491 resolved_modules,
492 entry_points,
493 entry_points,
494 &[],
495 files,
496 )
497 }
498
499 pub fn build_with_reachability_roots(
501 resolved_modules: &[ResolvedModule],
502 entry_points: &[EntryPoint],
503 runtime_entry_points: &[EntryPoint],
504 test_entry_points: &[EntryPoint],
505 files: &[DiscoveredFile],
506 ) -> Self {
507 Self::build_with_reachability_roots_and_replacements(
508 resolved_modules,
509 &[],
510 entry_points,
511 runtime_entry_points,
512 test_entry_points,
513 files,
514 )
515 }
516
517 pub fn build_with_reachability_roots_and_replacements(
519 resolved_modules: &[ResolvedModule],
520 replaced_module_targets: &[ResolvedReplacedModuleTarget],
521 entry_points: &[EntryPoint],
522 runtime_entry_points: &[EntryPoint],
523 test_entry_points: &[EntryPoint],
524 files: &[DiscoveredFile],
525 ) -> Self {
526 let _span = tracing::info_span!("build_graph").entered();
527
528 let module_count = files.len();
529
530 let max_file_id = files
531 .iter()
532 .map(|f| f.id.0 as usize)
533 .max()
534 .map_or(0, |m| m + 1);
535 let total_capacity = max_file_id.max(module_count);
536
537 let path_to_id: FxHashMap<&Path, FileId> =
538 files.iter().map(|f| (f.path.as_path(), f.id)).collect();
539
540 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
541 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
542
543 let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
544 let runtime_entry_point_ids =
545 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
546 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
547
548 for file in files {
549 if is_declaration_file_path(&file.path) {
550 entry_point_ids.insert(file.id);
551 }
552 }
553
554 let (mut graph, namespace_features) = Self::populate_edges(&build::PopulateEdgesInput {
555 files,
556 module_by_id: &module_by_id,
557 entry_point_ids: &entry_point_ids,
558 runtime_entry_point_ids: &runtime_entry_point_ids,
559 test_entry_point_ids: &test_entry_point_ids,
560 module_count,
561 total_capacity,
562 });
563
564 let test_reachability_plan = reachability::TestReachabilityPlan::new(
565 &test_entry_point_ids,
566 replaced_module_targets,
567 total_capacity,
568 );
569
570 let mut reference_paths =
571 ReferencePathInterner::new(test_reachability_plan.requires_reference_provenance());
572 graph.populate_references(&module_by_id, &entry_point_ids, &mut reference_paths);
573
574 if namespace_features.has_aliases || namespace_features.has_re_exports {
575 propagate_namespace_references(
576 &mut graph,
577 &module_by_id,
578 namespace_features,
579 &mut reference_paths,
580 );
581 }
582
583 graph.mark_reachable(
584 &entry_point_ids,
585 &runtime_entry_point_ids,
586 test_reachability_plan,
587 total_capacity,
588 );
589
590 graph.re_export_cycles =
591 graph.resolve_re_export_chains(&module_by_id, &mut reference_paths);
592 let finalized_paths = reference_paths.finalize(&mut graph.modules);
593 graph.reference_paths = finalized_paths.paths;
594 graph.reference_routes = finalized_paths.routes;
595
596 graph
597 }
598
599 #[must_use]
601 pub const fn module_count(&self) -> usize {
602 self.modules.len()
603 }
604
605 #[must_use]
607 pub const fn edge_count(&self) -> usize {
608 self.edges.len()
609 }
610
611 #[must_use]
613 pub fn is_test_reachable(&self, file_id: FileId) -> bool {
614 self.modules
615 .get(file_id.0 as usize)
616 .is_some_and(ModuleNode::is_test_reachable)
617 }
618
619 #[must_use]
627 pub fn is_test_reference_covered(&self, export: &ExportSymbol, reference_index: usize) -> bool {
628 let Some(reference) = export.references.get(reference_index) else {
629 return false;
630 };
631 if self.test_reachability_index.profile_count == 0 {
632 return self.is_test_reachable(reference.from_file);
633 }
634
635 let Some(path) = export.reference_path(reference_index) else {
636 return false;
637 };
638
639 self.test_reachability_index.covers_reference_path(
640 reference.from_file,
641 path,
642 &self.reference_paths,
643 &self.reference_routes,
644 )
645 }
646
647 #[must_use]
650 pub fn is_any_test_reference_covered(&self, export: &ExportSymbol) -> bool {
651 (0..export.references.len())
652 .any(|reference_index| self.is_test_reference_covered(export, reference_index))
653 }
654
655 #[cfg(test)]
656 fn reference_path_hops(
657 &self,
658 export: &ExportSymbol,
659 reference_index: usize,
660 ) -> Vec<(FileId, ModuleLoadMechanism)> {
661 let mut hops = Vec::new();
662 let mut next = export.reference_path(reference_index);
663 while let Some(path_id) = next {
664 let Some(node) = self.reference_paths.get(path_id.index()) else {
665 return Vec::new();
666 };
667 next = node.parent();
668 match *node {
669 ReferencePathNode::Hop {
670 target, mechanism, ..
671 } => hops.push((target, mechanism)),
672 ReferencePathNode::Route {
673 graph,
674 start,
675 terminal,
676 start_mechanism,
677 ..
678 } => hops.extend(self.reference_routes.canonical_hops(
679 graph,
680 start,
681 terminal,
682 start_mechanism,
683 )),
684 }
685 }
686 hops
687 }
688
689 pub(crate) fn reconstruct_namespace_imported(&mut self) {
704 let capacity = self
705 .edges
706 .iter()
707 .map(|edge| edge.target.0 as usize + 1)
708 .max()
709 .unwrap_or(0)
710 .max(self.modules.len());
711 let mut bitset = FixedBitSet::with_capacity(capacity);
712 for edge in &self.edges {
713 if edge
714 .symbols
715 .iter()
716 .any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
717 {
718 let idx = edge.target.0 as usize;
719 if idx < capacity {
720 bitset.insert(idx);
721 }
722 }
723 }
724 self.namespace_imported = bitset;
725 }
726
727 #[must_use]
730 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
731 let idx = file_id.0 as usize;
732 if idx >= self.namespace_imported.len() {
733 return false;
734 }
735 self.namespace_imported.contains(idx)
736 }
737
738 #[must_use]
740 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
741 let idx = file_id.0 as usize;
742 if idx >= self.modules.len() {
743 return Vec::new();
744 }
745 let range = &self.modules[idx].edge_range;
746 self.edges[range.clone()].iter().map(|e| e.target).collect()
747 }
748
749 pub fn outgoing_symbol_edges(
755 &self,
756 file_id: FileId,
757 ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
758 let idx = file_id.0 as usize;
759 let range = if idx < self.modules.len() {
760 self.modules[idx].edge_range.clone()
761 } else {
762 0..0
763 };
764 self.edges[range]
765 .iter()
766 .map(|edge| (edge.target, edge.symbols.as_slice()))
767 }
768
769 #[must_use]
773 pub fn importers_of(&self, target: FileId) -> &[FileId] {
774 self.reverse_deps
775 .get(target.0 as usize)
776 .map_or(&[], Vec::as_slice)
777 }
778
779 #[must_use]
784 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
785 let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
786 return Vec::new();
787 };
788
789 let mut summaries = Vec::new();
790 for &source in importers {
791 let idx = source.0 as usize;
792 let Some(source_node) = self.modules.get(idx) else {
793 continue;
794 };
795 let mut symbols = Vec::new();
796 for edge in &self.edges[source_node.edge_range.clone()] {
797 if edge.target != target {
798 continue;
799 }
800 symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
801 imported: imported_name_label(&symbol.imported_name),
802 local: symbol.local_name.clone(),
803 type_only: symbol.is_type_only,
804 }));
805 }
806 symbols.sort_by(|a, b| {
807 a.imported
808 .cmp(&b.imported)
809 .then_with(|| a.local.cmp(&b.local))
810 .then_with(|| a.type_only.cmp(&b.type_only))
811 });
812 symbols.dedup();
813 summaries.push(DirectImporterSummary { source, symbols });
814 }
815 summaries.sort_by_key(|summary| summary.source.0);
816 summaries
817 }
818
819 #[must_use]
826 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
827 let idx = source.0 as usize;
828 if idx >= self.modules.len() {
829 return None;
830 }
831 let range = &self.modules[idx].edge_range;
832 for edge in &self.edges[range.clone()] {
833 if edge.target == target {
834 return edge
835 .symbols
836 .iter()
837 .find(|s| !s.is_type_only)
838 .or_else(|| edge.symbols.first())
839 .map(|s| s.import_span.start);
840 }
841 }
842 None
843 }
844
845 pub fn outgoing_edge_summaries(
860 &self,
861 file_id: FileId,
862 ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
863 let idx = file_id.0 as usize;
864 let range = if idx < self.modules.len() {
865 self.modules[idx].edge_range.clone()
866 } else {
867 0..0
868 };
869 self.edges[range].iter().map(|edge| {
870 let all_type_only =
871 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
872 let span = edge
873 .symbols
874 .iter()
875 .find(|s| !s.is_type_only)
876 .or_else(|| edge.symbols.first())
877 .map(|s| s.import_span.start);
878 (edge.target, all_type_only, span)
879 })
880 }
881
882 pub fn outgoing_edge_summaries_with_exclusions<'a>(
893 &'a self,
894 file_id: FileId,
895 excluded_span_starts: &'a FxHashSet<u32>,
896 ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
897 let idx = file_id.0 as usize;
898 let range = if idx < self.modules.len() {
899 self.modules[idx].edge_range.clone()
900 } else {
901 0..0
902 };
903 self.edges[range].iter().map(move |edge| {
904 let all_type_only =
905 !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
906 let span = edge
907 .symbols
908 .iter()
909 .find(|s| !s.is_type_only)
910 .or_else(|| edge.symbols.first())
911 .map(|s| s.import_span.start);
912 let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
916 let all_client_only = value_symbols.peek().is_some()
917 && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
918 (edge.target, all_type_only, span, all_client_only)
919 })
920 }
921}
922
923fn imported_name_label(name: &ImportedName) -> String {
924 match name {
925 ImportedName::Named(name) => name.clone(),
926 ImportedName::Default => "default".to_string(),
927 ImportedName::Namespace => "*".to_string(),
928 ImportedName::SideEffect => "side-effect".to_string(),
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
936 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
937 use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
938 use std::path::PathBuf;
939
940 fn build_simple_graph() -> ModuleGraph {
941 let files = vec![
942 DiscoveredFile {
943 id: FileId(0),
944 path: PathBuf::from("/project/src/entry.ts"),
945 size_bytes: 100,
946 },
947 DiscoveredFile {
948 id: FileId(1),
949 path: PathBuf::from("/project/src/utils.ts"),
950 size_bytes: 50,
951 },
952 ];
953
954 let entry_points = vec![EntryPoint {
955 path: PathBuf::from("/project/src/entry.ts"),
956 source: EntryPointSource::PackageJsonMain,
957 }];
958
959 let resolved_modules = vec![
960 ResolvedModule {
961 file_id: FileId(0),
962 path: PathBuf::from("/project/src/entry.ts"),
963 resolved_imports: vec![ResolvedImport {
964 info: ImportInfo {
965 source: "./utils".to_string(),
966 imported_name: ImportedName::Named("foo".to_string()),
967 local_name: "foo".to_string(),
968 is_type_only: false,
969 from_style: false,
970 span: oxc_span::Span::new(0, 10),
971 source_span: oxc_span::Span::default(),
972 },
973 target: ResolveResult::InternalModule(FileId(1)),
974 }],
975 ..Default::default()
976 },
977 ResolvedModule {
978 file_id: FileId(1),
979 path: PathBuf::from("/project/src/utils.ts"),
980 exports: vec![
981 fallow_types::extract::ExportInfo {
982 name: ExportName::Named("foo".to_string()),
983 local_name: Some("foo".to_string()),
984 is_type_only: false,
985 visibility: VisibilityTag::None,
986 expected_unused_reason: None,
987 span: oxc_span::Span::new(0, 20),
988 members: vec![],
989 is_side_effect_used: false,
990 super_class: None,
991 },
992 fallow_types::extract::ExportInfo {
993 name: ExportName::Named("bar".to_string()),
994 local_name: Some("bar".to_string()),
995 is_type_only: false,
996 visibility: VisibilityTag::None,
997 expected_unused_reason: None,
998 span: oxc_span::Span::new(25, 45),
999 members: vec![],
1000 is_side_effect_used: false,
1001 super_class: None,
1002 },
1003 ],
1004 ..Default::default()
1005 },
1006 ];
1007
1008 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1009 }
1010
1011 #[test]
1012 fn graph_module_count() {
1013 let graph = build_simple_graph();
1014 assert_eq!(graph.module_count(), 2);
1015 }
1016
1017 #[test]
1018 fn graph_edge_count() {
1019 let graph = build_simple_graph();
1020 assert_eq!(graph.edge_count(), 1);
1021 }
1022
1023 #[test]
1024 fn graph_entry_point_is_reachable() {
1025 let graph = build_simple_graph();
1026 assert!(graph.modules[0].is_entry_point());
1027 assert!(graph.modules[0].is_reachable());
1028 }
1029
1030 #[test]
1031 fn graph_imported_module_is_reachable() {
1032 let graph = build_simple_graph();
1033 assert!(!graph.modules[1].is_entry_point());
1034 assert!(graph.modules[1].is_reachable());
1035 }
1036
1037 #[test]
1038 #[expect(
1039 clippy::too_many_lines,
1040 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
1041 would obscure the cross-role assertions"
1042 )]
1043 fn graph_distinguishes_runtime_test_and_support_reachability() {
1044 let files = vec![
1045 DiscoveredFile {
1046 id: FileId(0),
1047 path: PathBuf::from("/project/src/main.ts"),
1048 size_bytes: 100,
1049 },
1050 DiscoveredFile {
1051 id: FileId(1),
1052 path: PathBuf::from("/project/src/runtime-only.ts"),
1053 size_bytes: 50,
1054 },
1055 DiscoveredFile {
1056 id: FileId(2),
1057 path: PathBuf::from("/project/tests/app.test.ts"),
1058 size_bytes: 50,
1059 },
1060 DiscoveredFile {
1061 id: FileId(3),
1062 path: PathBuf::from("/project/tests/setup.ts"),
1063 size_bytes: 50,
1064 },
1065 DiscoveredFile {
1066 id: FileId(4),
1067 path: PathBuf::from("/project/src/covered.ts"),
1068 size_bytes: 50,
1069 },
1070 ];
1071
1072 let all_entry_points = vec![
1073 EntryPoint {
1074 path: PathBuf::from("/project/src/main.ts"),
1075 source: EntryPointSource::PackageJsonMain,
1076 },
1077 EntryPoint {
1078 path: PathBuf::from("/project/tests/app.test.ts"),
1079 source: EntryPointSource::TestFile,
1080 },
1081 EntryPoint {
1082 path: PathBuf::from("/project/tests/setup.ts"),
1083 source: EntryPointSource::Plugin {
1084 name: "vitest".to_string(),
1085 },
1086 },
1087 ];
1088 let runtime_entry_points = vec![EntryPoint {
1089 path: PathBuf::from("/project/src/main.ts"),
1090 source: EntryPointSource::PackageJsonMain,
1091 }];
1092 let test_entry_points = vec![EntryPoint {
1093 path: PathBuf::from("/project/tests/app.test.ts"),
1094 source: EntryPointSource::TestFile,
1095 }];
1096
1097 let resolved_modules = vec![
1098 ResolvedModule {
1099 file_id: FileId(0),
1100 path: PathBuf::from("/project/src/main.ts"),
1101 resolved_imports: vec![ResolvedImport {
1102 info: ImportInfo {
1103 source: "./runtime-only".to_string(),
1104 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1105 local_name: "runtimeOnly".to_string(),
1106 is_type_only: false,
1107 from_style: false,
1108 span: oxc_span::Span::new(0, 10),
1109 source_span: oxc_span::Span::default(),
1110 },
1111 target: ResolveResult::InternalModule(FileId(1)),
1112 }],
1113 ..Default::default()
1114 },
1115 ResolvedModule {
1116 file_id: FileId(1),
1117 path: PathBuf::from("/project/src/runtime-only.ts"),
1118 exports: vec![fallow_types::extract::ExportInfo {
1119 name: ExportName::Named("runtimeOnly".to_string()),
1120 local_name: Some("runtimeOnly".to_string()),
1121 is_type_only: false,
1122 visibility: VisibilityTag::None,
1123 expected_unused_reason: None,
1124 span: oxc_span::Span::new(0, 20),
1125 members: vec![],
1126 is_side_effect_used: false,
1127 super_class: None,
1128 }],
1129 ..Default::default()
1130 },
1131 ResolvedModule {
1132 file_id: FileId(2),
1133 path: PathBuf::from("/project/tests/app.test.ts"),
1134 resolved_imports: vec![ResolvedImport {
1135 info: ImportInfo {
1136 source: "../src/covered".to_string(),
1137 imported_name: ImportedName::Named("covered".to_string()),
1138 local_name: "covered".to_string(),
1139 is_type_only: false,
1140 from_style: false,
1141 span: oxc_span::Span::new(0, 10),
1142 source_span: oxc_span::Span::default(),
1143 },
1144 target: ResolveResult::InternalModule(FileId(4)),
1145 }],
1146 ..Default::default()
1147 },
1148 ResolvedModule {
1149 file_id: FileId(3),
1150 path: PathBuf::from("/project/tests/setup.ts"),
1151 resolved_imports: vec![ResolvedImport {
1152 info: ImportInfo {
1153 source: "../src/runtime-only".to_string(),
1154 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1155 local_name: "runtimeOnly".to_string(),
1156 is_type_only: false,
1157 from_style: false,
1158 span: oxc_span::Span::new(0, 10),
1159 source_span: oxc_span::Span::default(),
1160 },
1161 target: ResolveResult::InternalModule(FileId(1)),
1162 }],
1163 ..Default::default()
1164 },
1165 ResolvedModule {
1166 file_id: FileId(4),
1167 path: PathBuf::from("/project/src/covered.ts"),
1168 exports: vec![fallow_types::extract::ExportInfo {
1169 name: ExportName::Named("covered".to_string()),
1170 local_name: Some("covered".to_string()),
1171 is_type_only: false,
1172 visibility: VisibilityTag::None,
1173 expected_unused_reason: None,
1174 span: oxc_span::Span::new(0, 20),
1175 members: vec![],
1176 is_side_effect_used: false,
1177 super_class: None,
1178 }],
1179 ..Default::default()
1180 },
1181 ];
1182
1183 let graph = ModuleGraph::build_with_reachability_roots(
1184 &resolved_modules,
1185 &all_entry_points,
1186 &runtime_entry_points,
1187 &test_entry_points,
1188 &files,
1189 );
1190
1191 assert!(graph.modules[1].is_reachable());
1192 assert!(graph.modules[1].is_runtime_reachable());
1193 assert!(
1194 !graph.modules[1].is_test_reachable(),
1195 "support roots should not make runtime-only modules test reachable"
1196 );
1197
1198 assert!(graph.modules[4].is_reachable());
1199 assert!(graph.modules[4].is_test_reachable());
1200 assert!(
1201 !graph.modules[4].is_runtime_reachable(),
1202 "test-only reachability should stay separate from runtime roots"
1203 );
1204 }
1205
1206 #[test]
1207 fn graph_export_has_reference() {
1208 let graph = build_simple_graph();
1209 let utils = &graph.modules[1];
1210 let foo_export = utils
1211 .exports
1212 .iter()
1213 .find(|e| e.name.to_string() == "foo")
1214 .unwrap();
1215 assert!(
1216 !foo_export.references.is_empty(),
1217 "foo should have references"
1218 );
1219 }
1220
1221 #[test]
1222 fn graph_unused_export_no_reference() {
1223 let graph = build_simple_graph();
1224 let utils = &graph.modules[1];
1225 let bar_export = utils
1226 .exports
1227 .iter()
1228 .find(|e| e.name.to_string() == "bar")
1229 .unwrap();
1230 assert!(
1231 bar_export.references.is_empty(),
1232 "bar should have no references"
1233 );
1234 }
1235
1236 #[test]
1237 fn graph_no_namespace_import() {
1238 let graph = build_simple_graph();
1239 assert!(!graph.has_namespace_import(FileId(0)));
1240 assert!(!graph.has_namespace_import(FileId(1)));
1241 }
1242
1243 #[test]
1244 fn graph_has_namespace_import() {
1245 let files = vec![
1246 DiscoveredFile {
1247 id: FileId(0),
1248 path: PathBuf::from("/project/entry.ts"),
1249 size_bytes: 100,
1250 },
1251 DiscoveredFile {
1252 id: FileId(1),
1253 path: PathBuf::from("/project/utils.ts"),
1254 size_bytes: 50,
1255 },
1256 ];
1257
1258 let entry_points = vec![EntryPoint {
1259 path: PathBuf::from("/project/entry.ts"),
1260 source: EntryPointSource::PackageJsonMain,
1261 }];
1262
1263 let resolved_modules = vec![
1264 ResolvedModule {
1265 file_id: FileId(0),
1266 path: PathBuf::from("/project/entry.ts"),
1267 resolved_imports: vec![ResolvedImport {
1268 info: ImportInfo {
1269 source: "./utils".to_string(),
1270 imported_name: ImportedName::Namespace,
1271 local_name: "utils".to_string(),
1272 is_type_only: false,
1273 from_style: false,
1274 span: oxc_span::Span::new(0, 10),
1275 source_span: oxc_span::Span::default(),
1276 },
1277 target: ResolveResult::InternalModule(FileId(1)),
1278 }],
1279 ..Default::default()
1280 },
1281 ResolvedModule {
1282 file_id: FileId(1),
1283 path: PathBuf::from("/project/utils.ts"),
1284 exports: vec![fallow_types::extract::ExportInfo {
1285 name: ExportName::Named("foo".to_string()),
1286 local_name: Some("foo".to_string()),
1287 is_type_only: false,
1288 visibility: VisibilityTag::None,
1289 expected_unused_reason: None,
1290 span: oxc_span::Span::new(0, 20),
1291 members: vec![],
1292 is_side_effect_used: false,
1293 super_class: None,
1294 }],
1295 ..Default::default()
1296 },
1297 ];
1298
1299 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1300 assert!(
1301 graph.has_namespace_import(FileId(1)),
1302 "utils should have namespace import"
1303 );
1304 }
1305
1306 #[test]
1307 fn graph_has_namespace_import_out_of_bounds() {
1308 let graph = build_simple_graph();
1309 assert!(!graph.has_namespace_import(FileId(999)));
1310 }
1311
1312 #[test]
1317 fn reconstruct_namespace_imported_matches_fresh_build() {
1318 let files = vec![
1319 DiscoveredFile {
1320 id: FileId(0),
1321 path: PathBuf::from("/project/entry.ts"),
1322 size_bytes: 100,
1323 },
1324 DiscoveredFile {
1325 id: FileId(1),
1326 path: PathBuf::from("/project/utils.ts"),
1327 size_bytes: 50,
1328 },
1329 DiscoveredFile {
1330 id: FileId(2),
1331 path: PathBuf::from("/project/named-only.ts"),
1332 size_bytes: 50,
1333 },
1334 ];
1335 let entry_points = vec![EntryPoint {
1336 path: PathBuf::from("/project/entry.ts"),
1337 source: EntryPointSource::PackageJsonMain,
1338 }];
1339 let resolved_modules = vec![
1340 ResolvedModule {
1341 file_id: FileId(0),
1342 path: PathBuf::from("/project/entry.ts"),
1343 resolved_imports: vec![
1344 ResolvedImport {
1345 info: ImportInfo {
1346 source: "./utils".to_string(),
1347 imported_name: ImportedName::Namespace,
1348 local_name: "utils".to_string(),
1349 is_type_only: false,
1350 from_style: false,
1351 span: oxc_span::Span::new(0, 10),
1352 source_span: oxc_span::Span::default(),
1353 },
1354 target: ResolveResult::InternalModule(FileId(1)),
1355 },
1356 ResolvedImport {
1357 info: ImportInfo {
1358 source: "./named-only".to_string(),
1359 imported_name: ImportedName::Named("foo".to_string()),
1360 local_name: "foo".to_string(),
1361 is_type_only: false,
1362 from_style: false,
1363 span: oxc_span::Span::new(11, 20),
1364 source_span: oxc_span::Span::default(),
1365 },
1366 target: ResolveResult::InternalModule(FileId(2)),
1367 },
1368 ],
1369 ..Default::default()
1370 },
1371 ResolvedModule {
1372 file_id: FileId(1),
1373 path: PathBuf::from("/project/utils.ts"),
1374 ..Default::default()
1375 },
1376 ResolvedModule {
1377 file_id: FileId(2),
1378 path: PathBuf::from("/project/named-only.ts"),
1379 exports: vec![fallow_types::extract::ExportInfo {
1380 name: ExportName::Named("foo".to_string()),
1381 local_name: Some("foo".to_string()),
1382 is_type_only: false,
1383 visibility: VisibilityTag::None,
1384 expected_unused_reason: None,
1385 span: oxc_span::Span::new(0, 20),
1386 members: vec![],
1387 is_side_effect_used: false,
1388 super_class: None,
1389 }],
1390 ..Default::default()
1391 },
1392 ];
1393
1394 let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1395 let fresh = graph.namespace_imported.clone();
1396
1397 assert!(graph.has_namespace_import(FileId(1)));
1399 assert!(!graph.has_namespace_import(FileId(2)));
1400
1401 graph.namespace_imported = FixedBitSet::default();
1404 graph.reconstruct_namespace_imported();
1405
1406 assert_eq!(
1407 graph.namespace_imported, fresh,
1408 "reconstructed namespace_imported must equal the fresh-built bitset"
1409 );
1410 assert!(graph.has_namespace_import(FileId(1)));
1411 assert!(!graph.has_namespace_import(FileId(2)));
1412 }
1413
1414 #[test]
1415 fn graph_unreachable_module() {
1416 let files = vec![
1417 DiscoveredFile {
1418 id: FileId(0),
1419 path: PathBuf::from("/project/entry.ts"),
1420 size_bytes: 100,
1421 },
1422 DiscoveredFile {
1423 id: FileId(1),
1424 path: PathBuf::from("/project/utils.ts"),
1425 size_bytes: 50,
1426 },
1427 DiscoveredFile {
1428 id: FileId(2),
1429 path: PathBuf::from("/project/orphan.ts"),
1430 size_bytes: 30,
1431 },
1432 ];
1433
1434 let entry_points = vec![EntryPoint {
1435 path: PathBuf::from("/project/entry.ts"),
1436 source: EntryPointSource::PackageJsonMain,
1437 }];
1438
1439 let resolved_modules = vec![
1440 ResolvedModule {
1441 file_id: FileId(0),
1442 path: PathBuf::from("/project/entry.ts"),
1443 resolved_imports: vec![ResolvedImport {
1444 info: ImportInfo {
1445 source: "./utils".to_string(),
1446 imported_name: ImportedName::Named("foo".to_string()),
1447 local_name: "foo".to_string(),
1448 is_type_only: false,
1449 from_style: false,
1450 span: oxc_span::Span::new(0, 10),
1451 source_span: oxc_span::Span::default(),
1452 },
1453 target: ResolveResult::InternalModule(FileId(1)),
1454 }],
1455 ..Default::default()
1456 },
1457 ResolvedModule {
1458 file_id: FileId(1),
1459 path: PathBuf::from("/project/utils.ts"),
1460 exports: vec![fallow_types::extract::ExportInfo {
1461 name: ExportName::Named("foo".to_string()),
1462 local_name: Some("foo".to_string()),
1463 is_type_only: false,
1464 visibility: VisibilityTag::None,
1465 expected_unused_reason: None,
1466 span: oxc_span::Span::new(0, 20),
1467 members: vec![],
1468 is_side_effect_used: false,
1469 super_class: None,
1470 }],
1471 ..Default::default()
1472 },
1473 ResolvedModule {
1474 file_id: FileId(2),
1475 path: PathBuf::from("/project/orphan.ts"),
1476 exports: vec![fallow_types::extract::ExportInfo {
1477 name: ExportName::Named("orphan".to_string()),
1478 local_name: Some("orphan".to_string()),
1479 is_type_only: false,
1480 visibility: VisibilityTag::None,
1481 expected_unused_reason: None,
1482 span: oxc_span::Span::new(0, 20),
1483 members: vec![],
1484 is_side_effect_used: false,
1485 super_class: None,
1486 }],
1487 ..Default::default()
1488 },
1489 ];
1490
1491 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1492
1493 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1494 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1495 assert!(
1496 !graph.modules[2].is_reachable(),
1497 "orphan should NOT be reachable"
1498 );
1499 }
1500
1501 #[test]
1502 fn graph_package_usage_tracked() {
1503 let files = vec![DiscoveredFile {
1504 id: FileId(0),
1505 path: PathBuf::from("/project/entry.ts"),
1506 size_bytes: 100,
1507 }];
1508
1509 let entry_points = vec![EntryPoint {
1510 path: PathBuf::from("/project/entry.ts"),
1511 source: EntryPointSource::PackageJsonMain,
1512 }];
1513
1514 let resolved_modules = vec![ResolvedModule {
1515 file_id: FileId(0),
1516 path: PathBuf::from("/project/entry.ts"),
1517 exports: vec![],
1518 re_exports: vec![],
1519 resolved_imports: vec![
1520 ResolvedImport {
1521 info: ImportInfo {
1522 source: "react".to_string(),
1523 imported_name: ImportedName::Default,
1524 local_name: "React".to_string(),
1525 is_type_only: false,
1526 from_style: false,
1527 span: oxc_span::Span::new(0, 10),
1528 source_span: oxc_span::Span::default(),
1529 },
1530 target: ResolveResult::NpmPackage("react".to_string()),
1531 },
1532 ResolvedImport {
1533 info: ImportInfo {
1534 source: "lodash".to_string(),
1535 imported_name: ImportedName::Named("merge".to_string()),
1536 local_name: "merge".to_string(),
1537 is_type_only: false,
1538 from_style: false,
1539 span: oxc_span::Span::new(15, 30),
1540 source_span: oxc_span::Span::default(),
1541 },
1542 target: ResolveResult::NpmPackage("lodash".to_string()),
1543 },
1544 ],
1545 ..Default::default()
1546 }];
1547
1548 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1549 assert!(graph.package_usage.contains_key("react"));
1550 assert!(graph.package_usage.contains_key("lodash"));
1551 assert!(!graph.package_usage.contains_key("express"));
1552 }
1553
1554 #[test]
1555 fn graph_empty() {
1556 let graph = ModuleGraph::build(&[], &[], &[]);
1557 assert_eq!(graph.module_count(), 0);
1558 assert_eq!(graph.edge_count(), 0);
1559 }
1560
1561 #[test]
1567 fn graph_postcard_round_trip_is_lossless() {
1568 let graph = build_simple_graph();
1569
1570 let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1571 let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1572 decoded.reconstruct_namespace_imported();
1574
1575 assert_eq!(decoded.module_count(), graph.module_count());
1576 assert_eq!(decoded.edge_count(), graph.edge_count());
1577 assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1578
1579 let utils = &decoded.modules[1];
1581 let foo = utils
1582 .exports
1583 .iter()
1584 .find(|e| e.name.to_string() == "foo")
1585 .expect("foo export survives round-trip");
1586 assert!(!foo.references.is_empty());
1587 let bar = utils
1588 .exports
1589 .iter()
1590 .find(|e| e.name.to_string() == "bar")
1591 .expect("bar export survives round-trip");
1592 assert!(bar.references.is_empty());
1593
1594 assert!(decoded.modules[0].is_entry_point());
1596 assert!(decoded.modules[0].is_reachable());
1597 assert!(decoded.modules[1].is_reachable());
1598 assert_eq!(decoded.entry_points, graph.entry_points);
1599 }
1600
1601 #[test]
1602 fn graph_cjs_exports_tracked() {
1603 let files = vec![DiscoveredFile {
1604 id: FileId(0),
1605 path: PathBuf::from("/project/entry.ts"),
1606 size_bytes: 100,
1607 }];
1608
1609 let entry_points = vec![EntryPoint {
1610 path: PathBuf::from("/project/entry.ts"),
1611 source: EntryPointSource::PackageJsonMain,
1612 }];
1613
1614 let resolved_modules = vec![ResolvedModule {
1615 file_id: FileId(0),
1616 path: PathBuf::from("/project/entry.ts"),
1617 has_cjs_exports: true,
1618 has_angular_component_template_url: false,
1619 ..Default::default()
1620 }];
1621
1622 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1623 assert!(graph.modules[0].has_cjs_exports());
1624 }
1625
1626 #[test]
1627 fn graph_edges_for_returns_targets() {
1628 let graph = build_simple_graph();
1629 let targets = graph.edges_for(FileId(0));
1630 assert_eq!(targets, vec![FileId(1)]);
1631 }
1632
1633 #[test]
1634 fn graph_edges_for_no_imports() {
1635 let graph = build_simple_graph();
1636 let targets = graph.edges_for(FileId(1));
1637 assert!(targets.is_empty());
1638 }
1639
1640 #[test]
1641 fn graph_edges_for_out_of_bounds() {
1642 let graph = build_simple_graph();
1643 let targets = graph.edges_for(FileId(999));
1644 assert!(targets.is_empty());
1645 }
1646
1647 #[test]
1648 fn graph_direct_importer_summaries_include_symbols() {
1649 let graph = build_simple_graph();
1650 let summaries = graph.direct_importer_summaries(FileId(1));
1651
1652 assert_eq!(
1653 summaries,
1654 vec![DirectImporterSummary {
1655 source: FileId(0),
1656 symbols: vec![ImportedSymbolSummary {
1657 imported: "foo".to_string(),
1658 local: "foo".to_string(),
1659 type_only: false,
1660 }],
1661 }]
1662 );
1663 }
1664
1665 #[test]
1666 fn graph_find_import_span_start_found() {
1667 let graph = build_simple_graph();
1668 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1669 assert!(span_start.is_some());
1670 assert_eq!(span_start.unwrap(), 0);
1671 }
1672
1673 #[test]
1674 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1675 let files = vec![
1676 DiscoveredFile {
1677 id: FileId(0),
1678 path: PathBuf::from("/project/entry.ts"),
1679 size_bytes: 100,
1680 },
1681 DiscoveredFile {
1682 id: FileId(1),
1683 path: PathBuf::from("/project/utils.ts"),
1684 size_bytes: 50,
1685 },
1686 ];
1687 let entry_points = vec![EntryPoint {
1688 path: PathBuf::from("/project/entry.ts"),
1689 source: EntryPointSource::PackageJsonMain,
1690 }];
1691 let resolved_modules = vec![
1692 ResolvedModule {
1693 file_id: FileId(0),
1694 path: PathBuf::from("/project/entry.ts"),
1695 resolved_imports: vec![
1696 ResolvedImport {
1697 info: ImportInfo {
1698 source: "./utils".to_string(),
1699 imported_name: ImportedName::Named("Foo".to_string()),
1700 local_name: "Foo".to_string(),
1701 is_type_only: true,
1702 from_style: false,
1703 span: oxc_span::Span::new(10, 20),
1704 source_span: oxc_span::Span::default(),
1705 },
1706 target: ResolveResult::InternalModule(FileId(1)),
1707 },
1708 ResolvedImport {
1709 info: ImportInfo {
1710 source: "./utils".to_string(),
1711 imported_name: ImportedName::Named("foo".to_string()),
1712 local_name: "foo".to_string(),
1713 is_type_only: false,
1714 from_style: false,
1715 span: oxc_span::Span::new(50, 60),
1716 source_span: oxc_span::Span::default(),
1717 },
1718 target: ResolveResult::InternalModule(FileId(1)),
1719 },
1720 ],
1721 ..Default::default()
1722 },
1723 ResolvedModule {
1724 file_id: FileId(1),
1725 path: PathBuf::from("/project/utils.ts"),
1726 ..Default::default()
1727 },
1728 ];
1729
1730 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1731 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1732 }
1733
1734 #[test]
1735 fn graph_find_import_span_start_wrong_target() {
1736 let graph = build_simple_graph();
1737 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1738 assert!(span_start.is_none());
1739 }
1740
1741 #[test]
1742 fn graph_find_import_span_start_source_out_of_bounds() {
1743 let graph = build_simple_graph();
1744 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1745 assert!(span_start.is_none());
1746 }
1747
1748 #[test]
1749 fn graph_find_import_span_start_no_edges() {
1750 let graph = build_simple_graph();
1751 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1752 assert!(span_start.is_none());
1753 }
1754
1755 #[test]
1756 fn graph_reverse_deps_populated() {
1757 let graph = build_simple_graph();
1758 assert!(graph.reverse_deps[1].contains(&FileId(0)));
1759 assert!(graph.reverse_deps[0].is_empty());
1760 }
1761
1762 #[test]
1763 fn graph_type_only_package_usage_tracked() {
1764 let files = vec![DiscoveredFile {
1765 id: FileId(0),
1766 path: PathBuf::from("/project/entry.ts"),
1767 size_bytes: 100,
1768 }];
1769 let entry_points = vec![EntryPoint {
1770 path: PathBuf::from("/project/entry.ts"),
1771 source: EntryPointSource::PackageJsonMain,
1772 }];
1773 let resolved_modules = vec![ResolvedModule {
1774 file_id: FileId(0),
1775 path: PathBuf::from("/project/entry.ts"),
1776 resolved_imports: vec![
1777 ResolvedImport {
1778 info: ImportInfo {
1779 source: "react".to_string(),
1780 imported_name: ImportedName::Named("FC".to_string()),
1781 local_name: "FC".to_string(),
1782 is_type_only: true,
1783 from_style: false,
1784 span: oxc_span::Span::new(0, 10),
1785 source_span: oxc_span::Span::default(),
1786 },
1787 target: ResolveResult::NpmPackage("react".to_string()),
1788 },
1789 ResolvedImport {
1790 info: ImportInfo {
1791 source: "react".to_string(),
1792 imported_name: ImportedName::Named("useState".to_string()),
1793 local_name: "useState".to_string(),
1794 is_type_only: false,
1795 from_style: false,
1796 span: oxc_span::Span::new(15, 30),
1797 source_span: oxc_span::Span::default(),
1798 },
1799 target: ResolveResult::NpmPackage("react".to_string()),
1800 },
1801 ],
1802 ..Default::default()
1803 }];
1804
1805 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1806 assert!(graph.package_usage.contains_key("react"));
1807 assert!(graph.type_only_package_usage.contains_key("react"));
1808 }
1809
1810 #[test]
1811 fn graph_default_import_reference() {
1812 let files = vec![
1813 DiscoveredFile {
1814 id: FileId(0),
1815 path: PathBuf::from("/project/entry.ts"),
1816 size_bytes: 100,
1817 },
1818 DiscoveredFile {
1819 id: FileId(1),
1820 path: PathBuf::from("/project/utils.ts"),
1821 size_bytes: 50,
1822 },
1823 ];
1824 let entry_points = vec![EntryPoint {
1825 path: PathBuf::from("/project/entry.ts"),
1826 source: EntryPointSource::PackageJsonMain,
1827 }];
1828 let resolved_modules = vec![
1829 ResolvedModule {
1830 file_id: FileId(0),
1831 path: PathBuf::from("/project/entry.ts"),
1832 resolved_imports: vec![ResolvedImport {
1833 info: ImportInfo {
1834 source: "./utils".to_string(),
1835 imported_name: ImportedName::Default,
1836 local_name: "Utils".to_string(),
1837 is_type_only: false,
1838 from_style: false,
1839 span: oxc_span::Span::new(0, 10),
1840 source_span: oxc_span::Span::default(),
1841 },
1842 target: ResolveResult::InternalModule(FileId(1)),
1843 }],
1844 ..Default::default()
1845 },
1846 ResolvedModule {
1847 file_id: FileId(1),
1848 path: PathBuf::from("/project/utils.ts"),
1849 exports: vec![fallow_types::extract::ExportInfo {
1850 name: ExportName::Default,
1851 local_name: None,
1852 is_type_only: false,
1853 visibility: VisibilityTag::None,
1854 expected_unused_reason: None,
1855 span: oxc_span::Span::new(0, 20),
1856 members: vec![],
1857 is_side_effect_used: false,
1858 super_class: None,
1859 }],
1860 ..Default::default()
1861 },
1862 ];
1863
1864 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1865 let utils = &graph.modules[1];
1866 let default_export = utils
1867 .exports
1868 .iter()
1869 .find(|e| matches!(e.name, ExportName::Default))
1870 .unwrap();
1871 assert!(!default_export.references.is_empty());
1872 assert_eq!(
1873 default_export.references[0].kind,
1874 ReferenceKind::DefaultImport
1875 );
1876 }
1877
1878 #[test]
1879 fn graph_side_effect_import_no_export_reference() {
1880 let files = vec![
1881 DiscoveredFile {
1882 id: FileId(0),
1883 path: PathBuf::from("/project/entry.ts"),
1884 size_bytes: 100,
1885 },
1886 DiscoveredFile {
1887 id: FileId(1),
1888 path: PathBuf::from("/project/styles.ts"),
1889 size_bytes: 50,
1890 },
1891 ];
1892 let entry_points = vec![EntryPoint {
1893 path: PathBuf::from("/project/entry.ts"),
1894 source: EntryPointSource::PackageJsonMain,
1895 }];
1896 let resolved_modules = vec![
1897 ResolvedModule {
1898 file_id: FileId(0),
1899 path: PathBuf::from("/project/entry.ts"),
1900 resolved_imports: vec![ResolvedImport {
1901 info: ImportInfo {
1902 source: "./styles".to_string(),
1903 imported_name: ImportedName::SideEffect,
1904 local_name: String::new(),
1905 is_type_only: false,
1906 from_style: false,
1907 span: oxc_span::Span::new(0, 10),
1908 source_span: oxc_span::Span::default(),
1909 },
1910 target: ResolveResult::InternalModule(FileId(1)),
1911 }],
1912 ..Default::default()
1913 },
1914 ResolvedModule {
1915 file_id: FileId(1),
1916 path: PathBuf::from("/project/styles.ts"),
1917 exports: vec![fallow_types::extract::ExportInfo {
1918 name: ExportName::Named("primaryColor".to_string()),
1919 local_name: Some("primaryColor".to_string()),
1920 is_type_only: false,
1921 visibility: VisibilityTag::None,
1922 expected_unused_reason: None,
1923 span: oxc_span::Span::new(0, 20),
1924 members: vec![],
1925 is_side_effect_used: false,
1926 super_class: None,
1927 }],
1928 ..Default::default()
1929 },
1930 ];
1931
1932 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1933 assert_eq!(graph.edge_count(), 1);
1934 let styles = &graph.modules[1];
1935 let export = &styles.exports[0];
1936 assert!(
1937 export.references.is_empty(),
1938 "side-effect import should not reference named exports"
1939 );
1940 }
1941
1942 #[test]
1943 fn graph_multiple_entry_points() {
1944 let files = vec![
1945 DiscoveredFile {
1946 id: FileId(0),
1947 path: PathBuf::from("/project/main.ts"),
1948 size_bytes: 100,
1949 },
1950 DiscoveredFile {
1951 id: FileId(1),
1952 path: PathBuf::from("/project/worker.ts"),
1953 size_bytes: 100,
1954 },
1955 DiscoveredFile {
1956 id: FileId(2),
1957 path: PathBuf::from("/project/shared.ts"),
1958 size_bytes: 50,
1959 },
1960 ];
1961 let entry_points = vec![
1962 EntryPoint {
1963 path: PathBuf::from("/project/main.ts"),
1964 source: EntryPointSource::PackageJsonMain,
1965 },
1966 EntryPoint {
1967 path: PathBuf::from("/project/worker.ts"),
1968 source: EntryPointSource::PackageJsonMain,
1969 },
1970 ];
1971 let resolved_modules = vec![
1972 ResolvedModule {
1973 file_id: FileId(0),
1974 path: PathBuf::from("/project/main.ts"),
1975 resolved_imports: vec![ResolvedImport {
1976 info: ImportInfo {
1977 source: "./shared".to_string(),
1978 imported_name: ImportedName::Named("helper".to_string()),
1979 local_name: "helper".to_string(),
1980 is_type_only: false,
1981 from_style: false,
1982 span: oxc_span::Span::new(0, 10),
1983 source_span: oxc_span::Span::default(),
1984 },
1985 target: ResolveResult::InternalModule(FileId(2)),
1986 }],
1987 ..Default::default()
1988 },
1989 ResolvedModule {
1990 file_id: FileId(1),
1991 path: PathBuf::from("/project/worker.ts"),
1992 ..Default::default()
1993 },
1994 ResolvedModule {
1995 file_id: FileId(2),
1996 path: PathBuf::from("/project/shared.ts"),
1997 exports: vec![fallow_types::extract::ExportInfo {
1998 name: ExportName::Named("helper".to_string()),
1999 local_name: Some("helper".to_string()),
2000 is_type_only: false,
2001 visibility: VisibilityTag::None,
2002 expected_unused_reason: None,
2003 span: oxc_span::Span::new(0, 20),
2004 members: vec![],
2005 is_side_effect_used: false,
2006 super_class: None,
2007 }],
2008 ..Default::default()
2009 },
2010 ];
2011
2012 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2013 assert!(graph.modules[0].is_entry_point());
2014 assert!(graph.modules[1].is_entry_point());
2015 assert!(!graph.modules[2].is_entry_point());
2016 assert!(graph.modules[0].is_reachable());
2017 assert!(graph.modules[1].is_reachable());
2018 assert!(graph.modules[2].is_reachable());
2019 }
2020}