Skip to main content

fallow_graph/graph/
reachability.rs

1//! Phase 3: BFS reachability from entry points.
2
3use std::collections::VecDeque;
4
5use fixedbitset::FixedBitSet;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use crate::resolve::ResolvedReplacedModuleTarget;
9use fallow_types::discover::FileId;
10use fallow_types::extract::ModuleLoadMechanism;
11
12use super::{Edge, ModuleGraph, TestReachabilityIndex};
13
14/// Upper bound on distinct replacement-mask profiles before profiled
15/// reachability falls back to the legacy coarse bitset pass.
16///
17/// Follows the `re_export_transition_safety_cap` precedent of bounding an
18/// otherwise unbounded blowup. `TestReachabilityIndex` storage is
19/// O(files * profile_count / 64) u64 words and worklist propagation touches
20/// every profile word per edge visit, so at 1024 profiles each file costs 16
21/// words (128 bytes): about 12 MiB of index for a 100k-file monorepo. Beyond
22/// that the memory and propagation cost outweigh the masking precision, and
23/// the legacy pass is a safe over-approximation (fail-open, pre-#2068
24/// behavior).
25const PROFILE_COUNT_SAFETY_CAP: usize = 1024;
26
27#[derive(Debug, PartialEq, Eq)]
28pub(super) struct TestReachabilityProfile {
29    masked_targets: Vec<FileId>,
30    roots: Vec<FileId>,
31}
32
33pub(super) enum TestReachabilityPlan<'roots> {
34    NoRoots,
35    Legacy {
36        roots: &'roots FxHashSet<FileId>,
37        /// True when replacement masks exist but their profile count passed
38        /// [`PROFILE_COUNT_SAFETY_CAP`], so masking is off.
39        capped: bool,
40    },
41    Profiled {
42        profiles: Vec<TestReachabilityProfile>,
43    },
44}
45
46impl<'roots> TestReachabilityPlan<'roots> {
47    pub(super) fn new(
48        test_entry_points: &'roots FxHashSet<FileId>,
49        replaced_module_targets: &[ResolvedReplacedModuleTarget],
50        total_capacity: usize,
51    ) -> Self {
52        if test_entry_points.is_empty() {
53            return Self::NoRoots;
54        }
55
56        let mut targets_by_root: FxHashMap<FileId, Vec<FileId>> = FxHashMap::default();
57        for replacement in replaced_module_targets {
58            if test_entry_points.contains(&replacement.source_file)
59                && (replacement.target_file.0 as usize) < total_capacity
60            {
61                targets_by_root
62                    .entry(replacement.source_file)
63                    .or_default()
64                    .push(replacement.target_file);
65            }
66        }
67        for targets in targets_by_root.values_mut() {
68            targets.sort_unstable_by_key(|target| target.0);
69            targets.dedup();
70        }
71
72        if targets_by_root.is_empty() {
73            return Self::Legacy {
74                roots: test_entry_points,
75                capped: false,
76            };
77        }
78
79        let mut roots_by_mask: FxHashMap<Vec<FileId>, Vec<FileId>> = FxHashMap::default();
80        for &root in test_entry_points {
81            let mask = targets_by_root.remove(&root).unwrap_or_default();
82            roots_by_mask.entry(mask).or_default().push(root);
83        }
84
85        if roots_by_mask.len() > PROFILE_COUNT_SAFETY_CAP {
86            tracing::warn!(
87                profile_count = roots_by_mask.len(),
88                safety_cap = PROFILE_COUNT_SAFETY_CAP,
89                "Test-reachability profile count exceeded its safety cap; \
90                 falling back to coarse test reachability without replacement \
91                 masking. Mocked modules stay test-reachable (fail-open)."
92            );
93            return Self::Legacy {
94                roots: test_entry_points,
95                capped: true,
96            };
97        }
98
99        let mut profiles: Vec<_> = roots_by_mask
100            .into_iter()
101            .map(|(masked_targets, mut roots)| {
102                roots.sort_unstable_by_key(|root| root.0);
103                roots.dedup();
104                TestReachabilityProfile {
105                    masked_targets,
106                    roots,
107                }
108            })
109            .collect();
110        profiles.sort_by(|left, right| {
111            left.masked_targets
112                .iter()
113                .map(|file_id| file_id.0)
114                .cmp(right.masked_targets.iter().map(|file_id| file_id.0))
115        });
116
117        Self::Profiled { profiles }
118    }
119
120    pub(super) const fn requires_reference_provenance(&self) -> bool {
121        matches!(self, Self::Profiled { .. })
122    }
123}
124
125enum TestReachability {
126    NoRoots,
127    Legacy {
128        reachable: FixedBitSet,
129        capped: bool,
130    },
131    Profiled {
132        reachable: FixedBitSet,
133        index: TestReachabilityIndex,
134        #[cfg(test)]
135        dirty_word_pops: usize,
136    },
137}
138
139impl TestReachability {
140    fn into_parts(self) -> (Option<FixedBitSet>, TestReachabilityIndex) {
141        match self {
142            Self::NoRoots => (None, TestReachabilityIndex::default()),
143            Self::Legacy { reachable, capped } => (
144                Some(reachable),
145                TestReachabilityIndex {
146                    masking_capped: capped,
147                    ..TestReachabilityIndex::default()
148                },
149            ),
150            Self::Profiled {
151                reachable, index, ..
152            } => (Some(reachable), index),
153        }
154    }
155
156    #[cfg(test)]
157    const fn dirty_word_pop_count(&self) -> usize {
158        match self {
159            Self::Profiled {
160                dirty_word_pops, ..
161            } => *dirty_word_pops,
162            Self::NoRoots | Self::Legacy { .. } => 0,
163        }
164    }
165}
166
167/// Owns the transient state for one bit-parallel profiled reachability pass.
168///
169/// Only `index` and `all_reachable` survive the pass. Pending deltas, queue
170/// membership, and the target-sparse mask map remain local to propagation.
171struct ProfileWorklist {
172    index: TestReachabilityIndex,
173    masked_profiles: FxHashMap<FileId, Vec<u64>>,
174    all_reachable: FixedBitSet,
175    pending_profiles: Vec<u64>,
176    queued: FixedBitSet,
177    queue: VecDeque<(FileId, usize)>,
178    dirty_word_pops: usize,
179}
180
181impl ProfileWorklist {
182    fn new(profiles: &[TestReachabilityProfile], total_capacity: usize) -> Self {
183        let profile_count = profiles.len();
184        let mut index = TestReachabilityIndex::new(total_capacity, profile_count);
185        index.profile_roots = profiles
186            .iter()
187            .map(|profile| profile.roots.clone())
188            .collect();
189        let words_per_file = index.words_per_file;
190        let mut masked_profiles: FxHashMap<FileId, Vec<u64>> = FxHashMap::default();
191
192        for (profile, reachability_profile) in profiles.iter().enumerate() {
193            let word_index = profile / u64::BITS as usize;
194            let profile_bit = 1_u64 << (profile % u64::BITS as usize);
195            for &target in &reachability_profile.masked_targets {
196                masked_profiles
197                    .entry(target)
198                    .or_insert_with(|| vec![0; words_per_file])[word_index] |= profile_bit;
199            }
200        }
201
202        let pending_profiles = vec![0_u64; index.reachable_profiles.len()];
203        let mut worklist = Self {
204            index,
205            masked_profiles,
206            all_reachable: FixedBitSet::with_capacity(total_capacity),
207            pending_profiles,
208            queued: FixedBitSet::with_capacity(total_capacity.saturating_mul(words_per_file)),
209            queue: VecDeque::new(),
210            dirty_word_pops: 0,
211        };
212
213        for (profile, reachability_profile) in profiles.iter().enumerate() {
214            let word_index = profile / u64::BITS as usize;
215            let profile_bit = 1_u64 << (profile % u64::BITS as usize);
216            for &root in &reachability_profile.roots {
217                let root_index = root.0 as usize;
218                if root_index >= total_capacity {
219                    continue;
220                }
221                let Some(slot) = root_index
222                    .checked_mul(words_per_file)
223                    .and_then(|start| start.checked_add(word_index))
224                else {
225                    continue;
226                };
227                let Some(reachable_word) = worklist.index.reachable_profiles.get_mut(slot) else {
228                    continue;
229                };
230                if *reachable_word & profile_bit != 0 {
231                    continue;
232                }
233                *reachable_word |= profile_bit;
234                worklist.pending_profiles[slot] |= profile_bit;
235                worklist.all_reachable.insert(root_index);
236                if !worklist.queued.contains(slot) {
237                    worklist.queued.insert(slot);
238                    worklist.queue.push_back((root, word_index));
239                }
240            }
241        }
242
243        worklist
244    }
245
246    fn run(mut self, graph: &ModuleGraph) -> (FixedBitSet, TestReachabilityIndex, usize) {
247        let words_per_file = self.index.words_per_file;
248
249        while let Some((file_id, word_index)) = self.queue.pop_front() {
250            self.dirty_word_pops += 1;
251            let file_index = file_id.0 as usize;
252            let Some(source_slot) = file_index
253                .checked_mul(words_per_file)
254                .and_then(|start| start.checked_add(word_index))
255            else {
256                continue;
257            };
258            self.queued.remove(source_slot);
259            let Some(source_delta) = self.pending_profiles.get_mut(source_slot) else {
260                continue;
261            };
262            let delta_word = *source_delta;
263            *source_delta = 0;
264            if delta_word == 0 {
265                continue;
266            }
267
268            let Some(module) = graph.modules.get(file_index) else {
269                continue;
270            };
271            for edge in &graph.edges[module.edge_range.clone()] {
272                let target_idx = edge.target.0 as usize;
273                if target_idx >= self.all_reachable.len() || !edge.runs_target_code_in_tests() {
274                    continue;
275                }
276                let Some(target_slot) = target_idx
277                    .checked_mul(words_per_file)
278                    .and_then(|start| start.checked_add(word_index))
279                else {
280                    continue;
281                };
282                let edge_is_esm_only = edge.symbols.iter().all(|symbol| {
283                    !matches!(symbol.mechanism, ModuleLoadMechanism::CommonJsRequire)
284                });
285                let edge_mask = if edge_is_esm_only {
286                    self.masked_profiles.get(&edge.target)
287                } else {
288                    None
289                };
290
291                let propagated = edge_mask
292                    .and_then(|mask| mask.get(word_index))
293                    .map_or(delta_word, |mask_word| delta_word & !mask_word);
294                let Some(reachable_word) = self.index.reachable_profiles.get_mut(target_slot)
295                else {
296                    continue;
297                };
298                let new_profiles = propagated & !*reachable_word;
299                if new_profiles == 0 {
300                    continue;
301                }
302                *reachable_word |= new_profiles;
303                self.pending_profiles[target_slot] |= new_profiles;
304
305                self.all_reachable.insert(target_idx);
306                if !self.queued.contains(target_slot) {
307                    self.queued.insert(target_slot);
308                    self.queue.push_back((edge.target, word_index));
309                }
310            }
311        }
312
313        self.index.set_sparse_masks(self.masked_profiles);
314        (self.all_reachable, self.index, self.dirty_word_pops)
315    }
316}
317
318impl ModuleGraph {
319    /// BFS over the edge list from `entry_points`.
320    ///
321    /// Reads edges only, so the result is stable from the moment
322    /// `populate_edges` finishes; the graph build calls it once and hands the
323    /// same bitset to the exposed namespace closure and to `mark_reachable`.
324    pub(super) fn collect_reachable(
325        &self,
326        entry_points: &FxHashSet<FileId>,
327        total_capacity: usize,
328    ) -> FixedBitSet {
329        self.collect_reachable_along(entry_points, total_capacity, Edge::runs_target_code)
330    }
331
332    /// BFS over the edges that `follows` accepts.
333    fn collect_reachable_along(
334        &self,
335        entry_points: &FxHashSet<FileId>,
336        total_capacity: usize,
337        follows: fn(&Edge) -> bool,
338    ) -> FixedBitSet {
339        #[cfg(test)]
340        tests::record_traversal();
341
342        let mut visited = FixedBitSet::with_capacity(total_capacity);
343        let mut queue = VecDeque::new();
344
345        for &ep_id in entry_points {
346            if (ep_id.0 as usize) < total_capacity {
347                visited.insert(ep_id.0 as usize);
348                queue.push_back(ep_id);
349            }
350        }
351
352        while let Some(file_id) = queue.pop_front() {
353            if (file_id.0 as usize) >= self.modules.len() {
354                continue;
355            }
356            let module = &self.modules[file_id.0 as usize];
357            for edge in &self.edges[module.edge_range.clone()] {
358                // An asset reference keeps its target in use through
359                // `has_reachable_importer`, but the target never runs, so its
360                // own imports stay out of reach.
361                if !follows(edge) {
362                    continue;
363                }
364                let target_idx = edge.target.0 as usize;
365                if target_idx < total_capacity && !visited.contains(target_idx) {
366                    visited.insert(target_idx);
367                    queue.push_back(edge.target);
368                }
369            }
370        }
371
372        visited
373    }
374
375    fn collect_profiled_reachable(
376        &self,
377        profiles: &[TestReachabilityProfile],
378        total_capacity: usize,
379    ) -> (FixedBitSet, TestReachabilityIndex, usize) {
380        #[cfg(test)]
381        tests::record_traversal();
382
383        ProfileWorklist::new(profiles, total_capacity).run(self)
384    }
385
386    fn collect_test_reachable(
387        &self,
388        plan: TestReachabilityPlan<'_>,
389        total_capacity: usize,
390    ) -> TestReachability {
391        match plan {
392            TestReachabilityPlan::NoRoots => TestReachability::NoRoots,
393            TestReachabilityPlan::Legacy { roots, capped } => TestReachability::Legacy {
394                reachable: self.collect_reachable_along(
395                    roots,
396                    total_capacity,
397                    Edge::runs_target_code_in_tests,
398                ),
399                capped,
400            },
401            TestReachabilityPlan::Profiled { profiles } => {
402                let (reachable, index, dirty_word_pops) =
403                    self.collect_profiled_reachable(&profiles, total_capacity);
404                #[cfg(not(test))]
405                let _ = dirty_word_pops;
406
407                TestReachability::Profiled {
408                    reachable,
409                    index,
410                    #[cfg(test)]
411                    dirty_word_pops,
412                }
413            }
414        }
415    }
416
417    /// Mark modules reachable from overall, runtime, and test entry points via BFS.
418    ///
419    /// Skips redundant BFS passes when entry point sets are identical or empty.
420    /// `visited` is the overall entry-point pass, already computed by the
421    /// caller through [`ModuleGraph::collect_reachable`].
422    pub(super) fn mark_reachable(
423        &mut self,
424        visited: &FixedBitSet,
425        entry_points: &FxHashSet<FileId>,
426        runtime_entry_points: &FxHashSet<FileId>,
427        test_reachability_plan: TestReachabilityPlan<'_>,
428        total_capacity: usize,
429    ) {
430        let runtime_same = runtime_entry_points == entry_points;
431        let runtime_visited = if runtime_same {
432            None
433        } else {
434            Some(self.collect_reachable(runtime_entry_points, total_capacity))
435        };
436
437        let requires_reference_provenance = test_reachability_plan.requires_reference_provenance();
438        let (test_visited, test_reachability_index) = self
439            .collect_test_reachable(test_reachability_plan, total_capacity)
440            .into_parts();
441        debug_assert_eq!(
442            requires_reference_provenance,
443            test_reachability_index.profile_count > 0
444        );
445        self.test_reachability_index = test_reachability_index;
446
447        for (idx, module) in self.modules.iter_mut().enumerate() {
448            module.set_reachable(visited.contains(idx));
449            module.set_runtime_reachable(
450                runtime_visited
451                    .as_ref()
452                    .map_or_else(|| visited.contains(idx), |rv| rv.contains(idx)),
453            );
454            module.set_test_reachable(test_visited.as_ref().is_some_and(|tv| tv.contains(idx)));
455        }
456    }
457
458    /// The test entry points that reach each file of `files`, sorted by id.
459    ///
460    /// Each test entry point has its own traversal with its own `vi.mock`
461    /// replacement mask, so a test that replaces the file, or a module on
462    /// each import path to it, does not reach it. The traversal is the same
463    /// profiled pass that sets [`ModuleGraph::is_test_reachable`], with one
464    /// profile for each test entry point. A file is test-reachable exactly
465    /// when its list is not empty. When the profile count of the graph build
466    /// exceeded its safety cap, the masks are off, as in that build.
467    ///
468    /// The query runs one pass over the graph for each 64 test entry points.
469    #[must_use]
470    pub fn test_entry_points_reaching(&self, files: &[FileId]) -> FxHashMap<FileId, Vec<FileId>> {
471        let mut reaching: FxHashMap<FileId, Vec<FileId>> =
472            files.iter().map(|&file| (file, Vec::new())).collect();
473        let total_capacity = self.modules.len();
474        let mut roots: Vec<FileId> = self
475            .test_entry_points
476            .iter()
477            .copied()
478            .filter(|root| (root.0 as usize) < total_capacity)
479            .collect();
480        if files.is_empty() || roots.is_empty() {
481            return reaching;
482        }
483        roots.sort_unstable_by_key(|root| root.0);
484        let mut masks = self.test_reachability_index.root_masks();
485        for batch in roots.chunks(u64::BITS as usize) {
486            let profiles: Vec<TestReachabilityProfile> = batch
487                .iter()
488                .map(|&root| TestReachabilityProfile {
489                    masked_targets: masks.remove(&root).unwrap_or_default(),
490                    roots: vec![root],
491                })
492                .collect();
493            let (_, index, _) = ProfileWorklist::new(&profiles, total_capacity).run(self);
494            for (&file, entries) in &mut reaching {
495                let Some(&word) = index
496                    .profiles_for(&index.reachable_profiles, file)
497                    .and_then(|words| words.first())
498                else {
499                    continue;
500                };
501                entries.extend(
502                    batch
503                        .iter()
504                        .enumerate()
505                        .filter(|&(bit, _)| word & (1_u64 << bit) != 0)
506                        .map(|(_, &root)| root),
507                );
508            }
509        }
510        reaching
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use std::path::PathBuf;
517
518    use rustc_hash::FxHashSet;
519
520    use super::{TestReachabilityPlan, TestReachabilityProfile};
521    use crate::graph::{ExportNamespace, ExportTestReach, ModuleGraph, TestMaskingState};
522    use crate::resolve::{
523        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport,
524        ResolvedReplacedModuleTarget,
525    };
526    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
527    use fallow_types::extract::{
528        ExportName, ImportInfo, ImportedName, ModuleLoadMechanism, ReExportInfo, VisibilityTag,
529    };
530
531    std::thread_local! {
532        static TRAVERSALS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
533    }
534
535    pub(super) fn record_traversal() {
536        TRAVERSALS.with(|count| count.set(count.get() + 1));
537    }
538
539    fn capture_traversals<T>(run: impl FnOnce() -> T) -> (T, usize) {
540        let before = TRAVERSALS.with(std::cell::Cell::get);
541        let result = run();
542        let traversals = TRAVERSALS.with(|count| count.get() - before);
543        (result, traversals)
544    }
545
546    /// Build a graph with separate runtime and test entry point sets.
547    ///
548    /// `file_count` nodes are created, `edges_spec` defines directed edges,
549    /// `runtime_eps` and `test_eps` are file indices for each entry point
550    /// category. All entry points (union of runtime + test) form the overall
551    /// entry set.
552    fn build_reachability_graph(
553        file_count: usize,
554        edges_spec: &[(u32, u32)],
555        runtime_eps: &[u32],
556        test_eps: &[u32],
557    ) -> ModuleGraph {
558        let edges: Vec<_> = edges_spec
559            .iter()
560            .map(|&(source, target)| (source, target, false))
561            .collect();
562        build_reachability_graph_with_replacements(file_count, &edges, runtime_eps, test_eps, &[])
563    }
564
565    fn build_reachability_graph_with_replacements(
566        file_count: usize,
567        edges_spec: &[(u32, u32, bool)],
568        runtime_eps: &[u32],
569        test_eps: &[u32],
570        replacements: &[ResolvedReplacedModuleTarget],
571    ) -> ModuleGraph {
572        let edges: Vec<_> = edges_spec
573            .iter()
574            .map(|&(source, target, is_commonjs)| ReachabilityEdge {
575                source,
576                target,
577                is_commonjs,
578                is_type_only: false,
579            })
580            .collect();
581        build_reachability_graph_with_edges(file_count, &edges, runtime_eps, test_eps, replacements)
582    }
583
584    struct ReachabilityEdge {
585        source: u32,
586        target: u32,
587        is_commonjs: bool,
588        is_type_only: bool,
589    }
590
591    #[expect(
592        clippy::cast_possible_truncation,
593        reason = "test file counts are trivially small"
594    )]
595    fn build_reachability_graph_with_edges(
596        file_count: usize,
597        edges_spec: &[ReachabilityEdge],
598        runtime_eps: &[u32],
599        test_eps: &[u32],
600        replacements: &[ResolvedReplacedModuleTarget],
601    ) -> ModuleGraph {
602        let files: Vec<DiscoveredFile> = (0..file_count)
603            .map(|i| DiscoveredFile {
604                id: FileId(i as u32),
605                path: PathBuf::from(format!("/project/file{i}.ts")),
606                size_bytes: 100,
607            })
608            .collect();
609
610        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
611            .map(|i| {
612                let imports: Vec<ResolvedImport> = edges_spec
613                    .iter()
614                    .filter(|edge| edge.source == i as u32)
615                    .map(|edge| ResolvedImport {
616                        info: ImportInfo {
617                            source: format!("./file{}", edge.target),
618                            imported_name: ImportedName::Named("x".to_string()),
619                            local_name: "x".to_string(),
620                            is_type_only: edge.is_type_only,
621                            is_type_only_star: false,
622                            from_style: false,
623                            span: oxc_span::Span::new(0, 10),
624                            source_span: oxc_span::Span::default(),
625                        },
626                        target: if edge.is_commonjs {
627                            ResolveResult::CommonJsInternalModule(FileId(edge.target))
628                        } else {
629                            ResolveResult::InternalModule(FileId(edge.target))
630                        },
631                    })
632                    .collect();
633
634                ResolvedModule {
635                    file_id: FileId(i as u32),
636                    path: PathBuf::from(format!("/project/file{i}.ts")),
637                    exports: vec![fallow_types::extract::ExportInfo {
638                        name: ExportName::Named("x".to_string()),
639                        local_name: Some("x".to_string()),
640                        is_type_only: false,
641                        visibility: VisibilityTag::None,
642                        expected_unused_reason: None,
643                        span: oxc_span::Span::new(0, 20),
644                        members: vec![],
645                        is_side_effect_used: false,
646                        super_class: None,
647                        deprecated: false,
648                        deprecated_reason: None,
649                    }]
650                    .into(),
651                    re_exports: vec![],
652                    resolved_imports: imports,
653                    resolved_dynamic_imports: vec![],
654                    resolved_dynamic_patterns: vec![],
655                    member_accesses: vec![].into(),
656                    semantic_facts: std::sync::Arc::default(),
657                    whole_object_uses: std::sync::Arc::default(),
658                    has_cjs_exports: false,
659                    has_angular_component_template_url: false,
660                    unused_import_bindings: FxHashSet::default(),
661                    type_referenced_import_bindings: vec![],
662                    value_referenced_import_bindings: vec![],
663                    namespace_object_aliases: vec![],
664                    exported_factory_returns: std::sync::Arc::default(),
665                    exported_factory_return_object_shapes: std::sync::Arc::default(),
666                    type_member_types: std::sync::Arc::default(),
667                    missing_export_targets: vec![],
668                }
669            })
670            .collect();
671
672        let runtime_entry_points: Vec<EntryPoint> = runtime_eps
673            .iter()
674            .map(|&i| EntryPoint {
675                path: PathBuf::from(format!("/project/file{i}.ts")),
676                source: EntryPointSource::PackageJsonMain,
677            })
678            .collect();
679
680        let test_entry_points: Vec<EntryPoint> = test_eps
681            .iter()
682            .map(|&i| EntryPoint {
683                path: PathBuf::from(format!("/project/file{i}.ts")),
684                source: EntryPointSource::TestFile,
685            })
686            .collect();
687
688        let mut all_entry_points = runtime_entry_points.clone();
689        all_entry_points.extend(test_entry_points.iter().cloned());
690
691        ModuleGraph::build_with_reachability_roots_and_replacements(
692            &resolved_modules,
693            replacements,
694            &all_entry_points,
695            &runtime_entry_points,
696            &test_entry_points,
697            &files,
698        )
699    }
700
701    fn build_masked_re_export_graph(
702        commonjs_to_barrel: bool,
703        commonjs_to_target: bool,
704    ) -> ModuleGraph {
705        let files: Vec<_> = (0..3)
706            .map(|id| DiscoveredFile {
707                id: FileId(id),
708                path: PathBuf::from(format!("/project/file{id}.ts")),
709                size_bytes: 100,
710            })
711            .collect();
712        let mut test_imports = vec![ResolvedImport {
713            info: ImportInfo {
714                source: "./barrel".to_string(),
715                imported_name: ImportedName::Named("value".to_string()),
716                local_name: "value".to_string(),
717                is_type_only: false,
718                is_type_only_star: false,
719                from_style: false,
720                span: oxc_span::Span::new(0, 10),
721                source_span: oxc_span::Span::default(),
722            },
723            target: if commonjs_to_barrel {
724                ResolveResult::CommonJsInternalModule(FileId(1))
725            } else {
726                ResolveResult::InternalModule(FileId(1))
727            },
728        }];
729        if commonjs_to_target {
730            test_imports.push(ResolvedImport {
731                info: ImportInfo {
732                    source: "./target".to_string(),
733                    imported_name: ImportedName::Named("value".to_string()),
734                    local_name: "requiredValue".to_string(),
735                    is_type_only: false,
736                    is_type_only_star: false,
737                    from_style: false,
738                    span: oxc_span::Span::new(20, 30),
739                    source_span: oxc_span::Span::default(),
740                },
741                target: ResolveResult::CommonJsInternalModule(FileId(2)),
742            });
743        }
744        let modules = vec![
745            ResolvedModule {
746                file_id: FileId(0),
747                path: files[0].path.clone(),
748                resolved_imports: test_imports,
749                ..ResolvedModule::default()
750            },
751            ResolvedModule {
752                file_id: FileId(1),
753                path: files[1].path.clone(),
754                re_exports: vec![ResolvedReExport {
755                    info: ReExportInfo {
756                        source: "./target".to_string(),
757                        imported_name: "value".to_string(),
758                        exported_name: "value".to_string(),
759                        is_type_only: false,
760                        span: oxc_span::Span::new(0, 10),
761                        statement_span: oxc_span::Span::new(0, 0),
762                        source_span: oxc_span::Span::new(0, 0),
763                    },
764                    target: ResolveResult::InternalModule(FileId(2)),
765                }],
766                ..ResolvedModule::default()
767            },
768            ResolvedModule {
769                file_id: FileId(2),
770                path: files[2].path.clone(),
771                exports: vec![fallow_types::extract::ExportInfo {
772                    name: ExportName::Named("value".to_string()),
773                    local_name: Some("value".to_string()),
774                    is_type_only: false,
775                    visibility: VisibilityTag::None,
776                    expected_unused_reason: None,
777                    span: oxc_span::Span::new(0, 20),
778                    members: Vec::new(),
779                    is_side_effect_used: false,
780                    super_class: None,
781                    deprecated: false,
782                    deprecated_reason: None,
783                }]
784                .into(),
785                ..ResolvedModule::default()
786            },
787        ];
788        let test_entry_points = vec![EntryPoint {
789            path: files[0].path.clone(),
790            source: EntryPointSource::TestFile,
791        }];
792        ModuleGraph::build_with_reachability_roots_and_replacements(
793            &modules,
794            &[ResolvedReplacedModuleTarget {
795                source_file: FileId(0),
796                target_file: FileId(2),
797            }],
798            &test_entry_points,
799            &[],
800            &test_entry_points,
801            &files,
802        )
803    }
804
805    fn build_masked_pattern_graph(mechanisms: &[ModuleLoadMechanism]) -> ModuleGraph {
806        let files: Vec<_> = (0..2)
807            .map(|id| DiscoveredFile {
808                id: FileId(id),
809                path: PathBuf::from(format!("/project/file{id}.ts")),
810                size_bytes: 100,
811            })
812            .collect();
813        let modules = vec![
814            ResolvedModule {
815                file_id: FileId(0),
816                path: files[0].path.clone(),
817                resolved_dynamic_patterns: mechanisms
818                    .iter()
819                    .copied()
820                    .map(|mechanism| {
821                        (
822                            fallow_types::extract::DynamicImportPattern {
823                                prefix: "./modules/".to_string(),
824                                suffix: None,
825                                span: oxc_span::Span::new(0, 10),
826                                mechanism,
827                                excludes: Vec::new(),
828                            },
829                            vec![FileId(1)],
830                        )
831                    })
832                    .collect(),
833                ..ResolvedModule::default()
834            },
835            ResolvedModule {
836                file_id: FileId(1),
837                path: files[1].path.clone(),
838                exports: vec![fallow_types::extract::ExportInfo {
839                    name: ExportName::Named("value".to_string()),
840                    local_name: Some("value".to_string()),
841                    is_type_only: false,
842                    visibility: VisibilityTag::None,
843                    expected_unused_reason: None,
844                    span: oxc_span::Span::new(0, 20),
845                    members: Vec::new(),
846                    is_side_effect_used: false,
847                    super_class: None,
848                    deprecated: false,
849                    deprecated_reason: None,
850                }]
851                .into(),
852                ..ResolvedModule::default()
853            },
854        ];
855        let test_entry_points = vec![EntryPoint {
856            path: files[0].path.clone(),
857            source: EntryPointSource::TestFile,
858        }];
859        ModuleGraph::build_with_reachability_roots_and_replacements(
860            &modules,
861            &[ResolvedReplacedModuleTarget {
862                source_file: FileId(0),
863                target_file: FileId(1),
864            }],
865            &test_entry_points,
866            &[],
867            &test_entry_points,
868            &files,
869        )
870    }
871
872    fn intermediate_import(
873        source: &str,
874        target: FileId,
875        imported_name: ImportedName,
876        commonjs: bool,
877    ) -> ResolvedImport {
878        ResolvedImport {
879            info: ImportInfo {
880                source: source.to_string(),
881                imported_name,
882                local_name: "value".to_string(),
883                is_type_only: false,
884                is_type_only_star: false,
885                from_style: false,
886                span: oxc_span::Span::new(0, 10),
887                source_span: oxc_span::Span::default(),
888            },
889            target: if commonjs {
890                ResolveResult::CommonJsInternalModule(target)
891            } else {
892                ResolveResult::InternalModule(target)
893            },
894        }
895    }
896
897    fn intermediate_modules(
898        files: &[DiscoveredFile],
899        commonjs_to_barrel: bool,
900        include_cycle: bool,
901    ) -> Vec<ResolvedModule> {
902        let mut target_re_exports = Vec::new();
903        if include_cycle {
904            target_re_exports.push(ResolvedReExport {
905                info: ReExportInfo {
906                    source: "./barrel".to_string(),
907                    imported_name: "*".to_string(),
908                    exported_name: "*".to_string(),
909                    is_type_only: false,
910                    span: oxc_span::Span::new(20, 30),
911                    statement_span: oxc_span::Span::new(0, 0),
912                    source_span: oxc_span::Span::new(0, 0),
913                },
914                target: ResolveResult::InternalModule(FileId(1)),
915            });
916        }
917        vec![
918            ResolvedModule {
919                file_id: FileId(0),
920                path: files[0].path.clone(),
921                resolved_imports: vec![
922                    intermediate_import(
923                        "./barrel",
924                        FileId(1),
925                        ImportedName::Named("value".to_string()),
926                        commonjs_to_barrel,
927                    ),
928                    intermediate_import("./alternate", FileId(3), ImportedName::SideEffect, false),
929                ],
930                ..ResolvedModule::default()
931            },
932            ResolvedModule {
933                file_id: FileId(1),
934                path: files[1].path.clone(),
935                re_exports: vec![ResolvedReExport {
936                    info: ReExportInfo {
937                        source: "./target".to_string(),
938                        imported_name: "value".to_string(),
939                        exported_name: "value".to_string(),
940                        is_type_only: false,
941                        span: oxc_span::Span::new(0, 10),
942                        statement_span: oxc_span::Span::new(0, 0),
943                        source_span: oxc_span::Span::new(0, 0),
944                    },
945                    target: ResolveResult::InternalModule(FileId(2)),
946                }],
947                ..ResolvedModule::default()
948            },
949            ResolvedModule {
950                file_id: FileId(2),
951                path: files[2].path.clone(),
952                exports: vec![fallow_types::extract::ExportInfo {
953                    name: ExportName::Named("value".to_string()),
954                    local_name: Some("value".to_string()),
955                    is_type_only: false,
956                    visibility: VisibilityTag::None,
957                    expected_unused_reason: None,
958                    span: oxc_span::Span::new(0, 20),
959                    members: Vec::new(),
960                    is_side_effect_used: false,
961                    super_class: None,
962                    deprecated: false,
963                    deprecated_reason: None,
964                }]
965                .into(),
966                re_exports: target_re_exports,
967                ..ResolvedModule::default()
968            },
969            ResolvedModule {
970                file_id: FileId(3),
971                path: files[3].path.clone(),
972                resolved_imports: vec![intermediate_import(
973                    "./target",
974                    FileId(2),
975                    ImportedName::SideEffect,
976                    false,
977                )],
978                ..ResolvedModule::default()
979            },
980        ]
981    }
982
983    fn build_intermediate_replacement_graph(
984        commonjs_to_barrel: bool,
985        include_cycle: bool,
986        mask_final_target: bool,
987    ) -> ModuleGraph {
988        let files: Vec<_> = (0..4)
989            .map(|id| DiscoveredFile {
990                id: FileId(id),
991                path: PathBuf::from(format!("/project/file{id}.ts")),
992                size_bytes: 100,
993            })
994            .collect();
995        let modules = intermediate_modules(&files, commonjs_to_barrel, include_cycle);
996        let test_entry_points = vec![EntryPoint {
997            path: files[0].path.clone(),
998            source: EntryPointSource::TestFile,
999        }];
1000        let mut replacements = vec![ResolvedReplacedModuleTarget {
1001            source_file: FileId(0),
1002            target_file: FileId(1),
1003        }];
1004        if mask_final_target {
1005            replacements.push(ResolvedReplacedModuleTarget {
1006                source_file: FileId(0),
1007                target_file: FileId(2),
1008            });
1009        }
1010
1011        ModuleGraph::build_with_reachability_roots_and_replacements(
1012            &modules,
1013            &replacements,
1014            &test_entry_points,
1015            &[],
1016            &test_entry_points,
1017            &files,
1018        )
1019    }
1020
1021    #[test]
1022    fn entry_point_is_reachable() {
1023        let graph = build_reachability_graph(1, &[], &[0], &[]);
1024        assert!(graph.modules[0].is_reachable());
1025    }
1026
1027    #[test]
1028    fn direct_dependency_is_reachable() {
1029        let graph = build_reachability_graph(2, &[(0, 1)], &[0], &[]);
1030        assert!(graph.modules[0].is_reachable());
1031        assert!(graph.modules[1].is_reachable());
1032    }
1033
1034    #[test]
1035    fn chain_reachability_a_b_c() {
1036        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[0], &[]);
1037        assert!(graph.modules[0].is_reachable());
1038        assert!(graph.modules[1].is_reachable());
1039        assert!(graph.modules[2].is_reachable());
1040    }
1041
1042    #[test]
1043    fn deep_chain_all_reachable() {
1044        let graph = build_reachability_graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4)], &[0], &[]);
1045        for i in 0..5 {
1046            assert!(
1047                graph.modules[i].is_reachable(),
1048                "file{i} should be reachable through chain"
1049            );
1050        }
1051    }
1052
1053    #[test]
1054    fn disconnected_file_is_unreachable() {
1055        let graph = build_reachability_graph(3, &[(0, 1)], &[0], &[]);
1056        assert!(graph.modules[0].is_reachable());
1057        assert!(graph.modules[1].is_reachable());
1058        assert!(!graph.modules[2].is_reachable());
1059    }
1060
1061    #[test]
1062    fn no_entry_points_all_unreachable() {
1063        let graph = build_reachability_graph(2, &[(0, 1)], &[], &[]);
1064        assert!(!graph.modules[0].is_reachable());
1065        assert!(!graph.modules[1].is_reachable());
1066    }
1067
1068    #[test]
1069    fn cycle_both_reachable_when_entry() {
1070        let graph = build_reachability_graph(2, &[(0, 1), (1, 0)], &[0], &[]);
1071        assert!(graph.modules[0].is_reachable());
1072        assert!(graph.modules[1].is_reachable());
1073    }
1074
1075    #[test]
1076    fn three_node_cycle_all_reachable() {
1077        let graph = build_reachability_graph(3, &[(0, 1), (1, 2), (2, 0)], &[0], &[]);
1078        for i in 0..3 {
1079            assert!(
1080                graph.modules[i].is_reachable(),
1081                "file{i} in cycle should be reachable"
1082            );
1083        }
1084    }
1085
1086    #[test]
1087    fn cycle_not_reachable_from_entry() {
1088        let graph = build_reachability_graph(3, &[(1, 2), (2, 1)], &[0], &[]);
1089        assert!(graph.modules[0].is_reachable());
1090        assert!(!graph.modules[1].is_reachable());
1091        assert!(!graph.modules[2].is_reachable());
1092    }
1093
1094    #[test]
1095    fn runtime_reachable_only_from_runtime_entries() {
1096        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0], &[2]);
1097        assert!(graph.modules[0].is_runtime_reachable());
1098        assert!(graph.modules[1].is_runtime_reachable());
1099        assert!(!graph.modules[2].is_runtime_reachable());
1100        assert!(!graph.modules[3].is_runtime_reachable());
1101    }
1102
1103    #[test]
1104    fn test_reachable_only_from_test_entries() {
1105        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0], &[2]);
1106        assert!(!graph.modules[0].is_test_reachable());
1107        assert!(!graph.modules[1].is_test_reachable());
1108        assert!(graph.modules[2].is_test_reachable());
1109        assert!(graph.modules[3].is_test_reachable());
1110    }
1111
1112    /// File 0 is a test root. It imports `x` from file 1, with `import type`
1113    /// when `type_only` is set. With `masked`, file 0 also mocks the
1114    /// unrelated file 2, so the graph uses the profiled traversal instead of
1115    /// the plain BFS.
1116    fn build_type_only_test_import_graph(type_only: bool, masked: bool) -> ModuleGraph {
1117        let edges = [ReachabilityEdge {
1118            source: 0,
1119            target: 1,
1120            is_commonjs: false,
1121            is_type_only: type_only,
1122        }];
1123        let replacements = if masked {
1124            vec![ResolvedReplacedModuleTarget {
1125                source_file: FileId(0),
1126                target_file: FileId(2),
1127            }]
1128        } else {
1129            Vec::new()
1130        };
1131        build_reachability_graph_with_edges(3, &edges, &[], &[0], &replacements)
1132    }
1133
1134    #[test]
1135    fn type_only_test_import_gives_no_export_coverage() {
1136        for masked in [false, true] {
1137            let graph = build_type_only_test_import_graph(true, masked);
1138            assert_eq!(graph.test_reachability_index.profile_count > 0, masked);
1139            let export = &graph.modules[1].exports[0];
1140            assert_eq!(export.references.len(), 1, "masked: {masked}");
1141            assert_eq!(
1142                export.references[0].namespace,
1143                ExportNamespace::Type,
1144                "masked: {masked}"
1145            );
1146
1147            assert!(
1148                !graph.is_test_reference_covered(export, 0),
1149                "masked: {masked}"
1150            );
1151            assert!(
1152                !graph.is_any_test_reference_covered(export),
1153                "masked: {masked}"
1154            );
1155        }
1156    }
1157
1158    /// File 0 is a test root and file 3 a runtime root. Both import file 1,
1159    /// the test with `import type` when `type_only` is set. File 1 imports
1160    /// file 2 by value. With `masked`, file 0 also mocks the unrelated file 4.
1161    fn build_type_only_test_chain_graph(type_only: bool, masked: bool) -> ModuleGraph {
1162        let edge = |source, target, is_type_only| ReachabilityEdge {
1163            source,
1164            target,
1165            is_commonjs: false,
1166            is_type_only,
1167        };
1168        let edges = [edge(0, 1, type_only), edge(1, 2, false), edge(3, 1, false)];
1169        let replacements = if masked {
1170            vec![ResolvedReplacedModuleTarget {
1171                source_file: FileId(0),
1172                target_file: FileId(4),
1173            }]
1174        } else {
1175            Vec::new()
1176        };
1177        build_reachability_graph_with_edges(5, &edges, &[3], &[0], &replacements)
1178    }
1179
1180    #[test]
1181    fn type_only_test_import_gives_no_file_test_reach() {
1182        for masked in [false, true] {
1183            let graph = build_type_only_test_chain_graph(true, masked);
1184            assert_eq!(graph.test_reachability_index.profile_count > 0, masked);
1185            assert!(graph.modules[0].is_test_reachable(), "masked: {masked}");
1186            assert!(!graph.modules[1].is_test_reachable(), "masked: {masked}");
1187            assert!(!graph.modules[2].is_test_reachable(), "masked: {masked}");
1188            assert!(
1189                graph
1190                    .test_entry_points_reaching(&[FileId(1), FileId(2)])
1191                    .values()
1192                    .all(Vec::is_empty),
1193                "masked: {masked}"
1194            );
1195            // Dead-code reachability still follows the type-only edge.
1196            assert!(graph.modules[1].is_reachable(), "masked: {masked}");
1197            assert!(graph.modules[2].is_reachable(), "masked: {masked}");
1198        }
1199    }
1200
1201    #[test]
1202    fn value_test_import_gives_file_test_reach() {
1203        for masked in [false, true] {
1204            let graph = build_type_only_test_chain_graph(false, masked);
1205            assert!(graph.modules[1].is_test_reachable(), "masked: {masked}");
1206            assert!(graph.modules[2].is_test_reachable(), "masked: {masked}");
1207            let reaching = graph.test_entry_points_reaching(&[FileId(2)]);
1208            assert_eq!(reaching[&FileId(2)], vec![FileId(0)], "masked: {masked}");
1209        }
1210    }
1211
1212    #[test]
1213    fn value_test_import_gives_export_coverage() {
1214        for masked in [false, true] {
1215            let graph = build_type_only_test_import_graph(false, masked);
1216            let export = &graph.modules[1].exports[0];
1217
1218            assert!(
1219                graph.is_test_reference_covered(export, 0),
1220                "masked: {masked}"
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn esm_replacement_masks_target_only_from_test_reachability() {
1227        let graph = build_reachability_graph_with_replacements(
1228            3,
1229            &[(0, 1, false), (1, 2, false)],
1230            &[],
1231            &[0],
1232            &[ResolvedReplacedModuleTarget {
1233                source_file: FileId(0),
1234                target_file: FileId(1),
1235            }],
1236        );
1237
1238        assert!(graph.modules[1].is_reachable());
1239        assert!(!graph.modules[1].is_test_reachable());
1240        assert!(!graph.modules[2].is_test_reachable());
1241        assert_eq!(graph.test_reachability_index.profile_count, 1);
1242        assert!(graph.test_reachability_index.profile_reaches(FileId(0), 0));
1243        assert!(!graph.test_reachability_index.profile_reaches(FileId(1), 0));
1244    }
1245
1246    #[test]
1247    fn commonjs_path_preserves_a_replaced_target() {
1248        let graph = build_reachability_graph_with_replacements(
1249            2,
1250            &[(0, 1, true)],
1251            &[],
1252            &[0],
1253            &[ResolvedReplacedModuleTarget {
1254                source_file: FileId(0),
1255                target_file: FileId(1),
1256            }],
1257        );
1258
1259        assert!(graph.modules[1].is_test_reachable());
1260    }
1261
1262    #[test]
1263    fn mixed_commonjs_and_esm_symbols_preserve_a_replaced_target() {
1264        let graph = build_reachability_graph_with_replacements(
1265            2,
1266            &[(0, 1, false), (0, 1, true)],
1267            &[],
1268            &[0],
1269            &[ResolvedReplacedModuleTarget {
1270                source_file: FileId(0),
1271                target_file: FileId(1),
1272            }],
1273        );
1274
1275        assert!(graph.modules[1].is_test_reachable());
1276    }
1277
1278    #[test]
1279    fn replacement_masks_a_target_behind_an_esm_re_export() {
1280        let graph = build_masked_re_export_graph(false, false);
1281        let target_export = &graph.modules[2].exports[0];
1282        assert!(
1283            !target_export.references.is_empty(),
1284            "barrel consumer reference should propagate"
1285        );
1286
1287        assert!(graph.modules[1].is_test_reachable());
1288        assert!(!graph.modules[2].is_test_reachable());
1289        assert_eq!(
1290            graph.reference_path_hops(target_export, 0)[0].1,
1291            ModuleLoadMechanism::EsModule
1292        );
1293        assert!(!graph.is_test_reference_covered(target_export, 0));
1294    }
1295
1296    #[test]
1297    fn commonjs_loaded_barrel_does_not_bypass_its_esm_re_export() {
1298        let graph = build_masked_re_export_graph(true, false);
1299        let target_export = &graph.modules[2].exports[0];
1300        assert!(
1301            !target_export.references.is_empty(),
1302            "barrel consumer reference should propagate"
1303        );
1304
1305        assert!(graph.modules[1].is_test_reachable());
1306        assert!(!graph.modules[2].is_test_reachable());
1307        assert_eq!(
1308            graph.reference_path_hops(target_export, 0)[0].1,
1309            ModuleLoadMechanism::EsModule
1310        );
1311        assert!(!graph.is_test_reference_covered(target_export, 0));
1312    }
1313
1314    #[test]
1315    fn direct_commonjs_and_esm_re_export_retain_distinct_coverage() {
1316        let graph = build_masked_re_export_graph(false, true);
1317        let export = &graph.modules[2].exports[0];
1318
1319        assert_eq!(export.references.len(), 2);
1320        let esm = (0..export.references.len())
1321            .find(|&index| {
1322                graph.reference_path_hops(export, index)[0].1 == ModuleLoadMechanism::EsModule
1323            })
1324            .expect("ESM re-export reference");
1325        let commonjs = (0..export.references.len())
1326            .find(|&index| {
1327                graph.reference_path_hops(export, index)[0].1
1328                    == ModuleLoadMechanism::CommonJsRequire
1329            })
1330            .expect("direct CommonJS reference");
1331        assert!(!graph.is_test_reference_covered(export, esm));
1332        assert!(graph.is_test_reference_covered(export, commonjs));
1333    }
1334
1335    #[test]
1336    fn mocked_intermediate_barrel_blocks_a_reference_despite_an_alternate_target_route() {
1337        let graph = build_intermediate_replacement_graph(false, false, false);
1338        let export = &graph.modules[2].exports[0];
1339        let hops = graph.reference_path_hops(export, 0);
1340
1341        assert!(graph.modules[2].is_test_reachable());
1342        assert_eq!(
1343            hops,
1344            vec![
1345                (FileId(2), ModuleLoadMechanism::EsModule),
1346                (FileId(1), ModuleLoadMechanism::EsModule),
1347            ]
1348        );
1349        assert!(!graph.is_test_reference_covered(export, 0));
1350    }
1351
1352    #[test]
1353    fn mocked_re_export_cycle_member_blocks_the_cyclic_reference_path() {
1354        let graph = build_intermediate_replacement_graph(false, true, false);
1355        let export = &graph.modules[2].exports[0];
1356
1357        assert!(
1358            graph
1359                .re_export_cycles
1360                .iter()
1361                .any(|cycle| cycle.file_ids == vec![FileId(1), FileId(2)])
1362        );
1363        assert!(graph.modules[2].is_test_reachable());
1364        assert!(!graph.is_test_reference_covered(export, 0));
1365    }
1366
1367    #[test]
1368    fn commonjs_barrel_hop_bypasses_only_its_own_replacement() {
1369        let graph = build_intermediate_replacement_graph(true, false, false);
1370        let export = &graph.modules[2].exports[0];
1371
1372        assert_eq!(
1373            graph.reference_path_hops(export, 0),
1374            vec![
1375                (FileId(2), ModuleLoadMechanism::EsModule),
1376                (FileId(1), ModuleLoadMechanism::CommonJsRequire),
1377            ]
1378        );
1379        assert!(graph.is_test_reference_covered(export, 0));
1380    }
1381
1382    #[test]
1383    fn commonjs_barrel_hop_does_not_bypass_a_replaced_esm_target() {
1384        let graph = build_intermediate_replacement_graph(true, false, true);
1385        let export = &graph.modules[2].exports[0];
1386
1387        assert!(!graph.is_test_reference_covered(export, 0));
1388    }
1389
1390    #[test]
1391    fn reference_paths_survive_graph_cache_serialization() {
1392        let graph = build_intermediate_replacement_graph(false, false, false);
1393        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1394        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1395        let export = &decoded.modules[2].exports[0];
1396
1397        assert_eq!(
1398            decoded.reference_path_hops(export, 0),
1399            vec![
1400                (FileId(2), ModuleLoadMechanism::EsModule),
1401                (FileId(1), ModuleLoadMechanism::EsModule),
1402            ]
1403        );
1404        assert!(!decoded.is_test_reference_covered(export, 0));
1405    }
1406
1407    #[test]
1408    fn require_context_keeps_a_replaced_target_covered() {
1409        let graph = build_masked_pattern_graph(&[ModuleLoadMechanism::CommonJsRequire]);
1410        let export = &graph.modules[1].exports[0];
1411
1412        assert!(graph.modules[1].is_test_reachable());
1413        assert_eq!(
1414            graph.reference_path_hops(export, 0)[0].1,
1415            ModuleLoadMechanism::CommonJsRequire
1416        );
1417        assert!(graph.is_test_reference_covered(export, 0));
1418    }
1419
1420    #[test]
1421    fn overlapping_patterns_preserve_exact_esm_and_commonjs_coverage() {
1422        let graph = build_masked_pattern_graph(&[
1423            ModuleLoadMechanism::EsModule,
1424            ModuleLoadMechanism::CommonJsRequire,
1425        ]);
1426        let export = &graph.modules[1].exports[0];
1427
1428        assert!(graph.modules[1].is_test_reachable());
1429        assert_eq!(export.references.len(), 2);
1430        let esm = (0..export.references.len())
1431            .find(|&index| {
1432                graph.reference_path_hops(export, index)[0].1 == ModuleLoadMechanism::EsModule
1433            })
1434            .expect("ESM pattern reference");
1435        let commonjs = (0..export.references.len())
1436            .find(|&index| {
1437                graph.reference_path_hops(export, index)[0].1
1438                    == ModuleLoadMechanism::CommonJsRequire
1439            })
1440            .expect("CommonJS pattern reference");
1441        assert!(!graph.is_test_reference_covered(export, esm));
1442        assert!(graph.is_test_reference_covered(export, commonjs));
1443    }
1444
1445    #[test]
1446    fn replacement_stops_before_a_masked_cycle_member() {
1447        let graph = build_reachability_graph_with_replacements(
1448            3,
1449            &[(0, 1, false), (1, 2, false), (2, 1, false)],
1450            &[],
1451            &[0],
1452            &[ResolvedReplacedModuleTarget {
1453                source_file: FileId(0),
1454                target_file: FileId(2),
1455            }],
1456        );
1457
1458        assert!(graph.modules[0].is_test_reachable());
1459        assert!(graph.modules[1].is_test_reachable());
1460        assert!(!graph.modules[2].is_test_reachable());
1461    }
1462
1463    #[test]
1464    fn identical_masks_share_one_reachability_profile() {
1465        let graph = build_reachability_graph_with_replacements(
1466            3,
1467            &[(0, 2, false), (1, 2, false)],
1468            &[],
1469            &[0, 1],
1470            &[
1471                ResolvedReplacedModuleTarget {
1472                    source_file: FileId(1),
1473                    target_file: FileId(2),
1474                },
1475                ResolvedReplacedModuleTarget {
1476                    source_file: FileId(0),
1477                    target_file: FileId(2),
1478                },
1479            ],
1480        );
1481
1482        assert_eq!(graph.test_reachability_index.profile_count, 1);
1483        assert!(graph.test_reachability_index.profile_masks(FileId(2), 0));
1484        assert!(graph.test_reachability_index.profile_reaches(FileId(0), 0));
1485        assert!(graph.test_reachability_index.profile_reaches(FileId(1), 0));
1486        assert!(!graph.modules[2].is_test_reachable());
1487    }
1488
1489    #[test]
1490    fn an_unmasked_root_keeps_the_shared_target_test_reachable() {
1491        let graph = build_reachability_graph_with_replacements(
1492            3,
1493            &[(0, 2, false), (1, 2, false)],
1494            &[],
1495            &[0, 1],
1496            &[ResolvedReplacedModuleTarget {
1497                source_file: FileId(0),
1498                target_file: FileId(2),
1499            }],
1500        );
1501
1502        assert_eq!(graph.test_reachability_index.profile_count, 2);
1503        assert!(graph.modules[2].is_test_reachable());
1504        assert!(!graph.test_reachability_index.profile_masks(FileId(2), 0));
1505        assert!(graph.test_reachability_index.profile_reaches(FileId(1), 0));
1506        assert!(graph.test_reachability_index.profile_reaches(FileId(2), 0));
1507        assert!(graph.test_reachability_index.profile_masks(FileId(2), 1));
1508        assert!(graph.test_reachability_index.profile_reaches(FileId(0), 1));
1509        let export = &graph.modules[2].exports[0];
1510        let reference_from = |source| {
1511            export
1512                .references
1513                .iter()
1514                .position(|reference| reference.from_file == source)
1515                .expect("both test roots reference the target export")
1516        };
1517        assert!(!graph.is_test_reference_covered(export, reference_from(FileId(0))));
1518        assert!(graph.is_test_reference_covered(export, reference_from(FileId(1))));
1519    }
1520
1521    #[test]
1522    fn replacement_declared_outside_a_test_root_keeps_the_fast_path() {
1523        let graph = build_reachability_graph_with_replacements(
1524            3,
1525            &[(0, 1, false)],
1526            &[],
1527            &[0],
1528            &[ResolvedReplacedModuleTarget {
1529                source_file: FileId(2),
1530                target_file: FileId(1),
1531            }],
1532        );
1533
1534        assert_eq!(graph.test_reachability_index.profile_count, 0);
1535        assert!(graph.modules[1].is_test_reachable());
1536    }
1537
1538    #[test]
1539    fn test_reachability_plan_uses_no_roots_without_test_entries() {
1540        let test_roots = FxHashSet::default();
1541
1542        let plan = TestReachabilityPlan::new(&test_roots, &[], 3);
1543
1544        assert!(matches!(&plan, TestReachabilityPlan::NoRoots));
1545        assert!(!plan.requires_reference_provenance());
1546    }
1547
1548    #[test]
1549    fn test_reachability_plan_ignores_replacements_outside_test_roots() {
1550        let test_roots = FxHashSet::from_iter([FileId(0)]);
1551        let replacements = [ResolvedReplacedModuleTarget {
1552            source_file: FileId(1),
1553            target_file: FileId(2),
1554        }];
1555
1556        let plan = TestReachabilityPlan::new(&test_roots, &replacements, 3);
1557
1558        assert!(matches!(&plan, TestReachabilityPlan::Legacy { .. }));
1559        assert!(!plan.requires_reference_provenance());
1560    }
1561
1562    #[test]
1563    fn test_reachability_plan_ignores_out_of_capacity_targets() {
1564        let test_roots = FxHashSet::from_iter([FileId(0)]);
1565        let replacements = [ResolvedReplacedModuleTarget {
1566            source_file: FileId(0),
1567            target_file: FileId(3),
1568        }];
1569
1570        let plan = TestReachabilityPlan::new(&test_roots, &replacements, 3);
1571
1572        assert!(matches!(&plan, TestReachabilityPlan::Legacy { .. }));
1573        assert!(!plan.requires_reference_provenance());
1574    }
1575
1576    #[test]
1577    fn test_reachability_plan_groups_roots_by_normalized_replacement_mask() {
1578        let test_roots = FxHashSet::from_iter([FileId(0), FileId(1), FileId(2)]);
1579        let replacements = [
1580            ResolvedReplacedModuleTarget {
1581                source_file: FileId(0),
1582                target_file: FileId(4),
1583            },
1584            ResolvedReplacedModuleTarget {
1585                source_file: FileId(0),
1586                target_file: FileId(4),
1587            },
1588            ResolvedReplacedModuleTarget {
1589                source_file: FileId(1),
1590                target_file: FileId(4),
1591            },
1592        ];
1593
1594        let plan = TestReachabilityPlan::new(&test_roots, &replacements, 5);
1595
1596        assert!(plan.requires_reference_provenance());
1597        let TestReachabilityPlan::Profiled { profiles } = plan else {
1598            panic!("valid test-root replacements must use profiled reachability");
1599        };
1600        assert_eq!(
1601            profiles,
1602            vec![
1603                TestReachabilityProfile {
1604                    masked_targets: Vec::new(),
1605                    roots: vec![FileId(2)],
1606                },
1607                TestReachabilityProfile {
1608                    masked_targets: vec![FileId(4)],
1609                    roots: vec![FileId(0), FileId(1)],
1610                },
1611            ]
1612        );
1613    }
1614
1615    type UniqueMaskInputs = (
1616        Vec<u32>,
1617        Vec<(u32, u32, bool)>,
1618        Vec<ResolvedReplacedModuleTarget>,
1619    );
1620
1621    /// Build roots 0..count, each importing and mocking a unique target at
1622    /// count + root, so every root lands in its own mask profile.
1623    fn unique_mask_graph_inputs(count: u32) -> UniqueMaskInputs {
1624        let test_roots: Vec<u32> = (0..count).collect();
1625        let mut edges = Vec::with_capacity(test_roots.len());
1626        let mut replacements = Vec::with_capacity(test_roots.len());
1627        for &root in &test_roots {
1628            let masked_target = count + root;
1629            edges.push((root, masked_target, false));
1630            replacements.push(ResolvedReplacedModuleTarget {
1631                source_file: FileId(root),
1632                target_file: FileId(masked_target),
1633            });
1634        }
1635        (test_roots, edges, replacements)
1636    }
1637
1638    #[test]
1639    #[cfg_attr(
1640        miri,
1641        ignore = "thousands of synthetic profiles are too slow under miri"
1642    )]
1643    fn profile_count_at_the_safety_cap_keeps_profiled_masking() {
1644        let count = u32::try_from(super::PROFILE_COUNT_SAFETY_CAP).expect("cap fits in u32");
1645        let total = count as usize * 2;
1646        let (test_roots, edges, replacements) = unique_mask_graph_inputs(count);
1647        let graph = build_reachability_graph_with_replacements(
1648            total,
1649            &edges,
1650            &[],
1651            &test_roots,
1652            &replacements,
1653        );
1654
1655        assert_eq!(
1656            graph.test_reachability_index.profile_count,
1657            super::PROFILE_COUNT_SAFETY_CAP
1658        );
1659        // Profiles sort by mask, so profile 0 belongs to root 0 whose unique
1660        // target is FileId(count).
1661        assert!(
1662            graph
1663                .test_reachability_index
1664                .profile_masks(FileId(count), 0)
1665        );
1666        assert!(graph.modules[0].is_test_reachable());
1667        // Masking still applies under the cap: the mocked target is only
1668        // imported by the root that replaces it, so it stays unreachable.
1669        assert!(!graph.modules[count as usize].is_test_reachable());
1670        assert_eq!(graph.test_masking_state(), TestMaskingState::Active);
1671    }
1672
1673    #[test]
1674    #[cfg_attr(
1675        miri,
1676        ignore = "thousands of synthetic profiles are too slow under miri"
1677    )]
1678    fn profile_count_above_the_safety_cap_falls_back_to_coarse_reachability() {
1679        let count = u32::try_from(super::PROFILE_COUNT_SAFETY_CAP * 4).expect("cap fits in u32");
1680        let total = count as usize * 2;
1681        let (test_roots, edges, replacements) = unique_mask_graph_inputs(count);
1682        let graph = build_reachability_graph_with_replacements(
1683            total,
1684            &edges,
1685            &[],
1686            &test_roots,
1687            &replacements,
1688        );
1689        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1690
1691        let plan = TestReachabilityPlan::new(&root_set, &replacements, total);
1692        assert!(matches!(
1693            &plan,
1694            TestReachabilityPlan::Legacy { capped: true, .. }
1695        ));
1696        assert!(!plan.requires_reference_provenance());
1697
1698        // Fail-open: no profiles survive, and mocked targets stay
1699        // test-reachable exactly as the pre-profile coarse pass reported them.
1700        assert_eq!(graph.test_reachability_index.profile_count, 0);
1701        for &root in &test_roots {
1702            assert!(graph.modules[root as usize].is_test_reachable());
1703            assert!(graph.modules[(count + root) as usize].is_test_reachable());
1704        }
1705
1706        // The capped state is visible, and a mocked export keeps its reach
1707        // class instead of `MockedOnly`.
1708        assert_eq!(graph.test_masking_state(), TestMaskingState::Capped);
1709        let mocked = &graph.modules[count as usize];
1710        let reach =
1711            graph.export_test_reach(mocked.file_id, &mocked.exports[0], &|file| file.0 < count);
1712        assert_eq!(reach.reach, ExportTestReach::Direct);
1713    }
1714
1715    #[test]
1716    fn replacement_profiles_survive_graph_cache_serialization() {
1717        let graph = build_reachability_graph_with_replacements(
1718            2,
1719            &[(0, 1, false)],
1720            &[],
1721            &[0],
1722            &[ResolvedReplacedModuleTarget {
1723                source_file: FileId(0),
1724                target_file: FileId(1),
1725            }],
1726        );
1727
1728        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1729        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1730
1731        assert_eq!(decoded.test_reachability_index.profile_count, 1);
1732        assert!(decoded.test_reachability_index.profile_masks(FileId(1), 0));
1733        assert!(
1734            decoded
1735                .test_reachability_index
1736                .profile_reaches(FileId(0), 0)
1737        );
1738        assert!(
1739            !decoded
1740                .test_reachability_index
1741                .profile_reaches(FileId(1), 0)
1742        );
1743    }
1744
1745    #[test]
1746    #[cfg_attr(miri, ignore = "too slow under Miri: a scale test of safe code")]
1747    fn many_unmasked_roots_use_one_test_traversal() {
1748        let test_roots: Vec<u32> = (0..512).collect();
1749        let graph = build_reachability_graph(512, &[], &[], &test_roots);
1750        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1751
1752        let plan = TestReachabilityPlan::new(&root_set, &[], 512);
1753        let (reachability, traversals) =
1754            capture_traversals(|| graph.collect_test_reachable(plan, 512));
1755
1756        assert_eq!(traversals, 1);
1757        let (reachable, _) = reachability.into_parts();
1758        let reachable = reachable.expect("test roots have a reachability bitset");
1759        assert!(
1760            root_set
1761                .iter()
1762                .all(|root| reachable.contains(root.0 as usize))
1763        );
1764    }
1765
1766    #[test]
1767    #[cfg_attr(miri, ignore = "too slow under Miri: a scale test of safe code")]
1768    fn many_roots_share_one_profile_worklist() {
1769        let test_roots: Vec<u32> = (0..128).collect();
1770        let edges: Vec<_> = test_roots
1771            .iter()
1772            .copied()
1773            .map(|root| (root, 128, false))
1774            .collect();
1775        let replacements: Vec<_> = (0..64)
1776            .map(|root| ResolvedReplacedModuleTarget {
1777                source_file: FileId(root),
1778                target_file: FileId(128),
1779            })
1780            .collect();
1781        let graph = build_reachability_graph_with_replacements(
1782            129,
1783            &edges,
1784            &[],
1785            &test_roots,
1786            &replacements,
1787        );
1788        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1789
1790        let plan = TestReachabilityPlan::new(&root_set, &replacements, 129);
1791        let (reachability, traversals) =
1792            capture_traversals(|| graph.collect_test_reachable(plan, 129));
1793
1794        assert_eq!(traversals, 1);
1795        assert_eq!(reachability.dirty_word_pop_count(), 129);
1796    }
1797
1798    #[test]
1799    #[cfg_attr(miri, ignore = "too slow under Miri: a scale test of safe code")]
1800    fn unique_masks_cross_profile_words_in_one_deterministic_worklist() {
1801        let test_roots: Vec<u32> = (0..65).collect();
1802        let mut edges = Vec::with_capacity(test_roots.len() * 2);
1803        let mut replacements = Vec::with_capacity(test_roots.len());
1804        for &root in &test_roots {
1805            let masked_target = 65 + root;
1806            edges.push((root, masked_target, false));
1807            edges.push((root, 130, false));
1808            replacements.push(ResolvedReplacedModuleTarget {
1809                source_file: FileId(root),
1810                target_file: FileId(masked_target),
1811            });
1812        }
1813        let graph = build_reachability_graph_with_replacements(
1814            131,
1815            &edges,
1816            &[],
1817            &test_roots,
1818            &replacements,
1819        );
1820        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1821
1822        let plan = TestReachabilityPlan::new(&root_set, &replacements, 131);
1823        let (reachability, traversals) =
1824            capture_traversals(|| graph.collect_test_reachable(plan, 131));
1825
1826        assert_eq!(traversals, 1);
1827        assert_eq!(reachability.dirty_word_pop_count(), 67);
1828        assert_eq!(graph.test_reachability_index.profile_count, 65);
1829        assert_eq!(graph.test_reachability_index.words_per_file, 2);
1830        assert_eq!(graph.test_reachability_index.masked_profiles.len(), 65);
1831        assert!(
1832            graph
1833                .test_reachability_index
1834                .profile_reaches(FileId(130), 0)
1835        );
1836        assert!(
1837            graph
1838                .test_reachability_index
1839                .profile_reaches(FileId(130), 64)
1840        );
1841        assert!(graph.test_reachability_index.profile_masks(FileId(65), 0));
1842        assert!(graph.test_reachability_index.profile_masks(FileId(129), 64));
1843        assert!(!graph.test_reachability_index.profile_reaches(FileId(65), 0));
1844        assert!(
1845            !graph
1846                .test_reachability_index
1847                .profile_reaches(FileId(129), 64)
1848        );
1849
1850        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1851        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1852        assert_eq!(decoded.test_reachability_index.profile_count, 65);
1853        assert_eq!(decoded.test_reachability_index.words_per_file, 2);
1854        assert_eq!(decoded.test_reachability_index.masked_profiles.len(), 65);
1855        assert!(
1856            decoded
1857                .test_reachability_index
1858                .profile_reaches(FileId(130), 64)
1859        );
1860        assert!(
1861            decoded
1862                .test_reachability_index
1863                .profile_masks(FileId(129), 64)
1864        );
1865    }
1866
1867    #[test]
1868    #[cfg_attr(miri, ignore = "too slow under Miri: a scale test of safe code")]
1869    fn staggered_profile_words_propagate_without_clean_word_scans() {
1870        const PROFILE_COUNT: u32 = 65;
1871        const MASK_START: u32 = PROFILE_COUNT;
1872        const FANOUT_START: u32 = MASK_START + PROFILE_COUNT;
1873        const FANOUT_COUNT: u32 = 8;
1874
1875        let test_roots: Vec<u32> = (0..PROFILE_COUNT).collect();
1876        let mut edges = Vec::new();
1877        for root in 1..PROFILE_COUNT {
1878            edges.push((root, root - 1, false));
1879        }
1880        for target in FANOUT_START..FANOUT_START + FANOUT_COUNT {
1881            edges.push((0, target, false));
1882        }
1883        let replacements: Vec<_> = test_roots
1884            .iter()
1885            .copied()
1886            .map(|root| ResolvedReplacedModuleTarget {
1887                source_file: FileId(root),
1888                target_file: FileId(MASK_START + root),
1889            })
1890            .collect();
1891        let file_count = (FANOUT_START + FANOUT_COUNT) as usize;
1892        let graph = build_reachability_graph_with_replacements(
1893            file_count,
1894            &edges,
1895            &[],
1896            &test_roots,
1897            &replacements,
1898        );
1899        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1900
1901        let plan = TestReachabilityPlan::new(&root_set, &replacements, file_count);
1902        let reachability = graph.collect_test_reachable(plan, file_count);
1903
1904        assert_eq!(graph.test_reachability_index.words_per_file, 2);
1905        let expected_dirty_word_pops =
1906            (PROFILE_COUNT * (PROFILE_COUNT + 1) / 2 + FANOUT_COUNT * PROFILE_COUNT) as usize;
1907        assert_eq!(
1908            reachability.dirty_word_pop_count(),
1909            expected_dirty_word_pops
1910        );
1911        for target in FANOUT_START..FANOUT_START + FANOUT_COUNT {
1912            assert!(
1913                graph
1914                    .test_reachability_index
1915                    .profile_reaches(FileId(target), 0)
1916            );
1917            assert!(
1918                graph
1919                    .test_reachability_index
1920                    .profile_reaches(FileId(target), 64)
1921            );
1922        }
1923    }
1924
1925    #[test]
1926    #[cfg_attr(miri, ignore = "too slow under Miri: a scale test of safe code")]
1927    fn no_profile_coverage_omits_unused_reference_provenance() {
1928        const CHAIN_LENGTH: u32 = 128;
1929
1930        let files: Vec<_> = (0..=CHAIN_LENGTH)
1931            .map(|id| DiscoveredFile {
1932                id: FileId(id),
1933                path: PathBuf::from(format!("/project/file{id}.ts")),
1934                size_bytes: 100,
1935            })
1936            .collect();
1937        let mut modules = Vec::with_capacity(files.len());
1938        modules.push(ResolvedModule {
1939            file_id: FileId(0),
1940            path: files[0].path.clone(),
1941            resolved_imports: vec![intermediate_import(
1942                "./file1",
1943                FileId(1),
1944                ImportedName::Named("value".to_string()),
1945                false,
1946            )],
1947            ..ResolvedModule::default()
1948        });
1949        for id in 1..CHAIN_LENGTH {
1950            modules.push(ResolvedModule {
1951                file_id: FileId(id),
1952                path: files[id as usize].path.clone(),
1953                re_exports: vec![ResolvedReExport {
1954                    info: ReExportInfo {
1955                        source: format!("./file{}", id + 1),
1956                        imported_name: "value".to_string(),
1957                        exported_name: "value".to_string(),
1958                        is_type_only: false,
1959                        span: oxc_span::Span::new(0, 10),
1960                        statement_span: oxc_span::Span::new(0, 0),
1961                        source_span: oxc_span::Span::new(0, 0),
1962                    },
1963                    target: ResolveResult::InternalModule(FileId(id + 1)),
1964                }],
1965                ..ResolvedModule::default()
1966            });
1967        }
1968        modules.push(ResolvedModule {
1969            file_id: FileId(CHAIN_LENGTH),
1970            path: files[CHAIN_LENGTH as usize].path.clone(),
1971            exports: vec![fallow_types::extract::ExportInfo {
1972                name: ExportName::Named("value".to_string()),
1973                local_name: Some("value".to_string()),
1974                is_type_only: false,
1975                visibility: VisibilityTag::None,
1976                expected_unused_reason: None,
1977                span: oxc_span::Span::new(0, 20),
1978                members: Vec::new(),
1979                is_side_effect_used: false,
1980                super_class: None,
1981                deprecated: false,
1982                deprecated_reason: None,
1983            }]
1984            .into(),
1985            ..ResolvedModule::default()
1986        });
1987        let test_entry_points = vec![EntryPoint {
1988            path: files[0].path.clone(),
1989            source: EntryPointSource::TestFile,
1990        }];
1991        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
1992            &modules,
1993            &[],
1994            &test_entry_points,
1995            &[],
1996            &test_entry_points,
1997            &files,
1998        );
1999        let export = &graph.modules[CHAIN_LENGTH as usize].exports[0];
2000
2001        assert_eq!(graph.test_reachability_index.profile_count, 0);
2002        assert!(
2003            export.reference_paths.is_empty(),
2004            "the provenance side table must stay unallocated on the fast path"
2005        );
2006        assert!(graph.reference_paths.is_empty());
2007        assert!(graph.is_test_reference_covered(export, 0));
2008    }
2009
2010    #[test]
2011    fn overall_reachable_is_union_of_runtime_and_test() {
2012        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0], &[2]);
2013        for i in 0..4 {
2014            assert!(
2015                graph.modules[i].is_reachable(),
2016                "file{i} should be overall-reachable"
2017            );
2018        }
2019    }
2020
2021    #[test]
2022    fn shared_dependency_is_both_runtime_and_test_reachable() {
2023        let graph = build_reachability_graph(3, &[(0, 2), (1, 2)], &[0], &[1]);
2024        assert!(graph.modules[2].is_runtime_reachable());
2025        assert!(graph.modules[2].is_test_reachable());
2026        assert!(graph.modules[2].is_reachable());
2027    }
2028
2029    #[test]
2030    fn runtime_same_as_overall_reuses_bfs() {
2031        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[0], &[]);
2032        for i in 0..3 {
2033            assert_eq!(
2034                graph.modules[i].is_reachable(),
2035                graph.modules[i].is_runtime_reachable(),
2036                "file{i}: reachable and runtime_reachable should match when runtime==overall"
2037            );
2038        }
2039    }
2040
2041    #[test]
2042    fn empty_test_entries_none_test_reachable() {
2043        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[0], &[]);
2044        for i in 0..3 {
2045            assert!(
2046                !graph.modules[i].is_test_reachable(),
2047                "file{i} should not be test-reachable when no test entries exist"
2048            );
2049        }
2050    }
2051
2052    #[test]
2053    fn only_test_entries_runtime_unreachable() {
2054        let graph = build_reachability_graph(2, &[(0, 1)], &[], &[0]);
2055        assert!(graph.modules[0].is_test_reachable());
2056        assert!(graph.modules[1].is_test_reachable());
2057        assert!(!graph.modules[0].is_runtime_reachable());
2058        assert!(!graph.modules[1].is_runtime_reachable());
2059        assert!(graph.modules[0].is_reachable());
2060        assert!(graph.modules[1].is_reachable());
2061    }
2062
2063    #[test]
2064    fn diamond_dependency_all_reachable() {
2065        let graph = build_reachability_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)], &[0], &[]);
2066        for i in 0..4 {
2067            assert!(
2068                graph.modules[i].is_reachable(),
2069                "file{i} in diamond should be reachable"
2070            );
2071        }
2072    }
2073
2074    #[test]
2075    fn multiple_entry_points_reach_disjoint_subtrees() {
2076        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0, 2], &[]);
2077        for i in 0..4 {
2078            assert!(
2079                graph.modules[i].is_reachable(),
2080                "file{i} should be reachable from one of the entry points"
2081            );
2082        }
2083    }
2084
2085    #[test]
2086    fn empty_graph_no_panics() {
2087        let graph = build_reachability_graph(0, &[], &[], &[]);
2088        assert_eq!(graph.module_count(), 0);
2089    }
2090
2091    fn reaching(graph: &ModuleGraph, file: u32) -> Vec<u32> {
2092        let map = graph.test_entry_points_reaching(&[FileId(file)]);
2093        map[&FileId(file)].iter().map(|root| root.0).collect()
2094    }
2095
2096    #[test]
2097    fn per_entry_reach_applies_the_replacement_mask_of_each_test() {
2098        // 0 is a test that mocks 2 and imports it through 1.
2099        // 3 is a test that imports 2 directly.
2100        let graph = build_reachability_graph_with_replacements(
2101            4,
2102            &[(0, 1, false), (1, 2, false), (3, 2, false)],
2103            &[],
2104            &[0, 3],
2105            &[ResolvedReplacedModuleTarget {
2106                source_file: FileId(0),
2107                target_file: FileId(2),
2108            }],
2109        );
2110
2111        assert_eq!(reaching(&graph, 2), vec![3]);
2112        assert_eq!(reaching(&graph, 1), vec![0]);
2113    }
2114
2115    #[test]
2116    fn per_entry_reach_keeps_a_masked_target_behind_a_commonjs_edge() {
2117        let graph = build_reachability_graph_with_replacements(
2118            3,
2119            &[(0, 1, true), (2, 1, false)],
2120            &[],
2121            &[0, 2],
2122            &[ResolvedReplacedModuleTarget {
2123                source_file: FileId(0),
2124                target_file: FileId(1),
2125            }],
2126        );
2127
2128        assert_eq!(reaching(&graph, 1), vec![0, 2]);
2129    }
2130
2131    #[test]
2132    fn per_entry_reach_without_masks_lists_each_test_that_imports_the_file() {
2133        let graph = build_reachability_graph(5, &[(0, 1), (1, 2), (3, 2), (4, 1)], &[], &[0, 3]);
2134
2135        assert_eq!(reaching(&graph, 2), vec![0, 3]);
2136        assert_eq!(reaching(&graph, 1), vec![0]);
2137        assert!(reaching(&graph, 4).is_empty());
2138    }
2139
2140    #[test]
2141    fn per_entry_reach_spans_more_tests_than_one_profile_word() {
2142        // 70 tests import file 70. Test 0 mocks it, so 69 tests reach it.
2143        let tests: Vec<u32> = (0..70).collect();
2144        let edges: Vec<(u32, u32, bool)> = tests.iter().map(|&test| (test, 70, false)).collect();
2145        let graph = build_reachability_graph_with_replacements(
2146            71,
2147            &edges,
2148            &[],
2149            &tests,
2150            &[ResolvedReplacedModuleTarget {
2151                source_file: FileId(0),
2152                target_file: FileId(70),
2153            }],
2154        );
2155
2156        assert_eq!(reaching(&graph, 70), (1..70).collect::<Vec<u32>>());
2157        assert!(graph.modules[70].is_test_reachable());
2158    }
2159
2160    #[test]
2161    fn per_entry_reach_agrees_with_test_reachability() {
2162        let graph = build_reachability_graph_with_replacements(
2163            6,
2164            &[(0, 1, false), (1, 2, false), (3, 4, false), (4, 2, false)],
2165            &[],
2166            &[0, 3],
2167            &[
2168                ResolvedReplacedModuleTarget {
2169                    source_file: FileId(0),
2170                    target_file: FileId(2),
2171                },
2172                ResolvedReplacedModuleTarget {
2173                    source_file: FileId(3),
2174                    target_file: FileId(4),
2175                },
2176            ],
2177        );
2178
2179        let files: Vec<FileId> = (0..6).map(FileId).collect();
2180        let map = graph.test_entry_points_reaching(&files);
2181        for file in &files {
2182            assert_eq!(
2183                !map[file].is_empty(),
2184                graph.modules[file.0 as usize].is_test_reachable(),
2185                "file{}",
2186                file.0
2187            );
2188        }
2189        assert_eq!(map[&FileId(1)], vec![FileId(0)]);
2190        assert!(map[&FileId(2)].is_empty());
2191    }
2192
2193    /// Classify the first export of `file` with the given test files.
2194    fn reach_of(
2195        graph: &ModuleGraph,
2196        file: u32,
2197        tests: &[u32],
2198    ) -> crate::graph::ExportTestReachResult {
2199        let module = &graph.modules[file as usize];
2200        graph.export_test_reach(module.file_id, &module.exports[0], &|id| {
2201            tests.contains(&id.0)
2202        })
2203    }
2204
2205    #[test]
2206    fn test_reach_without_mocks_reports_no_mocks() {
2207        let graph = build_reachability_graph(2, &[(0, 1)], &[], &[0]);
2208
2209        assert_eq!(graph.test_masking_state(), TestMaskingState::NoMocks);
2210        let reach = reach_of(&graph, 1, &[0]);
2211        assert_eq!(reach.reach, ExportTestReach::Direct);
2212        assert_eq!(reach.tests, vec![FileId(0)]);
2213    }
2214
2215    #[test]
2216    fn test_reach_through_a_non_test_module_is_transitive() {
2217        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[], &[0]);
2218
2219        assert_eq!(reach_of(&graph, 2, &[0]).reach, ExportTestReach::Transitive);
2220    }
2221
2222    #[test]
2223    fn test_reach_without_a_reference_separates_file_reach() {
2224        let files: Vec<_> = (0..3)
2225            .map(|id| DiscoveredFile {
2226                id: FileId(id),
2227                path: PathBuf::from(format!("/project/file{id}.ts")),
2228                size_bytes: 100,
2229            })
2230            .collect();
2231        let export = || {
2232            vec![fallow_types::extract::ExportInfo {
2233                name: ExportName::Named("value".to_string()),
2234                local_name: Some("value".to_string()),
2235                is_type_only: false,
2236                visibility: VisibilityTag::None,
2237                expected_unused_reason: None,
2238                span: oxc_span::Span::new(0, 20),
2239                members: Vec::new(),
2240                is_side_effect_used: false,
2241                super_class: None,
2242                deprecated: false,
2243                deprecated_reason: None,
2244            }]
2245            .into()
2246        };
2247        let modules = vec![
2248            ResolvedModule {
2249                file_id: FileId(0),
2250                path: files[0].path.clone(),
2251                resolved_imports: vec![intermediate_import(
2252                    "./file1",
2253                    FileId(1),
2254                    ImportedName::SideEffect,
2255                    false,
2256                )],
2257                ..ResolvedModule::default()
2258            },
2259            ResolvedModule {
2260                file_id: FileId(1),
2261                path: files[1].path.clone(),
2262                exports: export(),
2263                ..ResolvedModule::default()
2264            },
2265            ResolvedModule {
2266                file_id: FileId(2),
2267                path: files[2].path.clone(),
2268                exports: export(),
2269                ..ResolvedModule::default()
2270            },
2271        ];
2272        let test_entry_points = vec![EntryPoint {
2273            path: files[0].path.clone(),
2274            source: EntryPointSource::TestFile,
2275        }];
2276        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
2277            &modules,
2278            &[],
2279            &test_entry_points,
2280            &[],
2281            &test_entry_points,
2282            &files,
2283        );
2284
2285        assert_eq!(reach_of(&graph, 1, &[0]).reach, ExportTestReach::None);
2286        assert_eq!(reach_of(&graph, 2, &[0]).reach, ExportTestReach::NoneFile);
2287    }
2288
2289    #[test]
2290    fn test_reach_reports_an_esm_mask_on_a_direct_import() {
2291        let graph = build_reachability_graph_with_replacements(
2292            2,
2293            &[(0, 1, false)],
2294            &[],
2295            &[0],
2296            &[ResolvedReplacedModuleTarget {
2297                source_file: FileId(0),
2298                target_file: FileId(1),
2299            }],
2300        );
2301
2302        assert_eq!(graph.test_masking_state(), TestMaskingState::Active);
2303        // The masked module is not test-reachable, so without the mask check
2304        // the export would read as `NoneFile`.
2305        assert!(!graph.modules[1].is_test_reachable());
2306        let reach = reach_of(&graph, 1, &[0]);
2307        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2308        assert_eq!(reach.tests, vec![FileId(0)]);
2309        assert_eq!(reach.mocked_targets, vec![FileId(1)]);
2310    }
2311
2312    #[test]
2313    fn test_reach_reports_a_mask_behind_a_barrel() {
2314        let graph = build_masked_re_export_graph(false, false);
2315        let reach = reach_of(&graph, 2, &[0]);
2316
2317        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2318        assert_eq!(reach.mocked_targets, vec![FileId(2)]);
2319    }
2320
2321    #[test]
2322    fn test_reach_names_a_mocked_intermediate_barrel() {
2323        let graph = build_intermediate_replacement_graph(false, false, false);
2324        let reach = reach_of(&graph, 2, &[0]);
2325
2326        // An alternate route keeps the target test-reachable, but the only
2327        // reference passes through the mocked barrel.
2328        assert!(graph.modules[2].is_test_reachable());
2329        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2330        assert_eq!(reach.mocked_targets, vec![FileId(1)]);
2331    }
2332
2333    #[test]
2334    fn test_reach_keeps_a_commonjs_require_unmasked() {
2335        let graph = build_reachability_graph_with_replacements(
2336            2,
2337            &[(0, 1, true)],
2338            &[],
2339            &[0],
2340            &[ResolvedReplacedModuleTarget {
2341                source_file: FileId(0),
2342                target_file: FileId(1),
2343            }],
2344        );
2345
2346        assert_eq!(reach_of(&graph, 1, &[0]).reach, ExportTestReach::Direct);
2347    }
2348
2349    #[test]
2350    fn test_reach_through_a_required_barrel_checks_only_esm_hops() {
2351        let barrel_mocked = build_intermediate_replacement_graph(true, false, false);
2352        assert_eq!(
2353            reach_of(&barrel_mocked, 2, &[0]).reach,
2354            ExportTestReach::Direct
2355        );
2356
2357        let target_mocked = build_intermediate_replacement_graph(true, false, true);
2358        let reach = reach_of(&target_mocked, 2, &[0]);
2359        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2360        assert_eq!(reach.mocked_targets, vec![FileId(2)]);
2361    }
2362
2363    #[test]
2364    fn test_reach_with_an_unmasked_second_root_is_direct() {
2365        let graph = build_reachability_graph_with_replacements(
2366            3,
2367            &[(0, 2, false), (1, 2, false)],
2368            &[],
2369            &[0, 1],
2370            &[ResolvedReplacedModuleTarget {
2371                source_file: FileId(0),
2372                target_file: FileId(2),
2373            }],
2374        );
2375        let reach = reach_of(&graph, 2, &[0, 1]);
2376
2377        assert_eq!(reach.reach, ExportTestReach::Direct);
2378        assert_eq!(reach.tests, vec![FileId(1)]);
2379    }
2380
2381    #[test]
2382    fn test_reach_survives_graph_cache_serialization() {
2383        let graph = build_masked_re_export_graph(false, false);
2384        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
2385        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
2386
2387        assert_eq!(decoded.test_masking_state(), TestMaskingState::Active);
2388        assert_eq!(
2389            reach_of(&decoded, 2, &[0]).reach,
2390            ExportTestReach::MockedOnly
2391        );
2392    }
2393}