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