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    fn many_unmasked_roots_use_one_test_traversal() {
1747        let test_roots: Vec<u32> = (0..512).collect();
1748        let graph = build_reachability_graph(512, &[], &[], &test_roots);
1749        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1750
1751        let plan = TestReachabilityPlan::new(&root_set, &[], 512);
1752        let (reachability, traversals) =
1753            capture_traversals(|| graph.collect_test_reachable(plan, 512));
1754
1755        assert_eq!(traversals, 1);
1756        let (reachable, _) = reachability.into_parts();
1757        let reachable = reachable.expect("test roots have a reachability bitset");
1758        assert!(
1759            root_set
1760                .iter()
1761                .all(|root| reachable.contains(root.0 as usize))
1762        );
1763    }
1764
1765    #[test]
1766    fn many_roots_share_one_profile_worklist() {
1767        let test_roots: Vec<u32> = (0..128).collect();
1768        let edges: Vec<_> = test_roots
1769            .iter()
1770            .copied()
1771            .map(|root| (root, 128, false))
1772            .collect();
1773        let replacements: Vec<_> = (0..64)
1774            .map(|root| ResolvedReplacedModuleTarget {
1775                source_file: FileId(root),
1776                target_file: FileId(128),
1777            })
1778            .collect();
1779        let graph = build_reachability_graph_with_replacements(
1780            129,
1781            &edges,
1782            &[],
1783            &test_roots,
1784            &replacements,
1785        );
1786        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1787
1788        let plan = TestReachabilityPlan::new(&root_set, &replacements, 129);
1789        let (reachability, traversals) =
1790            capture_traversals(|| graph.collect_test_reachable(plan, 129));
1791
1792        assert_eq!(traversals, 1);
1793        assert_eq!(reachability.dirty_word_pop_count(), 129);
1794    }
1795
1796    #[test]
1797    fn unique_masks_cross_profile_words_in_one_deterministic_worklist() {
1798        let test_roots: Vec<u32> = (0..65).collect();
1799        let mut edges = Vec::with_capacity(test_roots.len() * 2);
1800        let mut replacements = Vec::with_capacity(test_roots.len());
1801        for &root in &test_roots {
1802            let masked_target = 65 + root;
1803            edges.push((root, masked_target, false));
1804            edges.push((root, 130, false));
1805            replacements.push(ResolvedReplacedModuleTarget {
1806                source_file: FileId(root),
1807                target_file: FileId(masked_target),
1808            });
1809        }
1810        let graph = build_reachability_graph_with_replacements(
1811            131,
1812            &edges,
1813            &[],
1814            &test_roots,
1815            &replacements,
1816        );
1817        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1818
1819        let plan = TestReachabilityPlan::new(&root_set, &replacements, 131);
1820        let (reachability, traversals) =
1821            capture_traversals(|| graph.collect_test_reachable(plan, 131));
1822
1823        assert_eq!(traversals, 1);
1824        assert_eq!(reachability.dirty_word_pop_count(), 67);
1825        assert_eq!(graph.test_reachability_index.profile_count, 65);
1826        assert_eq!(graph.test_reachability_index.words_per_file, 2);
1827        assert_eq!(graph.test_reachability_index.masked_profiles.len(), 65);
1828        assert!(
1829            graph
1830                .test_reachability_index
1831                .profile_reaches(FileId(130), 0)
1832        );
1833        assert!(
1834            graph
1835                .test_reachability_index
1836                .profile_reaches(FileId(130), 64)
1837        );
1838        assert!(graph.test_reachability_index.profile_masks(FileId(65), 0));
1839        assert!(graph.test_reachability_index.profile_masks(FileId(129), 64));
1840        assert!(!graph.test_reachability_index.profile_reaches(FileId(65), 0));
1841        assert!(
1842            !graph
1843                .test_reachability_index
1844                .profile_reaches(FileId(129), 64)
1845        );
1846
1847        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1848        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1849        assert_eq!(decoded.test_reachability_index.profile_count, 65);
1850        assert_eq!(decoded.test_reachability_index.words_per_file, 2);
1851        assert_eq!(decoded.test_reachability_index.masked_profiles.len(), 65);
1852        assert!(
1853            decoded
1854                .test_reachability_index
1855                .profile_reaches(FileId(130), 64)
1856        );
1857        assert!(
1858            decoded
1859                .test_reachability_index
1860                .profile_masks(FileId(129), 64)
1861        );
1862    }
1863
1864    #[test]
1865    fn staggered_profile_words_propagate_without_clean_word_scans() {
1866        const PROFILE_COUNT: u32 = 65;
1867        const MASK_START: u32 = PROFILE_COUNT;
1868        const FANOUT_START: u32 = MASK_START + PROFILE_COUNT;
1869        const FANOUT_COUNT: u32 = 8;
1870
1871        let test_roots: Vec<u32> = (0..PROFILE_COUNT).collect();
1872        let mut edges = Vec::new();
1873        for root in 1..PROFILE_COUNT {
1874            edges.push((root, root - 1, false));
1875        }
1876        for target in FANOUT_START..FANOUT_START + FANOUT_COUNT {
1877            edges.push((0, target, false));
1878        }
1879        let replacements: Vec<_> = test_roots
1880            .iter()
1881            .copied()
1882            .map(|root| ResolvedReplacedModuleTarget {
1883                source_file: FileId(root),
1884                target_file: FileId(MASK_START + root),
1885            })
1886            .collect();
1887        let file_count = (FANOUT_START + FANOUT_COUNT) as usize;
1888        let graph = build_reachability_graph_with_replacements(
1889            file_count,
1890            &edges,
1891            &[],
1892            &test_roots,
1893            &replacements,
1894        );
1895        let root_set: FxHashSet<_> = test_roots.iter().copied().map(FileId).collect();
1896
1897        let plan = TestReachabilityPlan::new(&root_set, &replacements, file_count);
1898        let reachability = graph.collect_test_reachable(plan, file_count);
1899
1900        assert_eq!(graph.test_reachability_index.words_per_file, 2);
1901        let expected_dirty_word_pops =
1902            (PROFILE_COUNT * (PROFILE_COUNT + 1) / 2 + FANOUT_COUNT * PROFILE_COUNT) as usize;
1903        assert_eq!(
1904            reachability.dirty_word_pop_count(),
1905            expected_dirty_word_pops
1906        );
1907        for target in FANOUT_START..FANOUT_START + FANOUT_COUNT {
1908            assert!(
1909                graph
1910                    .test_reachability_index
1911                    .profile_reaches(FileId(target), 0)
1912            );
1913            assert!(
1914                graph
1915                    .test_reachability_index
1916                    .profile_reaches(FileId(target), 64)
1917            );
1918        }
1919    }
1920
1921    #[test]
1922    fn no_profile_coverage_omits_unused_reference_provenance() {
1923        const CHAIN_LENGTH: u32 = 128;
1924
1925        let files: Vec<_> = (0..=CHAIN_LENGTH)
1926            .map(|id| DiscoveredFile {
1927                id: FileId(id),
1928                path: PathBuf::from(format!("/project/file{id}.ts")),
1929                size_bytes: 100,
1930            })
1931            .collect();
1932        let mut modules = Vec::with_capacity(files.len());
1933        modules.push(ResolvedModule {
1934            file_id: FileId(0),
1935            path: files[0].path.clone(),
1936            resolved_imports: vec![intermediate_import(
1937                "./file1",
1938                FileId(1),
1939                ImportedName::Named("value".to_string()),
1940                false,
1941            )],
1942            ..ResolvedModule::default()
1943        });
1944        for id in 1..CHAIN_LENGTH {
1945            modules.push(ResolvedModule {
1946                file_id: FileId(id),
1947                path: files[id as usize].path.clone(),
1948                re_exports: vec![ResolvedReExport {
1949                    info: ReExportInfo {
1950                        source: format!("./file{}", id + 1),
1951                        imported_name: "value".to_string(),
1952                        exported_name: "value".to_string(),
1953                        is_type_only: false,
1954                        span: oxc_span::Span::new(0, 10),
1955                        statement_span: oxc_span::Span::new(0, 0),
1956                        source_span: oxc_span::Span::new(0, 0),
1957                    },
1958                    target: ResolveResult::InternalModule(FileId(id + 1)),
1959                }],
1960                ..ResolvedModule::default()
1961            });
1962        }
1963        modules.push(ResolvedModule {
1964            file_id: FileId(CHAIN_LENGTH),
1965            path: files[CHAIN_LENGTH as usize].path.clone(),
1966            exports: vec![fallow_types::extract::ExportInfo {
1967                name: ExportName::Named("value".to_string()),
1968                local_name: Some("value".to_string()),
1969                is_type_only: false,
1970                visibility: VisibilityTag::None,
1971                expected_unused_reason: None,
1972                span: oxc_span::Span::new(0, 20),
1973                members: Vec::new(),
1974                is_side_effect_used: false,
1975                super_class: None,
1976                deprecated: false,
1977                deprecated_reason: None,
1978            }]
1979            .into(),
1980            ..ResolvedModule::default()
1981        });
1982        let test_entry_points = vec![EntryPoint {
1983            path: files[0].path.clone(),
1984            source: EntryPointSource::TestFile,
1985        }];
1986        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
1987            &modules,
1988            &[],
1989            &test_entry_points,
1990            &[],
1991            &test_entry_points,
1992            &files,
1993        );
1994        let export = &graph.modules[CHAIN_LENGTH as usize].exports[0];
1995
1996        assert_eq!(graph.test_reachability_index.profile_count, 0);
1997        assert!(
1998            export.reference_paths.is_empty(),
1999            "the provenance side table must stay unallocated on the fast path"
2000        );
2001        assert!(graph.reference_paths.is_empty());
2002        assert!(graph.is_test_reference_covered(export, 0));
2003    }
2004
2005    #[test]
2006    fn overall_reachable_is_union_of_runtime_and_test() {
2007        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0], &[2]);
2008        for i in 0..4 {
2009            assert!(
2010                graph.modules[i].is_reachable(),
2011                "file{i} should be overall-reachable"
2012            );
2013        }
2014    }
2015
2016    #[test]
2017    fn shared_dependency_is_both_runtime_and_test_reachable() {
2018        let graph = build_reachability_graph(3, &[(0, 2), (1, 2)], &[0], &[1]);
2019        assert!(graph.modules[2].is_runtime_reachable());
2020        assert!(graph.modules[2].is_test_reachable());
2021        assert!(graph.modules[2].is_reachable());
2022    }
2023
2024    #[test]
2025    fn runtime_same_as_overall_reuses_bfs() {
2026        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[0], &[]);
2027        for i in 0..3 {
2028            assert_eq!(
2029                graph.modules[i].is_reachable(),
2030                graph.modules[i].is_runtime_reachable(),
2031                "file{i}: reachable and runtime_reachable should match when runtime==overall"
2032            );
2033        }
2034    }
2035
2036    #[test]
2037    fn empty_test_entries_none_test_reachable() {
2038        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[0], &[]);
2039        for i in 0..3 {
2040            assert!(
2041                !graph.modules[i].is_test_reachable(),
2042                "file{i} should not be test-reachable when no test entries exist"
2043            );
2044        }
2045    }
2046
2047    #[test]
2048    fn only_test_entries_runtime_unreachable() {
2049        let graph = build_reachability_graph(2, &[(0, 1)], &[], &[0]);
2050        assert!(graph.modules[0].is_test_reachable());
2051        assert!(graph.modules[1].is_test_reachable());
2052        assert!(!graph.modules[0].is_runtime_reachable());
2053        assert!(!graph.modules[1].is_runtime_reachable());
2054        assert!(graph.modules[0].is_reachable());
2055        assert!(graph.modules[1].is_reachable());
2056    }
2057
2058    #[test]
2059    fn diamond_dependency_all_reachable() {
2060        let graph = build_reachability_graph(4, &[(0, 1), (0, 2), (1, 3), (2, 3)], &[0], &[]);
2061        for i in 0..4 {
2062            assert!(
2063                graph.modules[i].is_reachable(),
2064                "file{i} in diamond should be reachable"
2065            );
2066        }
2067    }
2068
2069    #[test]
2070    fn multiple_entry_points_reach_disjoint_subtrees() {
2071        let graph = build_reachability_graph(4, &[(0, 1), (2, 3)], &[0, 2], &[]);
2072        for i in 0..4 {
2073            assert!(
2074                graph.modules[i].is_reachable(),
2075                "file{i} should be reachable from one of the entry points"
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn empty_graph_no_panics() {
2082        let graph = build_reachability_graph(0, &[], &[], &[]);
2083        assert_eq!(graph.module_count(), 0);
2084    }
2085
2086    fn reaching(graph: &ModuleGraph, file: u32) -> Vec<u32> {
2087        let map = graph.test_entry_points_reaching(&[FileId(file)]);
2088        map[&FileId(file)].iter().map(|root| root.0).collect()
2089    }
2090
2091    #[test]
2092    fn per_entry_reach_applies_the_replacement_mask_of_each_test() {
2093        // 0 is a test that mocks 2 and imports it through 1.
2094        // 3 is a test that imports 2 directly.
2095        let graph = build_reachability_graph_with_replacements(
2096            4,
2097            &[(0, 1, false), (1, 2, false), (3, 2, false)],
2098            &[],
2099            &[0, 3],
2100            &[ResolvedReplacedModuleTarget {
2101                source_file: FileId(0),
2102                target_file: FileId(2),
2103            }],
2104        );
2105
2106        assert_eq!(reaching(&graph, 2), vec![3]);
2107        assert_eq!(reaching(&graph, 1), vec![0]);
2108    }
2109
2110    #[test]
2111    fn per_entry_reach_keeps_a_masked_target_behind_a_commonjs_edge() {
2112        let graph = build_reachability_graph_with_replacements(
2113            3,
2114            &[(0, 1, true), (2, 1, false)],
2115            &[],
2116            &[0, 2],
2117            &[ResolvedReplacedModuleTarget {
2118                source_file: FileId(0),
2119                target_file: FileId(1),
2120            }],
2121        );
2122
2123        assert_eq!(reaching(&graph, 1), vec![0, 2]);
2124    }
2125
2126    #[test]
2127    fn per_entry_reach_without_masks_lists_each_test_that_imports_the_file() {
2128        let graph = build_reachability_graph(5, &[(0, 1), (1, 2), (3, 2), (4, 1)], &[], &[0, 3]);
2129
2130        assert_eq!(reaching(&graph, 2), vec![0, 3]);
2131        assert_eq!(reaching(&graph, 1), vec![0]);
2132        assert!(reaching(&graph, 4).is_empty());
2133    }
2134
2135    #[test]
2136    fn per_entry_reach_spans_more_tests_than_one_profile_word() {
2137        // 70 tests import file 70. Test 0 mocks it, so 69 tests reach it.
2138        let tests: Vec<u32> = (0..70).collect();
2139        let edges: Vec<(u32, u32, bool)> = tests.iter().map(|&test| (test, 70, false)).collect();
2140        let graph = build_reachability_graph_with_replacements(
2141            71,
2142            &edges,
2143            &[],
2144            &tests,
2145            &[ResolvedReplacedModuleTarget {
2146                source_file: FileId(0),
2147                target_file: FileId(70),
2148            }],
2149        );
2150
2151        assert_eq!(reaching(&graph, 70), (1..70).collect::<Vec<u32>>());
2152        assert!(graph.modules[70].is_test_reachable());
2153    }
2154
2155    #[test]
2156    fn per_entry_reach_agrees_with_test_reachability() {
2157        let graph = build_reachability_graph_with_replacements(
2158            6,
2159            &[(0, 1, false), (1, 2, false), (3, 4, false), (4, 2, false)],
2160            &[],
2161            &[0, 3],
2162            &[
2163                ResolvedReplacedModuleTarget {
2164                    source_file: FileId(0),
2165                    target_file: FileId(2),
2166                },
2167                ResolvedReplacedModuleTarget {
2168                    source_file: FileId(3),
2169                    target_file: FileId(4),
2170                },
2171            ],
2172        );
2173
2174        let files: Vec<FileId> = (0..6).map(FileId).collect();
2175        let map = graph.test_entry_points_reaching(&files);
2176        for file in &files {
2177            assert_eq!(
2178                !map[file].is_empty(),
2179                graph.modules[file.0 as usize].is_test_reachable(),
2180                "file{}",
2181                file.0
2182            );
2183        }
2184        assert_eq!(map[&FileId(1)], vec![FileId(0)]);
2185        assert!(map[&FileId(2)].is_empty());
2186    }
2187
2188    /// Classify the first export of `file` with the given test files.
2189    fn reach_of(
2190        graph: &ModuleGraph,
2191        file: u32,
2192        tests: &[u32],
2193    ) -> crate::graph::ExportTestReachResult {
2194        let module = &graph.modules[file as usize];
2195        graph.export_test_reach(module.file_id, &module.exports[0], &|id| {
2196            tests.contains(&id.0)
2197        })
2198    }
2199
2200    #[test]
2201    fn test_reach_without_mocks_reports_no_mocks() {
2202        let graph = build_reachability_graph(2, &[(0, 1)], &[], &[0]);
2203
2204        assert_eq!(graph.test_masking_state(), TestMaskingState::NoMocks);
2205        let reach = reach_of(&graph, 1, &[0]);
2206        assert_eq!(reach.reach, ExportTestReach::Direct);
2207        assert_eq!(reach.tests, vec![FileId(0)]);
2208    }
2209
2210    #[test]
2211    fn test_reach_through_a_non_test_module_is_transitive() {
2212        let graph = build_reachability_graph(3, &[(0, 1), (1, 2)], &[], &[0]);
2213
2214        assert_eq!(reach_of(&graph, 2, &[0]).reach, ExportTestReach::Transitive);
2215    }
2216
2217    #[test]
2218    fn test_reach_without_a_reference_separates_file_reach() {
2219        let files: Vec<_> = (0..3)
2220            .map(|id| DiscoveredFile {
2221                id: FileId(id),
2222                path: PathBuf::from(format!("/project/file{id}.ts")),
2223                size_bytes: 100,
2224            })
2225            .collect();
2226        let export = || {
2227            vec![fallow_types::extract::ExportInfo {
2228                name: ExportName::Named("value".to_string()),
2229                local_name: Some("value".to_string()),
2230                is_type_only: false,
2231                visibility: VisibilityTag::None,
2232                expected_unused_reason: None,
2233                span: oxc_span::Span::new(0, 20),
2234                members: Vec::new(),
2235                is_side_effect_used: false,
2236                super_class: None,
2237                deprecated: false,
2238                deprecated_reason: None,
2239            }]
2240            .into()
2241        };
2242        let modules = vec![
2243            ResolvedModule {
2244                file_id: FileId(0),
2245                path: files[0].path.clone(),
2246                resolved_imports: vec![intermediate_import(
2247                    "./file1",
2248                    FileId(1),
2249                    ImportedName::SideEffect,
2250                    false,
2251                )],
2252                ..ResolvedModule::default()
2253            },
2254            ResolvedModule {
2255                file_id: FileId(1),
2256                path: files[1].path.clone(),
2257                exports: export(),
2258                ..ResolvedModule::default()
2259            },
2260            ResolvedModule {
2261                file_id: FileId(2),
2262                path: files[2].path.clone(),
2263                exports: export(),
2264                ..ResolvedModule::default()
2265            },
2266        ];
2267        let test_entry_points = vec![EntryPoint {
2268            path: files[0].path.clone(),
2269            source: EntryPointSource::TestFile,
2270        }];
2271        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
2272            &modules,
2273            &[],
2274            &test_entry_points,
2275            &[],
2276            &test_entry_points,
2277            &files,
2278        );
2279
2280        assert_eq!(reach_of(&graph, 1, &[0]).reach, ExportTestReach::None);
2281        assert_eq!(reach_of(&graph, 2, &[0]).reach, ExportTestReach::NoneFile);
2282    }
2283
2284    #[test]
2285    fn test_reach_reports_an_esm_mask_on_a_direct_import() {
2286        let graph = build_reachability_graph_with_replacements(
2287            2,
2288            &[(0, 1, false)],
2289            &[],
2290            &[0],
2291            &[ResolvedReplacedModuleTarget {
2292                source_file: FileId(0),
2293                target_file: FileId(1),
2294            }],
2295        );
2296
2297        assert_eq!(graph.test_masking_state(), TestMaskingState::Active);
2298        // The masked module is not test-reachable, so without the mask check
2299        // the export would read as `NoneFile`.
2300        assert!(!graph.modules[1].is_test_reachable());
2301        let reach = reach_of(&graph, 1, &[0]);
2302        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2303        assert_eq!(reach.tests, vec![FileId(0)]);
2304        assert_eq!(reach.mocked_targets, vec![FileId(1)]);
2305    }
2306
2307    #[test]
2308    fn test_reach_reports_a_mask_behind_a_barrel() {
2309        let graph = build_masked_re_export_graph(false, false);
2310        let reach = reach_of(&graph, 2, &[0]);
2311
2312        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2313        assert_eq!(reach.mocked_targets, vec![FileId(2)]);
2314    }
2315
2316    #[test]
2317    fn test_reach_names_a_mocked_intermediate_barrel() {
2318        let graph = build_intermediate_replacement_graph(false, false, false);
2319        let reach = reach_of(&graph, 2, &[0]);
2320
2321        // An alternate route keeps the target test-reachable, but the only
2322        // reference passes through the mocked barrel.
2323        assert!(graph.modules[2].is_test_reachable());
2324        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2325        assert_eq!(reach.mocked_targets, vec![FileId(1)]);
2326    }
2327
2328    #[test]
2329    fn test_reach_keeps_a_commonjs_require_unmasked() {
2330        let graph = build_reachability_graph_with_replacements(
2331            2,
2332            &[(0, 1, true)],
2333            &[],
2334            &[0],
2335            &[ResolvedReplacedModuleTarget {
2336                source_file: FileId(0),
2337                target_file: FileId(1),
2338            }],
2339        );
2340
2341        assert_eq!(reach_of(&graph, 1, &[0]).reach, ExportTestReach::Direct);
2342    }
2343
2344    #[test]
2345    fn test_reach_through_a_required_barrel_checks_only_esm_hops() {
2346        let barrel_mocked = build_intermediate_replacement_graph(true, false, false);
2347        assert_eq!(
2348            reach_of(&barrel_mocked, 2, &[0]).reach,
2349            ExportTestReach::Direct
2350        );
2351
2352        let target_mocked = build_intermediate_replacement_graph(true, false, true);
2353        let reach = reach_of(&target_mocked, 2, &[0]);
2354        assert_eq!(reach.reach, ExportTestReach::MockedOnly);
2355        assert_eq!(reach.mocked_targets, vec![FileId(2)]);
2356    }
2357
2358    #[test]
2359    fn test_reach_with_an_unmasked_second_root_is_direct() {
2360        let graph = build_reachability_graph_with_replacements(
2361            3,
2362            &[(0, 2, false), (1, 2, false)],
2363            &[],
2364            &[0, 1],
2365            &[ResolvedReplacedModuleTarget {
2366                source_file: FileId(0),
2367                target_file: FileId(2),
2368            }],
2369        );
2370        let reach = reach_of(&graph, 2, &[0, 1]);
2371
2372        assert_eq!(reach.reach, ExportTestReach::Direct);
2373        assert_eq!(reach.tests, vec![FileId(1)]);
2374    }
2375
2376    #[test]
2377    fn test_reach_survives_graph_cache_serialization() {
2378        let graph = build_masked_re_export_graph(false, false);
2379        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
2380        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
2381
2382        assert_eq!(decoded.test_masking_state(), TestMaskingState::Active);
2383        assert_eq!(
2384            reach_of(&decoded, 2, &[0]).reach,
2385            ExportTestReach::MockedOnly
2386        );
2387    }
2388}