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 .into(),
1005 ..Default::default()
1006 },
1007 ];
1008
1009 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1010 }
1011
1012 #[test]
1013 fn graph_module_count() {
1014 let graph = build_simple_graph();
1015 assert_eq!(graph.module_count(), 2);
1016 }
1017
1018 #[test]
1019 fn graph_edge_count() {
1020 let graph = build_simple_graph();
1021 assert_eq!(graph.edge_count(), 1);
1022 }
1023
1024 #[test]
1025 fn graph_entry_point_is_reachable() {
1026 let graph = build_simple_graph();
1027 assert!(graph.modules[0].is_entry_point());
1028 assert!(graph.modules[0].is_reachable());
1029 }
1030
1031 #[test]
1032 fn graph_imported_module_is_reachable() {
1033 let graph = build_simple_graph();
1034 assert!(!graph.modules[1].is_entry_point());
1035 assert!(graph.modules[1].is_reachable());
1036 }
1037
1038 #[test]
1039 #[expect(
1040 clippy::too_many_lines,
1041 reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
1042 would obscure the cross-role assertions"
1043 )]
1044 fn graph_distinguishes_runtime_test_and_support_reachability() {
1045 let files = vec![
1046 DiscoveredFile {
1047 id: FileId(0),
1048 path: PathBuf::from("/project/src/main.ts"),
1049 size_bytes: 100,
1050 },
1051 DiscoveredFile {
1052 id: FileId(1),
1053 path: PathBuf::from("/project/src/runtime-only.ts"),
1054 size_bytes: 50,
1055 },
1056 DiscoveredFile {
1057 id: FileId(2),
1058 path: PathBuf::from("/project/tests/app.test.ts"),
1059 size_bytes: 50,
1060 },
1061 DiscoveredFile {
1062 id: FileId(3),
1063 path: PathBuf::from("/project/tests/setup.ts"),
1064 size_bytes: 50,
1065 },
1066 DiscoveredFile {
1067 id: FileId(4),
1068 path: PathBuf::from("/project/src/covered.ts"),
1069 size_bytes: 50,
1070 },
1071 ];
1072
1073 let all_entry_points = vec![
1074 EntryPoint {
1075 path: PathBuf::from("/project/src/main.ts"),
1076 source: EntryPointSource::PackageJsonMain,
1077 },
1078 EntryPoint {
1079 path: PathBuf::from("/project/tests/app.test.ts"),
1080 source: EntryPointSource::TestFile,
1081 },
1082 EntryPoint {
1083 path: PathBuf::from("/project/tests/setup.ts"),
1084 source: EntryPointSource::Plugin {
1085 name: "vitest".to_string(),
1086 },
1087 },
1088 ];
1089 let runtime_entry_points = vec![EntryPoint {
1090 path: PathBuf::from("/project/src/main.ts"),
1091 source: EntryPointSource::PackageJsonMain,
1092 }];
1093 let test_entry_points = vec![EntryPoint {
1094 path: PathBuf::from("/project/tests/app.test.ts"),
1095 source: EntryPointSource::TestFile,
1096 }];
1097
1098 let resolved_modules = vec![
1099 ResolvedModule {
1100 file_id: FileId(0),
1101 path: PathBuf::from("/project/src/main.ts"),
1102 resolved_imports: vec![ResolvedImport {
1103 info: ImportInfo {
1104 source: "./runtime-only".to_string(),
1105 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1106 local_name: "runtimeOnly".to_string(),
1107 is_type_only: false,
1108 from_style: false,
1109 span: oxc_span::Span::new(0, 10),
1110 source_span: oxc_span::Span::default(),
1111 },
1112 target: ResolveResult::InternalModule(FileId(1)),
1113 }],
1114 ..Default::default()
1115 },
1116 ResolvedModule {
1117 file_id: FileId(1),
1118 path: PathBuf::from("/project/src/runtime-only.ts"),
1119 exports: vec![fallow_types::extract::ExportInfo {
1120 name: ExportName::Named("runtimeOnly".to_string()),
1121 local_name: Some("runtimeOnly".to_string()),
1122 is_type_only: false,
1123 visibility: VisibilityTag::None,
1124 expected_unused_reason: None,
1125 span: oxc_span::Span::new(0, 20),
1126 members: vec![],
1127 is_side_effect_used: false,
1128 super_class: None,
1129 }]
1130 .into(),
1131 ..Default::default()
1132 },
1133 ResolvedModule {
1134 file_id: FileId(2),
1135 path: PathBuf::from("/project/tests/app.test.ts"),
1136 resolved_imports: vec![ResolvedImport {
1137 info: ImportInfo {
1138 source: "../src/covered".to_string(),
1139 imported_name: ImportedName::Named("covered".to_string()),
1140 local_name: "covered".to_string(),
1141 is_type_only: false,
1142 from_style: false,
1143 span: oxc_span::Span::new(0, 10),
1144 source_span: oxc_span::Span::default(),
1145 },
1146 target: ResolveResult::InternalModule(FileId(4)),
1147 }],
1148 ..Default::default()
1149 },
1150 ResolvedModule {
1151 file_id: FileId(3),
1152 path: PathBuf::from("/project/tests/setup.ts"),
1153 resolved_imports: vec![ResolvedImport {
1154 info: ImportInfo {
1155 source: "../src/runtime-only".to_string(),
1156 imported_name: ImportedName::Named("runtimeOnly".to_string()),
1157 local_name: "runtimeOnly".to_string(),
1158 is_type_only: false,
1159 from_style: false,
1160 span: oxc_span::Span::new(0, 10),
1161 source_span: oxc_span::Span::default(),
1162 },
1163 target: ResolveResult::InternalModule(FileId(1)),
1164 }],
1165 ..Default::default()
1166 },
1167 ResolvedModule {
1168 file_id: FileId(4),
1169 path: PathBuf::from("/project/src/covered.ts"),
1170 exports: vec![fallow_types::extract::ExportInfo {
1171 name: ExportName::Named("covered".to_string()),
1172 local_name: Some("covered".to_string()),
1173 is_type_only: false,
1174 visibility: VisibilityTag::None,
1175 expected_unused_reason: None,
1176 span: oxc_span::Span::new(0, 20),
1177 members: vec![],
1178 is_side_effect_used: false,
1179 super_class: None,
1180 }]
1181 .into(),
1182 ..Default::default()
1183 },
1184 ];
1185
1186 let graph = ModuleGraph::build_with_reachability_roots(
1187 &resolved_modules,
1188 &all_entry_points,
1189 &runtime_entry_points,
1190 &test_entry_points,
1191 &files,
1192 );
1193
1194 assert!(graph.modules[1].is_reachable());
1195 assert!(graph.modules[1].is_runtime_reachable());
1196 assert!(
1197 !graph.modules[1].is_test_reachable(),
1198 "support roots should not make runtime-only modules test reachable"
1199 );
1200
1201 assert!(graph.modules[4].is_reachable());
1202 assert!(graph.modules[4].is_test_reachable());
1203 assert!(
1204 !graph.modules[4].is_runtime_reachable(),
1205 "test-only reachability should stay separate from runtime roots"
1206 );
1207 }
1208
1209 #[test]
1210 fn graph_export_has_reference() {
1211 let graph = build_simple_graph();
1212 let utils = &graph.modules[1];
1213 let foo_export = utils
1214 .exports
1215 .iter()
1216 .find(|e| e.name.to_string() == "foo")
1217 .unwrap();
1218 assert!(
1219 !foo_export.references.is_empty(),
1220 "foo should have references"
1221 );
1222 }
1223
1224 #[test]
1225 fn graph_unused_export_no_reference() {
1226 let graph = build_simple_graph();
1227 let utils = &graph.modules[1];
1228 let bar_export = utils
1229 .exports
1230 .iter()
1231 .find(|e| e.name.to_string() == "bar")
1232 .unwrap();
1233 assert!(
1234 bar_export.references.is_empty(),
1235 "bar should have no references"
1236 );
1237 }
1238
1239 #[test]
1240 fn graph_no_namespace_import() {
1241 let graph = build_simple_graph();
1242 assert!(!graph.has_namespace_import(FileId(0)));
1243 assert!(!graph.has_namespace_import(FileId(1)));
1244 }
1245
1246 #[test]
1247 fn graph_has_namespace_import() {
1248 let files = vec![
1249 DiscoveredFile {
1250 id: FileId(0),
1251 path: PathBuf::from("/project/entry.ts"),
1252 size_bytes: 100,
1253 },
1254 DiscoveredFile {
1255 id: FileId(1),
1256 path: PathBuf::from("/project/utils.ts"),
1257 size_bytes: 50,
1258 },
1259 ];
1260
1261 let entry_points = vec![EntryPoint {
1262 path: PathBuf::from("/project/entry.ts"),
1263 source: EntryPointSource::PackageJsonMain,
1264 }];
1265
1266 let resolved_modules = vec![
1267 ResolvedModule {
1268 file_id: FileId(0),
1269 path: PathBuf::from("/project/entry.ts"),
1270 resolved_imports: vec![ResolvedImport {
1271 info: ImportInfo {
1272 source: "./utils".to_string(),
1273 imported_name: ImportedName::Namespace,
1274 local_name: "utils".to_string(),
1275 is_type_only: false,
1276 from_style: false,
1277 span: oxc_span::Span::new(0, 10),
1278 source_span: oxc_span::Span::default(),
1279 },
1280 target: ResolveResult::InternalModule(FileId(1)),
1281 }],
1282 ..Default::default()
1283 },
1284 ResolvedModule {
1285 file_id: FileId(1),
1286 path: PathBuf::from("/project/utils.ts"),
1287 exports: vec![fallow_types::extract::ExportInfo {
1288 name: ExportName::Named("foo".to_string()),
1289 local_name: Some("foo".to_string()),
1290 is_type_only: false,
1291 visibility: VisibilityTag::None,
1292 expected_unused_reason: None,
1293 span: oxc_span::Span::new(0, 20),
1294 members: vec![],
1295 is_side_effect_used: false,
1296 super_class: None,
1297 }]
1298 .into(),
1299 ..Default::default()
1300 },
1301 ];
1302
1303 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1304 assert!(
1305 graph.has_namespace_import(FileId(1)),
1306 "utils should have namespace import"
1307 );
1308 }
1309
1310 #[test]
1311 fn graph_has_namespace_import_out_of_bounds() {
1312 let graph = build_simple_graph();
1313 assert!(!graph.has_namespace_import(FileId(999)));
1314 }
1315
1316 #[test]
1321 fn reconstruct_namespace_imported_matches_fresh_build() {
1322 let files = vec![
1323 DiscoveredFile {
1324 id: FileId(0),
1325 path: PathBuf::from("/project/entry.ts"),
1326 size_bytes: 100,
1327 },
1328 DiscoveredFile {
1329 id: FileId(1),
1330 path: PathBuf::from("/project/utils.ts"),
1331 size_bytes: 50,
1332 },
1333 DiscoveredFile {
1334 id: FileId(2),
1335 path: PathBuf::from("/project/named-only.ts"),
1336 size_bytes: 50,
1337 },
1338 ];
1339 let entry_points = vec![EntryPoint {
1340 path: PathBuf::from("/project/entry.ts"),
1341 source: EntryPointSource::PackageJsonMain,
1342 }];
1343 let resolved_modules = vec![
1344 ResolvedModule {
1345 file_id: FileId(0),
1346 path: PathBuf::from("/project/entry.ts"),
1347 resolved_imports: vec![
1348 ResolvedImport {
1349 info: ImportInfo {
1350 source: "./utils".to_string(),
1351 imported_name: ImportedName::Namespace,
1352 local_name: "utils".to_string(),
1353 is_type_only: false,
1354 from_style: false,
1355 span: oxc_span::Span::new(0, 10),
1356 source_span: oxc_span::Span::default(),
1357 },
1358 target: ResolveResult::InternalModule(FileId(1)),
1359 },
1360 ResolvedImport {
1361 info: ImportInfo {
1362 source: "./named-only".to_string(),
1363 imported_name: ImportedName::Named("foo".to_string()),
1364 local_name: "foo".to_string(),
1365 is_type_only: false,
1366 from_style: false,
1367 span: oxc_span::Span::new(11, 20),
1368 source_span: oxc_span::Span::default(),
1369 },
1370 target: ResolveResult::InternalModule(FileId(2)),
1371 },
1372 ],
1373 ..Default::default()
1374 },
1375 ResolvedModule {
1376 file_id: FileId(1),
1377 path: PathBuf::from("/project/utils.ts"),
1378 ..Default::default()
1379 },
1380 ResolvedModule {
1381 file_id: FileId(2),
1382 path: PathBuf::from("/project/named-only.ts"),
1383 exports: vec![fallow_types::extract::ExportInfo {
1384 name: ExportName::Named("foo".to_string()),
1385 local_name: Some("foo".to_string()),
1386 is_type_only: false,
1387 visibility: VisibilityTag::None,
1388 expected_unused_reason: None,
1389 span: oxc_span::Span::new(0, 20),
1390 members: vec![],
1391 is_side_effect_used: false,
1392 super_class: None,
1393 }]
1394 .into(),
1395 ..Default::default()
1396 },
1397 ];
1398
1399 let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1400 let fresh = graph.namespace_imported.clone();
1401
1402 assert!(graph.has_namespace_import(FileId(1)));
1404 assert!(!graph.has_namespace_import(FileId(2)));
1405
1406 graph.namespace_imported = FixedBitSet::default();
1409 graph.reconstruct_namespace_imported();
1410
1411 assert_eq!(
1412 graph.namespace_imported, fresh,
1413 "reconstructed namespace_imported must equal the fresh-built bitset"
1414 );
1415 assert!(graph.has_namespace_import(FileId(1)));
1416 assert!(!graph.has_namespace_import(FileId(2)));
1417 }
1418
1419 #[test]
1420 fn graph_unreachable_module() {
1421 let files = vec![
1422 DiscoveredFile {
1423 id: FileId(0),
1424 path: PathBuf::from("/project/entry.ts"),
1425 size_bytes: 100,
1426 },
1427 DiscoveredFile {
1428 id: FileId(1),
1429 path: PathBuf::from("/project/utils.ts"),
1430 size_bytes: 50,
1431 },
1432 DiscoveredFile {
1433 id: FileId(2),
1434 path: PathBuf::from("/project/orphan.ts"),
1435 size_bytes: 30,
1436 },
1437 ];
1438
1439 let entry_points = vec![EntryPoint {
1440 path: PathBuf::from("/project/entry.ts"),
1441 source: EntryPointSource::PackageJsonMain,
1442 }];
1443
1444 let resolved_modules = vec![
1445 ResolvedModule {
1446 file_id: FileId(0),
1447 path: PathBuf::from("/project/entry.ts"),
1448 resolved_imports: vec![ResolvedImport {
1449 info: ImportInfo {
1450 source: "./utils".to_string(),
1451 imported_name: ImportedName::Named("foo".to_string()),
1452 local_name: "foo".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/utils.ts"),
1465 exports: vec![fallow_types::extract::ExportInfo {
1466 name: ExportName::Named("foo".to_string()),
1467 local_name: Some("foo".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/orphan.ts"),
1482 exports: vec![fallow_types::extract::ExportInfo {
1483 name: ExportName::Named("orphan".to_string()),
1484 local_name: Some("orphan".to_string()),
1485 is_type_only: false,
1486 visibility: VisibilityTag::None,
1487 expected_unused_reason: None,
1488 span: oxc_span::Span::new(0, 20),
1489 members: vec![],
1490 is_side_effect_used: false,
1491 super_class: None,
1492 }]
1493 .into(),
1494 ..Default::default()
1495 },
1496 ];
1497
1498 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1499
1500 assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1501 assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1502 assert!(
1503 !graph.modules[2].is_reachable(),
1504 "orphan should NOT be reachable"
1505 );
1506 }
1507
1508 #[test]
1509 fn graph_package_usage_tracked() {
1510 let files = vec![DiscoveredFile {
1511 id: FileId(0),
1512 path: PathBuf::from("/project/entry.ts"),
1513 size_bytes: 100,
1514 }];
1515
1516 let entry_points = vec![EntryPoint {
1517 path: PathBuf::from("/project/entry.ts"),
1518 source: EntryPointSource::PackageJsonMain,
1519 }];
1520
1521 let resolved_modules = vec![ResolvedModule {
1522 file_id: FileId(0),
1523 path: PathBuf::from("/project/entry.ts"),
1524 exports: vec![].into(),
1525 re_exports: vec![],
1526 resolved_imports: vec![
1527 ResolvedImport {
1528 info: ImportInfo {
1529 source: "react".to_string(),
1530 imported_name: ImportedName::Default,
1531 local_name: "React".to_string(),
1532 is_type_only: false,
1533 from_style: false,
1534 span: oxc_span::Span::new(0, 10),
1535 source_span: oxc_span::Span::default(),
1536 },
1537 target: ResolveResult::NpmPackage("react".to_string()),
1538 },
1539 ResolvedImport {
1540 info: ImportInfo {
1541 source: "lodash".to_string(),
1542 imported_name: ImportedName::Named("merge".to_string()),
1543 local_name: "merge".to_string(),
1544 is_type_only: false,
1545 from_style: false,
1546 span: oxc_span::Span::new(15, 30),
1547 source_span: oxc_span::Span::default(),
1548 },
1549 target: ResolveResult::NpmPackage("lodash".to_string()),
1550 },
1551 ],
1552 ..Default::default()
1553 }];
1554
1555 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1556 assert!(graph.package_usage.contains_key("react"));
1557 assert!(graph.package_usage.contains_key("lodash"));
1558 assert!(!graph.package_usage.contains_key("express"));
1559 }
1560
1561 #[test]
1562 fn graph_empty() {
1563 let graph = ModuleGraph::build(&[], &[], &[]);
1564 assert_eq!(graph.module_count(), 0);
1565 assert_eq!(graph.edge_count(), 0);
1566 }
1567
1568 #[test]
1574 fn graph_postcard_round_trip_is_lossless() {
1575 let graph = build_simple_graph();
1576
1577 let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1578 let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1579 decoded.reconstruct_namespace_imported();
1581
1582 assert_eq!(decoded.module_count(), graph.module_count());
1583 assert_eq!(decoded.edge_count(), graph.edge_count());
1584 assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1585
1586 let utils = &decoded.modules[1];
1588 let foo = utils
1589 .exports
1590 .iter()
1591 .find(|e| e.name.to_string() == "foo")
1592 .expect("foo export survives round-trip");
1593 assert!(!foo.references.is_empty());
1594 let bar = utils
1595 .exports
1596 .iter()
1597 .find(|e| e.name.to_string() == "bar")
1598 .expect("bar export survives round-trip");
1599 assert!(bar.references.is_empty());
1600
1601 assert!(decoded.modules[0].is_entry_point());
1603 assert!(decoded.modules[0].is_reachable());
1604 assert!(decoded.modules[1].is_reachable());
1605 assert_eq!(decoded.entry_points, graph.entry_points);
1606 }
1607
1608 #[test]
1609 fn graph_cjs_exports_tracked() {
1610 let files = vec![DiscoveredFile {
1611 id: FileId(0),
1612 path: PathBuf::from("/project/entry.ts"),
1613 size_bytes: 100,
1614 }];
1615
1616 let entry_points = vec![EntryPoint {
1617 path: PathBuf::from("/project/entry.ts"),
1618 source: EntryPointSource::PackageJsonMain,
1619 }];
1620
1621 let resolved_modules = vec![ResolvedModule {
1622 file_id: FileId(0),
1623 path: PathBuf::from("/project/entry.ts"),
1624 has_cjs_exports: true,
1625 has_angular_component_template_url: false,
1626 ..Default::default()
1627 }];
1628
1629 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1630 assert!(graph.modules[0].has_cjs_exports());
1631 }
1632
1633 #[test]
1634 fn graph_edges_for_returns_targets() {
1635 let graph = build_simple_graph();
1636 let targets = graph.edges_for(FileId(0));
1637 assert_eq!(targets, vec![FileId(1)]);
1638 }
1639
1640 #[test]
1641 fn graph_edges_for_no_imports() {
1642 let graph = build_simple_graph();
1643 let targets = graph.edges_for(FileId(1));
1644 assert!(targets.is_empty());
1645 }
1646
1647 #[test]
1648 fn graph_edges_for_out_of_bounds() {
1649 let graph = build_simple_graph();
1650 let targets = graph.edges_for(FileId(999));
1651 assert!(targets.is_empty());
1652 }
1653
1654 #[test]
1655 fn graph_direct_importer_summaries_include_symbols() {
1656 let graph = build_simple_graph();
1657 let summaries = graph.direct_importer_summaries(FileId(1));
1658
1659 assert_eq!(
1660 summaries,
1661 vec![DirectImporterSummary {
1662 source: FileId(0),
1663 symbols: vec![ImportedSymbolSummary {
1664 imported: "foo".to_string(),
1665 local: "foo".to_string(),
1666 type_only: false,
1667 }],
1668 }]
1669 );
1670 }
1671
1672 #[test]
1673 fn graph_find_import_span_start_found() {
1674 let graph = build_simple_graph();
1675 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1676 assert!(span_start.is_some());
1677 assert_eq!(span_start.unwrap(), 0);
1678 }
1679
1680 #[test]
1681 fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1682 let files = vec![
1683 DiscoveredFile {
1684 id: FileId(0),
1685 path: PathBuf::from("/project/entry.ts"),
1686 size_bytes: 100,
1687 },
1688 DiscoveredFile {
1689 id: FileId(1),
1690 path: PathBuf::from("/project/utils.ts"),
1691 size_bytes: 50,
1692 },
1693 ];
1694 let entry_points = vec![EntryPoint {
1695 path: PathBuf::from("/project/entry.ts"),
1696 source: EntryPointSource::PackageJsonMain,
1697 }];
1698 let resolved_modules = vec![
1699 ResolvedModule {
1700 file_id: FileId(0),
1701 path: PathBuf::from("/project/entry.ts"),
1702 resolved_imports: vec![
1703 ResolvedImport {
1704 info: ImportInfo {
1705 source: "./utils".to_string(),
1706 imported_name: ImportedName::Named("Foo".to_string()),
1707 local_name: "Foo".to_string(),
1708 is_type_only: true,
1709 from_style: false,
1710 span: oxc_span::Span::new(10, 20),
1711 source_span: oxc_span::Span::default(),
1712 },
1713 target: ResolveResult::InternalModule(FileId(1)),
1714 },
1715 ResolvedImport {
1716 info: ImportInfo {
1717 source: "./utils".to_string(),
1718 imported_name: ImportedName::Named("foo".to_string()),
1719 local_name: "foo".to_string(),
1720 is_type_only: false,
1721 from_style: false,
1722 span: oxc_span::Span::new(50, 60),
1723 source_span: oxc_span::Span::default(),
1724 },
1725 target: ResolveResult::InternalModule(FileId(1)),
1726 },
1727 ],
1728 ..Default::default()
1729 },
1730 ResolvedModule {
1731 file_id: FileId(1),
1732 path: PathBuf::from("/project/utils.ts"),
1733 ..Default::default()
1734 },
1735 ];
1736
1737 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1738 assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1739 }
1740
1741 #[test]
1742 fn graph_find_import_span_start_wrong_target() {
1743 let graph = build_simple_graph();
1744 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1745 assert!(span_start.is_none());
1746 }
1747
1748 #[test]
1749 fn graph_find_import_span_start_source_out_of_bounds() {
1750 let graph = build_simple_graph();
1751 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1752 assert!(span_start.is_none());
1753 }
1754
1755 #[test]
1756 fn graph_find_import_span_start_no_edges() {
1757 let graph = build_simple_graph();
1758 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1759 assert!(span_start.is_none());
1760 }
1761
1762 #[test]
1763 fn graph_reverse_deps_populated() {
1764 let graph = build_simple_graph();
1765 assert!(graph.reverse_deps[1].contains(&FileId(0)));
1766 assert!(graph.reverse_deps[0].is_empty());
1767 }
1768
1769 #[test]
1770 fn graph_type_only_package_usage_tracked() {
1771 let files = vec![DiscoveredFile {
1772 id: FileId(0),
1773 path: PathBuf::from("/project/entry.ts"),
1774 size_bytes: 100,
1775 }];
1776 let entry_points = vec![EntryPoint {
1777 path: PathBuf::from("/project/entry.ts"),
1778 source: EntryPointSource::PackageJsonMain,
1779 }];
1780 let resolved_modules = vec![ResolvedModule {
1781 file_id: FileId(0),
1782 path: PathBuf::from("/project/entry.ts"),
1783 resolved_imports: vec![
1784 ResolvedImport {
1785 info: ImportInfo {
1786 source: "react".to_string(),
1787 imported_name: ImportedName::Named("FC".to_string()),
1788 local_name: "FC".to_string(),
1789 is_type_only: true,
1790 from_style: false,
1791 span: oxc_span::Span::new(0, 10),
1792 source_span: oxc_span::Span::default(),
1793 },
1794 target: ResolveResult::NpmPackage("react".to_string()),
1795 },
1796 ResolvedImport {
1797 info: ImportInfo {
1798 source: "react".to_string(),
1799 imported_name: ImportedName::Named("useState".to_string()),
1800 local_name: "useState".to_string(),
1801 is_type_only: false,
1802 from_style: false,
1803 span: oxc_span::Span::new(15, 30),
1804 source_span: oxc_span::Span::default(),
1805 },
1806 target: ResolveResult::NpmPackage("react".to_string()),
1807 },
1808 ],
1809 ..Default::default()
1810 }];
1811
1812 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1813 assert!(graph.package_usage.contains_key("react"));
1814 assert!(graph.type_only_package_usage.contains_key("react"));
1815 }
1816
1817 #[test]
1818 fn graph_default_import_reference() {
1819 let files = vec![
1820 DiscoveredFile {
1821 id: FileId(0),
1822 path: PathBuf::from("/project/entry.ts"),
1823 size_bytes: 100,
1824 },
1825 DiscoveredFile {
1826 id: FileId(1),
1827 path: PathBuf::from("/project/utils.ts"),
1828 size_bytes: 50,
1829 },
1830 ];
1831 let entry_points = vec![EntryPoint {
1832 path: PathBuf::from("/project/entry.ts"),
1833 source: EntryPointSource::PackageJsonMain,
1834 }];
1835 let resolved_modules = vec![
1836 ResolvedModule {
1837 file_id: FileId(0),
1838 path: PathBuf::from("/project/entry.ts"),
1839 resolved_imports: vec![ResolvedImport {
1840 info: ImportInfo {
1841 source: "./utils".to_string(),
1842 imported_name: ImportedName::Default,
1843 local_name: "Utils".to_string(),
1844 is_type_only: false,
1845 from_style: false,
1846 span: oxc_span::Span::new(0, 10),
1847 source_span: oxc_span::Span::default(),
1848 },
1849 target: ResolveResult::InternalModule(FileId(1)),
1850 }],
1851 ..Default::default()
1852 },
1853 ResolvedModule {
1854 file_id: FileId(1),
1855 path: PathBuf::from("/project/utils.ts"),
1856 exports: vec![fallow_types::extract::ExportInfo {
1857 name: ExportName::Default,
1858 local_name: None,
1859 is_type_only: false,
1860 visibility: VisibilityTag::None,
1861 expected_unused_reason: None,
1862 span: oxc_span::Span::new(0, 20),
1863 members: vec![],
1864 is_side_effect_used: false,
1865 super_class: None,
1866 }]
1867 .into(),
1868 ..Default::default()
1869 },
1870 ];
1871
1872 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1873 let utils = &graph.modules[1];
1874 let default_export = utils
1875 .exports
1876 .iter()
1877 .find(|e| matches!(e.name, ExportName::Default))
1878 .unwrap();
1879 assert!(!default_export.references.is_empty());
1880 assert_eq!(
1881 default_export.references[0].kind,
1882 ReferenceKind::DefaultImport
1883 );
1884 }
1885
1886 #[test]
1887 fn graph_side_effect_import_no_export_reference() {
1888 let files = vec![
1889 DiscoveredFile {
1890 id: FileId(0),
1891 path: PathBuf::from("/project/entry.ts"),
1892 size_bytes: 100,
1893 },
1894 DiscoveredFile {
1895 id: FileId(1),
1896 path: PathBuf::from("/project/styles.ts"),
1897 size_bytes: 50,
1898 },
1899 ];
1900 let entry_points = vec![EntryPoint {
1901 path: PathBuf::from("/project/entry.ts"),
1902 source: EntryPointSource::PackageJsonMain,
1903 }];
1904 let resolved_modules = vec![
1905 ResolvedModule {
1906 file_id: FileId(0),
1907 path: PathBuf::from("/project/entry.ts"),
1908 resolved_imports: vec![ResolvedImport {
1909 info: ImportInfo {
1910 source: "./styles".to_string(),
1911 imported_name: ImportedName::SideEffect,
1912 local_name: String::new(),
1913 is_type_only: false,
1914 from_style: false,
1915 span: oxc_span::Span::new(0, 10),
1916 source_span: oxc_span::Span::default(),
1917 },
1918 target: ResolveResult::InternalModule(FileId(1)),
1919 }],
1920 ..Default::default()
1921 },
1922 ResolvedModule {
1923 file_id: FileId(1),
1924 path: PathBuf::from("/project/styles.ts"),
1925 exports: vec![fallow_types::extract::ExportInfo {
1926 name: ExportName::Named("primaryColor".to_string()),
1927 local_name: Some("primaryColor".to_string()),
1928 is_type_only: false,
1929 visibility: VisibilityTag::None,
1930 expected_unused_reason: None,
1931 span: oxc_span::Span::new(0, 20),
1932 members: vec![],
1933 is_side_effect_used: false,
1934 super_class: None,
1935 }]
1936 .into(),
1937 ..Default::default()
1938 },
1939 ];
1940
1941 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1942 assert_eq!(graph.edge_count(), 1);
1943 let styles = &graph.modules[1];
1944 let export = &styles.exports[0];
1945 assert!(
1946 export.references.is_empty(),
1947 "side-effect import should not reference named exports"
1948 );
1949 }
1950
1951 #[test]
1952 fn graph_multiple_entry_points() {
1953 let files = vec![
1954 DiscoveredFile {
1955 id: FileId(0),
1956 path: PathBuf::from("/project/main.ts"),
1957 size_bytes: 100,
1958 },
1959 DiscoveredFile {
1960 id: FileId(1),
1961 path: PathBuf::from("/project/worker.ts"),
1962 size_bytes: 100,
1963 },
1964 DiscoveredFile {
1965 id: FileId(2),
1966 path: PathBuf::from("/project/shared.ts"),
1967 size_bytes: 50,
1968 },
1969 ];
1970 let entry_points = vec![
1971 EntryPoint {
1972 path: PathBuf::from("/project/main.ts"),
1973 source: EntryPointSource::PackageJsonMain,
1974 },
1975 EntryPoint {
1976 path: PathBuf::from("/project/worker.ts"),
1977 source: EntryPointSource::PackageJsonMain,
1978 },
1979 ];
1980 let resolved_modules = vec![
1981 ResolvedModule {
1982 file_id: FileId(0),
1983 path: PathBuf::from("/project/main.ts"),
1984 resolved_imports: vec![ResolvedImport {
1985 info: ImportInfo {
1986 source: "./shared".to_string(),
1987 imported_name: ImportedName::Named("helper".to_string()),
1988 local_name: "helper".to_string(),
1989 is_type_only: false,
1990 from_style: false,
1991 span: oxc_span::Span::new(0, 10),
1992 source_span: oxc_span::Span::default(),
1993 },
1994 target: ResolveResult::InternalModule(FileId(2)),
1995 }],
1996 ..Default::default()
1997 },
1998 ResolvedModule {
1999 file_id: FileId(1),
2000 path: PathBuf::from("/project/worker.ts"),
2001 ..Default::default()
2002 },
2003 ResolvedModule {
2004 file_id: FileId(2),
2005 path: PathBuf::from("/project/shared.ts"),
2006 exports: vec![fallow_types::extract::ExportInfo {
2007 name: ExportName::Named("helper".to_string()),
2008 local_name: Some("helper".to_string()),
2009 is_type_only: false,
2010 visibility: VisibilityTag::None,
2011 expected_unused_reason: None,
2012 span: oxc_span::Span::new(0, 20),
2013 members: vec![],
2014 is_side_effect_used: false,
2015 super_class: None,
2016 }]
2017 .into(),
2018 ..Default::default()
2019 },
2020 ];
2021
2022 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2023 assert!(graph.modules[0].is_entry_point());
2024 assert!(graph.modules[1].is_entry_point());
2025 assert!(!graph.modules[2].is_entry_point());
2026 assert!(graph.modules[0].is_reachable());
2027 assert!(graph.modules[1].is_reachable());
2028 assert!(graph.modules[2].is_reachable());
2029 }
2030}