Skip to main content

fallow_graph/graph/
test_reach.rs

1//! Test reach of one export, read from the existing test reachability index.
2//!
3//! The classification runs on demand for the exports a caller asks about. It
4//! adds no stored state: the mocked-only check replays the replacement masks
5//! along the recorded reference paths.
6
7use fallow_types::discover::FileId;
8use fallow_types::extract::ModuleLoadMechanism;
9use rustc_hash::FxHashSet;
10
11use super::types::{
12    ReferencePathId, ReferencePathNode, ReferenceRouteGraphId, ReferenceRouteNodeId,
13};
14use super::{ExportNamespace, ExportSymbol, ModuleGraph, TestReachabilityIndex};
15
16/// How tests reach one export.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum ExportTestReach {
19    /// A test file references the export, and a test root covers that
20    /// reference path.
21    Direct,
22    /// A covered reference exists, but no test file names the export.
23    Transitive,
24    /// No reference is covered, and a mock in a test root replaces a module
25    /// on at least one reference path.
26    MockedOnly,
27    /// A test root reaches the declaring file, but no reference is covered.
28    None,
29    /// No test root reaches the declaring file.
30    NoneFile,
31}
32
33/// Why replacement masking is or is not active for test reachability.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum TestMaskingState {
36    /// At least one test root replaces a module with a closed factory mock.
37    Active,
38    /// No test root declares a closed factory mock.
39    NoMocks,
40    /// Too many distinct mock sets: reachability ignores the masks, so a mocked
41    /// module counts as reached.
42    Capped,
43}
44
45/// The test reach of one export, with the files that support the class.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ExportTestReachResult {
48    /// The reach class.
49    pub reach: ExportTestReach,
50    /// Test files that name the export. For `Direct`, the test files whose
51    /// references are covered. For `MockedOnly`, the test files whose
52    /// references a mask removed. Empty for the other classes. Sorted by id.
53    pub tests: Vec<FileId>,
54    /// For `MockedOnly`: the masked modules on the reference paths. The
55    /// masked module can be a barrel in front of the declaring file. Sorted by
56    /// id.
57    pub mocked_targets: Vec<FileId>,
58}
59
60/// Profile bits of one reference that a mask removed, and the masked modules.
61#[derive(Default)]
62struct MaskedBits {
63    mocked: bool,
64    targets: FxHashSet<FileId>,
65}
66
67impl ModuleGraph {
68    /// Report whether test reachability applies replacement masks.
69    #[must_use]
70    pub fn test_masking_state(&self) -> TestMaskingState {
71        if self.test_reachability_index.profile_count > 0 {
72            TestMaskingState::Active
73        } else if self.test_reachability_index.masking_capped {
74            TestMaskingState::Capped
75        } else {
76            TestMaskingState::NoMocks
77        }
78    }
79
80    /// Classify how tests reach `export` of `file`.
81    ///
82    /// Only value references count: an `import type` gives no test reach.
83    /// `is_test_file` decides which referencing files are tests. The checks
84    /// run in this order: a covered reference from a test file, any covered
85    /// reference, a reference that a mask removed, then file reach.
86    #[must_use]
87    pub fn export_test_reach(
88        &self,
89        file: FileId,
90        export: &ExportSymbol,
91        is_test_file: &dyn Fn(FileId) -> bool,
92    ) -> ExportTestReachResult {
93        let value_references = || {
94            export
95                .references
96                .iter()
97                .enumerate()
98                .filter(|(_, reference)| reference.namespace == ExportNamespace::Value)
99        };
100
101        let mut covered = false;
102        let mut direct_tests: Vec<FileId> = Vec::new();
103        for (index, reference) in value_references() {
104            if !self.is_test_reference_covered(export, index) {
105                continue;
106            }
107            covered = true;
108            if is_test_file(reference.from_file) {
109                direct_tests.push(reference.from_file);
110            }
111        }
112        if !direct_tests.is_empty() {
113            return ExportTestReachResult {
114                reach: ExportTestReach::Direct,
115                tests: sorted_unique(direct_tests),
116                mocked_targets: Vec::new(),
117            };
118        }
119        if covered {
120            return ExportTestReachResult {
121                reach: ExportTestReach::Transitive,
122                tests: Vec::new(),
123                mocked_targets: Vec::new(),
124            };
125        }
126
127        if self.test_masking_state() == TestMaskingState::Active {
128            let mut mocked_tests = Vec::new();
129            let mut mocked_targets = FxHashSet::default();
130            for (index, reference) in value_references() {
131                let Some(path) = export.reference_path(index) else {
132                    continue;
133                };
134                let masked = self.test_reachability_index.masked_reference_bits(
135                    reference.from_file,
136                    path,
137                    self,
138                );
139                if !masked.mocked {
140                    continue;
141                }
142                mocked_targets.extend(masked.targets);
143                if is_test_file(reference.from_file) {
144                    mocked_tests.push(reference.from_file);
145                }
146            }
147            if !mocked_targets.is_empty() {
148                return ExportTestReachResult {
149                    reach: ExportTestReach::MockedOnly,
150                    tests: sorted_unique(mocked_tests),
151                    mocked_targets: sorted_unique(mocked_targets.into_iter().collect()),
152                };
153            }
154        }
155
156        let reach = if self.is_test_reachable(file) {
157            ExportTestReach::None
158        } else {
159            ExportTestReach::NoneFile
160        };
161        ExportTestReachResult {
162            reach,
163            tests: Vec::new(),
164            mocked_targets: Vec::new(),
165        }
166    }
167}
168
169fn sorted_unique(mut files: Vec<FileId>) -> Vec<FileId> {
170    files.sort_unstable_by_key(|file| file.0);
171    files.dedup();
172    files
173}
174
175impl TestReachabilityIndex {
176    /// Replay one reference path from the referencing file to the export and
177    /// collect the profiles that a mask removes on the way.
178    ///
179    /// The mask step runs before the reachable step at each hop, because a
180    /// profile never sets the reachable bit of a target that it masks. A
181    /// CommonJS hop never masks: replacement mocks do not intercept
182    /// `require()`.
183    fn masked_reference_bits(
184        &self,
185        source: FileId,
186        path: ReferencePathId,
187        graph: &ModuleGraph,
188    ) -> MaskedBits {
189        let mut result = MaskedBits::default();
190        let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
191            return result;
192        };
193        let mut chain: Vec<ReferencePathNode> = Vec::new();
194        let mut next = Some(path);
195        while let Some(path_id) = next {
196            let Some(&node) = graph.reference_paths.get(path_id.index()) else {
197                return result;
198            };
199            next = node.parent();
200            chain.push(node);
201        }
202        chain.reverse();
203
204        for (word_index, &source_word) in source_profiles.iter().enumerate() {
205            let mut alive = source_word;
206            for node in &chain {
207                if alive == 0 {
208                    break;
209                }
210                alive = match *node {
211                    ReferencePathNode::Hop {
212                        target, mechanism, ..
213                    } => self.masked_hop(target, mechanism, word_index, alive, &mut result),
214                    ReferencePathNode::Route {
215                        graph: route_graph,
216                        start,
217                        terminal,
218                        start_mechanism,
219                        ..
220                    } => self.masked_route(
221                        graph,
222                        RouteQuery {
223                            graph_id: route_graph,
224                            start,
225                            terminal,
226                            start_mechanism,
227                            word_index,
228                        },
229                        alive,
230                        &mut result,
231                    ),
232                };
233            }
234        }
235        result
236    }
237
238    /// Move the masked profiles of one ESM hop into `result`, then keep the
239    /// profiles that reach the target.
240    fn masked_hop(
241        &self,
242        target: FileId,
243        mechanism: ModuleLoadMechanism,
244        word_index: usize,
245        alive: u64,
246        result: &mut MaskedBits,
247    ) -> u64 {
248        let mut alive = alive;
249        if matches!(mechanism, ModuleLoadMechanism::EsModule)
250            && let Some(masked_word) = self
251                .masked_profiles_for(target)
252                .and_then(|profiles| profiles.get(word_index))
253        {
254            let masked = alive & masked_word;
255            if masked != 0 {
256                result.mocked = true;
257                result.targets.insert(target);
258                alive &= !masked_word;
259            }
260        }
261        let reachable = self
262            .profiles_for(&self.reachable_profiles, target)
263            .and_then(|profiles| profiles.get(word_index))
264            .copied()
265            .unwrap_or(0);
266        alive & reachable
267    }
268
269    /// Mirror `active_route_profiles` over one namespace transition graph and
270    /// collect the masked profiles of every visited ESM node.
271    fn masked_route(
272        &self,
273        graph: &ModuleGraph,
274        query: RouteQuery,
275        candidate_profiles: u64,
276        result: &mut MaskedBits,
277    ) -> u64 {
278        let routes = &graph.reference_routes;
279        let Some(route_graph) = routes.graphs.get(query.graph_id.0 as usize) else {
280            return 0;
281        };
282        let node_count = route_graph
283            .nodes
284            .end
285            .saturating_sub(route_graph.nodes.start) as usize;
286        let start_index = query.start.0 as usize;
287        let terminal_index = query.terminal.0 as usize;
288        if start_index >= node_count || terminal_index >= node_count {
289            return 0;
290        }
291
292        let mut attempted = vec![0_u64; node_count];
293        let mut pending = vec![0_u64; node_count];
294        let mut queued = vec![false; node_count];
295        let mut queue = std::collections::VecDeque::from([start_index]);
296        pending[start_index] = candidate_profiles;
297        queued[start_index] = true;
298        let mut successful_profiles = 0_u64;
299
300        while let Some(local_index) = queue.pop_front() {
301            queued[local_index] = false;
302            let incoming = pending[local_index] & !attempted[local_index];
303            pending[local_index] = 0;
304            attempted[local_index] |= incoming;
305            if incoming == 0 {
306                continue;
307            }
308            let Some(node) = routes
309                .nodes
310                .get(route_graph.nodes.start as usize + local_index)
311            else {
312                return 0;
313            };
314            let mechanism = if local_index == start_index {
315                query.start_mechanism
316            } else {
317                Some(node.mechanism)
318            };
319            // A start node without a mechanism is the module that the
320            // reference source already owns, so no load (and no mask) applies.
321            let active = match mechanism {
322                Some(mechanism) => {
323                    self.masked_hop(node.target, mechanism, query.word_index, incoming, result)
324                }
325                None => incoming,
326            };
327            if active == 0 {
328                continue;
329            }
330            if local_index == terminal_index {
331                successful_profiles |= active;
332                continue;
333            }
334            let Some(successors) = routes
335                .edges
336                .get(node.successors.start as usize..node.successors.end as usize)
337            else {
338                return 0;
339            };
340            for successor in successors {
341                let successor_index = successor.0 as usize;
342                if successor_index >= node_count {
343                    return 0;
344                }
345                let new_profiles = active & !attempted[successor_index] & !pending[successor_index];
346                if new_profiles == 0 {
347                    continue;
348                }
349                pending[successor_index] |= new_profiles;
350                if !queued[successor_index] {
351                    queued[successor_index] = true;
352                    queue.push_back(successor_index);
353                }
354            }
355        }
356        successful_profiles
357    }
358}
359
360/// One namespace route evaluation for one profile word.
361#[derive(Clone, Copy)]
362struct RouteQuery {
363    graph_id: ReferenceRouteGraphId,
364    start: ReferenceRouteNodeId,
365    terminal: ReferenceRouteNodeId,
366    start_mechanism: Option<ModuleLoadMechanism>,
367    word_index: usize,
368}