Skip to main content

fallow_engine/
module_graph.rs

1//! Module graph contracts owned by the engine boundary.
2
3#![allow(
4    clippy::implicit_hasher,
5    reason = "engine graph helpers use FxHashSet changed-file sets consistently with the rest of fallow"
6)]
7
8use std::path::{Path, PathBuf};
9
10use fallow_output::TestAdjacency;
11use fallow_types::discover::FileId;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use fallow_graph::graph::{
15    CoordinationGapPaths as GraphCoordinationGapPaths,
16    FocusFileFactsPaths as GraphFocusFileFactsPaths, ImpactClosurePaths as GraphImpactClosurePaths,
17    ModuleGraph, PartitionOrderPaths as GraphPartitionOrderPaths,
18    ReviewUnitPaths as GraphReviewUnitPaths,
19};
20use fallow_graph::graph::{
21    DirectImporterSummary as GraphDirectImporterSummary,
22    ImportedSymbolSummary as GraphImportedSymbolSummary,
23};
24use fallow_graph::graph::{
25    ExportTestReach as GraphExportTestReach, ExportTestReachResult as GraphExportTestReachResult,
26    TestMaskingState as GraphTestMaskingState,
27};
28
29/// Engine-owned retained graph handle.
30///
31/// Downstream crates can request stable graph facts through engine helpers
32/// without depending on `fallow-graph` node internals.
33#[derive(Debug)]
34pub struct RetainedModuleGraph {
35    inner: ModuleGraph,
36}
37
38impl RetainedModuleGraph {
39    /// Wrap a freshly built module graph for engine result contracts.
40    #[must_use]
41    const fn new(inner: ModuleGraph) -> Self {
42        Self { inner }
43    }
44
45    pub(crate) const fn as_graph(&self) -> &ModuleGraph {
46        &self.inner
47    }
48
49    /// Borrow the shared static test-coverage view for health analysis.
50    pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
51        StaticTestCoverage::new(&self.inner)
52    }
53
54    /// Number of modules in the retained graph.
55    #[must_use]
56    pub fn module_count(&self) -> usize {
57        self.inner.module_count()
58    }
59
60    /// Number of edges in the retained graph.
61    #[must_use]
62    pub fn edge_count(&self) -> usize {
63        self.inner.edge_count()
64    }
65
66    /// Build public export keys for a precomputed public-entry set.
67    #[must_use]
68    pub(crate) fn public_export_keys(
69        &self,
70        public_entries: &FxHashSet<FileId>,
71        root: &Path,
72    ) -> FxHashSet<String> {
73        self.inner.public_export_keys(public_entries, root)
74    }
75
76    /// Count direct importer modules for one file id.
77    #[must_use]
78    pub fn direct_importer_count(&self, file_id: FileId) -> usize {
79        self.inner
80            .reverse_deps
81            .get(file_id.0 as usize)
82            .map_or(0, Vec::len)
83    }
84
85    /// Summaries for modules that directly import one file.
86    #[must_use]
87    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
88        self.inner
89            .direct_importer_summaries(target)
90            .into_iter()
91            .map(DirectImporterSummary::from)
92            .collect()
93    }
94}
95
96/// Engine-owned view of root-correlated static test reachability.
97///
98/// This keeps health consumers on one contract while the graph crate owns the
99/// traversal and replacement-mask representation.
100///
101/// A view can read a second graph that holds the test files. Production mode
102/// keeps test files out of the analysis graph, so health reads test
103/// reachability from a graph of the full project. `file_map` then maps each
104/// analysis file id to the file id of the same path in that graph.
105#[derive(Clone, Copy)]
106pub(crate) struct StaticTestCoverage<'a> {
107    graph: &'a ModuleGraph,
108    file_map: Option<&'a FxHashMap<FileId, FileId>>,
109}
110
111impl<'a> StaticTestCoverage<'a> {
112    pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
113        Self {
114            graph,
115            file_map: None,
116        }
117    }
118
119    /// Read test reachability from `graph`, a graph built over a different
120    /// file set. `file_map` maps the caller's file ids to the ids in `graph`.
121    pub(crate) const fn mapped(
122        graph: &'a ModuleGraph,
123        file_map: &'a FxHashMap<FileId, FileId>,
124    ) -> Self {
125        Self {
126            graph,
127            file_map: Some(file_map),
128        }
129    }
130
131    fn graph_file_id(self, file_id: FileId) -> Option<FileId> {
132        match self.file_map {
133            Some(file_map) => file_map.get(&file_id).copied(),
134            None => Some(file_id),
135        }
136    }
137
138    /// The export in the coverage graph that has the name of `export`.
139    fn graph_export<'b>(
140        self,
141        file_id: FileId,
142        export: &'b fallow_graph::graph::ExportSymbol,
143    ) -> Option<(FileId, &'b fallow_graph::graph::ExportSymbol)>
144    where
145        'a: 'b,
146    {
147        if self.file_map.is_none() {
148            return Some((file_id, export));
149        }
150        let graph_file_id = self.graph_file_id(file_id)?;
151        let node = self.graph.modules.get(graph_file_id.0 as usize)?;
152        node.exports
153            .iter()
154            .find(|candidate| candidate.name == export.name)
155            .map(|candidate| (graph_file_id, candidate))
156    }
157
158    pub(crate) fn covers_file(self, file_id: FileId) -> bool {
159        self.graph_file_id(file_id)
160            .is_some_and(|graph_file_id| self.graph.is_test_reachable(graph_file_id))
161    }
162
163    pub(crate) fn covers_any_reference(
164        self,
165        file_id: FileId,
166        export: &fallow_graph::graph::ExportSymbol,
167    ) -> bool {
168        self.graph_export(file_id, export)
169            .is_some_and(|(_, export)| self.graph.is_any_test_reference_covered(export))
170    }
171
172    /// Whether only tests that mock a module on the reference path reference
173    /// `export`. Call it for an export without a covered reference.
174    pub(crate) fn is_mocked_only(
175        self,
176        file_id: FileId,
177        export: &fallow_graph::graph::ExportSymbol,
178    ) -> bool {
179        if self.graph.test_masking_state() != GraphTestMaskingState::Active {
180            return false;
181        }
182        self.graph_export(file_id, export)
183            .is_some_and(|(graph_file_id, export)| {
184                self.graph
185                    .export_test_reach(graph_file_id, export, &|_| false)
186                    .reach
187                    == GraphExportTestReach::MockedOnly
188            })
189    }
190}
191
192impl From<ModuleGraph> for RetainedModuleGraph {
193    fn from(inner: ModuleGraph) -> Self {
194        Self::new(inner)
195    }
196}
197
198/// Engine-owned importer details for one file that directly imports a target module.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct DirectImporterSummary {
201    /// File containing the import statement.
202    pub source: FileId,
203    /// Symbols this importer pulls from the target module.
204    pub symbols: Vec<ImportedSymbolSummary>,
205}
206
207impl From<GraphDirectImporterSummary> for DirectImporterSummary {
208    fn from(summary: GraphDirectImporterSummary) -> Self {
209        Self {
210            source: summary.source,
211            symbols: summary.symbols.into_iter().map(Into::into).collect(),
212        }
213    }
214}
215
216/// Engine-owned symbol details for a direct import edge.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ImportedSymbolSummary {
219    /// Exported name as declared by the target module.
220    pub imported: String,
221    /// Local binding name at the import site.
222    pub local: String,
223    /// True for type-only imports, which do not count as value usage.
224    pub type_only: bool,
225}
226
227impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
228    fn from(symbol: GraphImportedSymbolSummary) -> Self {
229        Self {
230            imported: symbol.imported,
231            local: symbol.local,
232            type_only: symbol.type_only,
233        }
234    }
235}
236
237/// Engine-owned snapshot of one value export in a module graph.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct ModuleValueExport {
240    /// File declaring the export.
241    pub file_id: FileId,
242    /// Exported name.
243    pub name: String,
244    /// Byte offset where the export declaration starts.
245    pub span_start: u32,
246    /// True when a test file references this export.
247    pub test_referenced: bool,
248}
249
250/// Engine-owned impact closure with file ids resolved to paths.
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct ImpactClosurePaths {
253    /// Changed files that are part of the diff under review.
254    pub in_diff: Vec<String>,
255    /// Files affected through the import closure but absent from the diff.
256    pub affected_not_shown: Vec<String>,
257    /// Changed contracts whose consumers were not updated in the same diff.
258    pub coordination_gap: Vec<CoordinationGapPaths>,
259}
260
261impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
262    fn from(paths: GraphImpactClosurePaths) -> Self {
263        Self {
264            in_diff: paths.in_diff,
265            affected_not_shown: paths.affected_not_shown,
266            coordination_gap: paths
267                .coordination_gap
268                .into_iter()
269                .map(CoordinationGapPaths::from)
270                .collect(),
271        }
272    }
273}
274
275/// Engine-owned coordination gap between a changed contract and consumer.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct CoordinationGapPaths {
278    /// Path of the changed file whose contract moved.
279    pub changed_file: String,
280    /// Path of the consumer that was not updated alongside it.
281    pub consumer_file: String,
282    /// Symbols the consumer imports from the changed file.
283    pub consumed_symbols: Vec<String>,
284}
285
286impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
287    fn from(paths: GraphCoordinationGapPaths) -> Self {
288        Self {
289            changed_file: paths.changed_file,
290            consumer_file: paths.consumer_file,
291            consumed_symbols: paths.consumed_symbols,
292        }
293    }
294}
295
296/// Engine-owned review partition and dependency-sensible order.
297#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct PartitionOrderPaths {
299    /// Changed files partitioned into per-module review units.
300    pub units: Vec<ReviewUnitPaths>,
301    /// File paths in dependency-sensible review order (dependencies first).
302    pub order: Vec<String>,
303    /// Connected components of the inter-unit dependency graph: units that
304    /// share no import edge with any unit outside their slice.
305    pub independent_slices: Vec<Vec<String>>,
306}
307
308impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
309    fn from(paths: GraphPartitionOrderPaths) -> Self {
310        Self {
311            units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
312            order: paths.order,
313            independent_slices: paths.independent_slices,
314        }
315    }
316}
317
318/// Engine-owned changed-file review unit.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct ReviewUnitPaths {
321    /// Directory the unit's files share.
322    pub module_dir: String,
323    /// Changed files grouped into this unit.
324    pub files: Vec<String>,
325}
326
327impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
328    fn from(paths: GraphReviewUnitPaths) -> Self {
329        Self {
330            module_dir: paths.module_dir,
331            files: paths.files,
332        }
333    }
334}
335
336/// Engine-owned focus facts for one changed file.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct FocusFileFactsPaths {
339    /// Path of the changed file the facts describe.
340    pub file: String,
341    /// Count of distinct files importing this file (blast radius), excluding
342    /// the file itself.
343    pub fan_in: u32,
344    /// Count of distinct files this file imports, excluding the file itself.
345    pub fan_out: u32,
346    /// True when the file has a dynamic-import edge in either direction, so
347    /// its static reachability signal is not complete. Conservative: a file
348    /// that MAY be dynamically wired carries the flag.
349    pub dynamic_dispatch: bool,
350    /// True when the file's reachability runs through re-export indirection,
351    /// so direct importer counts understate its reach.
352    pub re_export_indirection: bool,
353}
354
355impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
356    fn from(paths: GraphFocusFileFactsPaths) -> Self {
357        Self {
358            file: paths.file,
359            fan_in: paths.fan_in,
360            fan_out: paths.fan_out,
361            dynamic_dispatch: paths.dynamic_dispatch,
362            re_export_indirection: paths.re_export_indirection,
363        }
364    }
365}
366
367/// Return value exports with test-reference state without exposing graph node
368/// internals to downstream crates.
369#[must_use]
370pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
371    module_value_exports_with_test_reach(graph, None)
372}
373
374/// Return value exports of `graph` with test-reference state.
375///
376/// When `test_reach` is given, test-reference state comes from that
377/// full-project graph. Production mode needs this because its analysis graph
378/// has no test files.
379#[must_use]
380pub fn module_value_exports_with_test_reach(
381    graph: &RetainedModuleGraph,
382    test_reach: Option<&crate::health::TestReachGraph>,
383) -> Vec<ModuleValueExport> {
384    let test_coverage = test_reach.map_or_else(
385        || graph.static_test_coverage(),
386        crate::health::TestReachGraph::static_test_coverage,
387    );
388    let graph = graph.as_graph();
389
390    graph
391        .modules
392        .iter()
393        .flat_map(|node| {
394            node.exports
395                .iter()
396                .filter(|export| !export.is_type_only)
397                .map(|export| ModuleValueExport {
398                    file_id: node.file_id,
399                    name: export.name.to_string(),
400                    span_start: export.span.start,
401                    test_referenced: test_coverage.covers_any_reference(node.file_id, export),
402                })
403        })
404        .collect()
405}
406
407/// Compute a path-resolved impact closure for absolute changed paths.
408#[must_use]
409pub fn impact_closure_for_changed_paths(
410    graph: &RetainedModuleGraph,
411    root: &Path,
412    changed_files: &FxHashSet<PathBuf>,
413) -> Option<ImpactClosurePaths> {
414    let graph = graph.as_graph();
415    let changed_ids = changed_file_ids(graph, changed_files);
416    if changed_ids.is_empty() {
417        return None;
418    }
419
420    let closure = graph.impact_closure(&changed_ids);
421    Some(graph.closure_with_paths(&closure, root).into())
422}
423
424/// Compute path-resolved partition order for absolute changed paths.
425#[must_use]
426pub fn partition_order_for_changed_paths(
427    graph: &RetainedModuleGraph,
428    root: &Path,
429    changed_files: &FxHashSet<PathBuf>,
430) -> Option<PartitionOrderPaths> {
431    let graph = graph.as_graph();
432    let changed_ids = changed_file_ids(graph, changed_files);
433    if changed_ids.is_empty() {
434        return None;
435    }
436
437    let partition = graph.partition_order(&changed_ids);
438    Some(graph.partition_order_with_paths(&partition, root).into())
439}
440
441/// Compute path-resolved focus graph facts for absolute changed paths.
442#[must_use]
443pub fn focus_facts_for_changed_paths(
444    graph: &RetainedModuleGraph,
445    root: &Path,
446    changed_files: &FxHashSet<PathBuf>,
447) -> Option<Vec<FocusFileFactsPaths>> {
448    let graph = graph.as_graph();
449    let changed_ids = changed_file_ids(graph, changed_files);
450    if changed_ids.is_empty() {
451        return None;
452    }
453
454    let facts = graph.focus_file_facts(&changed_ids);
455    Some(
456        graph
457            .focus_facts_with_paths(&facts, root)
458            .into_iter()
459            .map(FocusFileFactsPaths::from)
460            .collect(),
461    )
462}
463
464/// Compute changed-file export line anchors without exposing graph nodes.
465#[must_use]
466pub fn export_lines_for_changed_paths(
467    graph: &RetainedModuleGraph,
468    root: &Path,
469    changed_files: &FxHashSet<PathBuf>,
470) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
471    let graph = graph.as_graph();
472    let changed_norm = normalized_changed_paths(changed_files);
473    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
474    for module in &graph.modules {
475        let abs = normalize_path(&module.path);
476        if !changed_norm.contains(&abs) || module.exports.is_empty() {
477            continue;
478        }
479        let Ok(content) = std::fs::read_to_string(&module.path) else {
480            continue;
481        };
482        let offsets = fallow_types::extract::compute_line_offsets(&content);
483        let exports: Vec<(String, u32)> = module
484            .exports
485            .iter()
486            .map(|export| {
487                let (line, _) =
488                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
489                (export.name.to_string(), line)
490            })
491            .collect();
492        map.insert(relative_key_path(&module.path, root), exports);
493    }
494    Some(map)
495}
496
497/// One consumer that a whole-word text search for an export name does not
498/// find, or finds only under another local name.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ConsumerSearchGapPaths {
501    /// Root-relative path of the consumer, forward-slashed.
502    pub consumer_file: String,
503    /// The literal-search check for the consumer.
504    pub check: crate::search_visibility::SearchCheck,
505}
506
507/// Literal-search gaps for one export of a changed file.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct ExportSearchGapPaths {
510    /// Root-relative path of the changed file, forward-slashed.
511    pub file: String,
512    /// The export name, `default` for a default export.
513    pub export_name: String,
514    /// 1-based line of the export.
515    pub line: u32,
516    /// 1-based first and last line of the top-level statement that declares
517    /// the export.
518    pub declaration_lines: (u32, u32),
519    /// The `missed` and `renamed` consumers outside the changed files, sorted
520    /// by path.
521    pub consumers: Vec<ConsumerSearchGapPaths>,
522}
523
524/// The names of the exports that `source` declares, parsed as the file at
525/// `path`. The names use the format of [`ChangedExport::export_name`].
526#[must_use]
527pub fn export_names_in_source(path: &Path, source: &str) -> FxHashSet<String> {
528    let source = source.strip_prefix('\u{feff}').unwrap_or(source);
529    let module = fallow_extract::parse_source_to_module(FileId(0), path, source, 0, false);
530    module
531        .exports
532        .iter()
533        .map(|export| export.name.to_string())
534        .collect()
535}
536
537/// One export of a changed file, with the line facts that the review brief
538/// joins on the diff.
539///
540/// [`changed_exports_for_changed_paths`] reads and parses each changed file
541/// once. The literal-search gap pass and the test reach pass share the list.
542#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct ChangedExport {
544    file_id: FileId,
545    export_index: usize,
546    /// Root-relative path of the changed file, forward-slashed.
547    pub file: String,
548    /// The export name, `default` for a default export.
549    pub export_name: String,
550    /// 1-based line of the export.
551    pub line: u32,
552    /// 1-based first and last line of the top-level statement that declares
553    /// the export.
554    pub declaration_lines: (u32, u32),
555    /// True for a type-only export.
556    pub is_type_only: bool,
557    /// True when the export is a variable whose initializer is a literal, or
558    /// an object or array of literals.
559    pub literal_value: bool,
560    /// True when a re-export statement (`export { x } from`) makes the export.
561    pub is_re_export: bool,
562}
563
564/// List the exports of absolute changed paths with their declaration lines.
565///
566/// Each changed file is read and parsed once. The exports keep graph order
567/// within a file, and the files are in `FileId` order. `None` when no module
568/// matches a changed path.
569#[must_use]
570pub fn changed_exports_for_changed_paths(
571    graph: &RetainedModuleGraph,
572    root: &Path,
573    changed_files: &FxHashSet<PathBuf>,
574) -> Option<Vec<ChangedExport>> {
575    let graph = graph.as_graph();
576    let mut changed_ids = changed_file_ids(graph, changed_files);
577    if changed_ids.is_empty() {
578        return None;
579    }
580    changed_ids.sort_unstable_by_key(|id| id.0);
581    let mut changed_exports = Vec::new();
582    for file_id in changed_ids {
583        let Some(module) = graph.modules.get(file_id.0 as usize) else {
584            continue;
585        };
586        if module.exports.is_empty() {
587            continue;
588        }
589        let Ok(source) = std::fs::read_to_string(&module.path) else {
590            continue;
591        };
592        changed_exports.extend(module_exports(graph, root, file_id, &source));
593    }
594    Some(changed_exports)
595}
596
597/// List the exports of one module with their declaration lines. `source` is
598/// the current text of the module. The exports keep graph order.
599pub(crate) fn module_exports(
600    graph: &ModuleGraph,
601    root: &Path,
602    file_id: FileId,
603    source: &str,
604) -> Vec<ChangedExport> {
605    let Some(module) = graph.modules.get(file_id.0 as usize) else {
606        return Vec::new();
607    };
608    if module.exports.is_empty() {
609        return Vec::new();
610    }
611    let offsets = fallow_types::extract::compute_line_offsets(source);
612    let starts: Vec<u32> = module
613        .exports
614        .iter()
615        .map(|export| export.span.start)
616        .collect();
617    let declarations = crate::search_visibility::export_declarations(&module.path, source, &starts);
618    let file = relative_key_path(&module.path, root);
619    module
620        .exports
621        .iter()
622        .enumerate()
623        .map(|(export_index, export)| {
624            let line =
625                fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start).0;
626            let declaration = declarations.get(&export.span.start);
627            ChangedExport {
628                file_id,
629                export_index,
630                file: file.clone(),
631                export_name: export.name.to_string(),
632                line,
633                declaration_lines: declaration.map_or((line, line), |found| found.lines),
634                is_type_only: export.is_type_only,
635                literal_value: declaration.is_some_and(|found| found.literal_value),
636                is_re_export: declaration.is_some_and(|found| found.is_re_export),
637            }
638        })
639        .collect()
640}
641
642/// Compute the literal-search gaps of the changed exports.
643///
644/// For each export of a changed file, the consumers outside the changed set
645/// are checked against a whole-word text search for the export name. Only
646/// exports with at least one `missed` or `renamed` consumer are returned. The
647/// consumer sources are read on demand.
648#[must_use]
649pub fn search_gaps_for_changed_paths(
650    graph: &RetainedModuleGraph,
651    root: &Path,
652    changed_files: &FxHashSet<PathBuf>,
653    changed_exports: &[ChangedExport],
654) -> Option<Vec<ExportSearchGapPaths>> {
655    use crate::search_visibility::{SearchTarget, SourceCache};
656    use fallow_graph::graph::ExportNamespace;
657    use fallow_types::trace::SearchVisibility;
658
659    let graph = graph.as_graph();
660    let changed_set: FxHashSet<FileId> =
661        changed_file_ids(graph, changed_files).into_iter().collect();
662    if changed_set.is_empty() {
663        return None;
664    }
665    let mut sources = SourceCache::new(graph);
666    let mut gaps = Vec::new();
667    for changed in changed_exports {
668        let Some(export) = graph
669            .modules
670            .get(changed.file_id.0 as usize)
671            .and_then(|module| module.exports.get(changed.export_index))
672        else {
673            continue;
674        };
675        let mut seen = FxHashSet::default();
676        let candidates: Vec<_> = export
677            .references
678            .iter()
679            .filter(|reference| {
680                !changed_set.contains(&reference.from_file)
681                    && graph
682                        .modules
683                        .get(reference.from_file.0 as usize)
684                        .is_some_and(fallow_graph::graph::ModuleNode::is_reachable)
685                    && seen.insert(reference.from_file)
686            })
687            .collect();
688        if candidates.is_empty() {
689            continue;
690        }
691        let namespace = if export.is_type_only {
692            ExportNamespace::Type
693        } else {
694            ExportNamespace::Value
695        };
696        let target = SearchTarget::new(
697            graph,
698            &mut sources,
699            changed.file_id,
700            &changed.export_name,
701            namespace,
702        );
703        let mut consumers: Vec<ConsumerSearchGapPaths> = candidates
704            .into_iter()
705            .filter_map(|reference| {
706                let check = target.check(graph, &mut sources, reference)?;
707                if check.visibility == SearchVisibility::Found {
708                    return None;
709                }
710                let consumer = graph.modules.get(reference.from_file.0 as usize)?;
711                Some(ConsumerSearchGapPaths {
712                    consumer_file: relative_key_path(&consumer.path, root),
713                    check,
714                })
715            })
716            .collect();
717        if consumers.is_empty() {
718            continue;
719        }
720        consumers.sort_by(|a, b| a.consumer_file.cmp(&b.consumer_file));
721        gaps.push(ExportSearchGapPaths {
722            file: changed.file.clone(),
723            export_name: changed.export_name.clone(),
724            line: changed.line,
725            declaration_lines: changed.declaration_lines,
726            consumers,
727        });
728    }
729    Some(gaps)
730}
731
732/// Package names whose presence marks an end-to-end test runner. Such tests
733/// drive a running app and do not import the source files.
734const E2E_RUNNER_PACKAGES: &[&str] = &["@playwright/test", "playwright", "cypress"];
735
736/// How tests reach one export.
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
738#[serde(rename_all = "kebab-case")]
739pub enum ExportTestReach {
740    /// A test file references the export, and a test root covers it.
741    Direct,
742    /// A covered reference exists, but no test file names the export.
743    Transitive,
744    /// No reference is covered, and a mock replaces a module on a reference
745    /// path.
746    MockedOnly,
747    /// A test root reaches the declaring file, but no reference is covered.
748    None,
749    /// No test root reaches the declaring file.
750    NoneFile,
751}
752
753impl From<GraphExportTestReach> for ExportTestReach {
754    fn from(reach: GraphExportTestReach) -> Self {
755        match reach {
756            GraphExportTestReach::Direct => Self::Direct,
757            GraphExportTestReach::Transitive => Self::Transitive,
758            GraphExportTestReach::MockedOnly => Self::MockedOnly,
759            GraphExportTestReach::None => Self::None,
760            GraphExportTestReach::NoneFile => Self::NoneFile,
761        }
762    }
763}
764
765/// Why replacement masking is or is not active for test reachability.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
767pub enum TestMaskingState {
768    /// At least one test root replaces a module with a closed factory mock.
769    Active,
770    /// No test root declares a closed factory mock.
771    NoMocks,
772    /// Too many distinct mock sets, so masking is off.
773    Capped,
774}
775
776impl From<GraphTestMaskingState> for TestMaskingState {
777    fn from(state: GraphTestMaskingState) -> Self {
778        match state {
779            GraphTestMaskingState::Active => Self::Active,
780            GraphTestMaskingState::NoMocks => Self::NoMocks,
781            GraphTestMaskingState::Capped => Self::Capped,
782        }
783    }
784}
785
786/// How tests reach one changed value export.
787#[derive(Debug, Clone, PartialEq, Eq)]
788pub struct ExportTestReachPaths {
789    /// Root-relative path of the changed file, forward-slashed.
790    pub file: String,
791    /// The export name, `default` for a default export.
792    pub export_name: String,
793    /// 1-based line of the export.
794    pub line: u32,
795    /// 1-based first and last line of the declaring statement.
796    pub declaration_lines: (u32, u32),
797    /// True when the export is a variable with a literal initializer.
798    pub literal_value: bool,
799    /// The reach class.
800    pub reach: ExportTestReach,
801    /// Test files that name the export, sorted: the covered test references
802    /// for `Direct`, the masked test references for `MockedOnly`.
803    pub tests: Vec<String>,
804    /// For `MockedOnly`: the masked modules on the reference paths, sorted.
805    pub mocked_targets: Vec<String>,
806}
807
808/// Test reach of the changed value exports of a diff.
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct TestReachPaths {
811    /// False when the project has no test entry points. Every export is then
812    /// `NoneFile`.
813    pub has_tests: bool,
814    /// Whether test reachability applies replacement masks.
815    pub masking: TestMaskingState,
816    /// True when the project imports an end-to-end test runner package.
817    pub has_e2e_runner: bool,
818    /// One row per changed value export of a changed source file.
819    pub exports: Vec<ExportTestReachPaths>,
820}
821
822/// Classify how tests reach the value exports in `changed_exports`.
823///
824/// Type-only exports, re-exports, exports of test files, of declaration files
825/// and of stylesheets are skipped. `is_test_path` classifies a root-relative,
826/// forward-slashed path; a test entry point always counts as a test file.
827/// The rows keep the order of `changed_exports`. `None` when the list holds no
828/// such export.
829#[must_use]
830pub fn test_reach_for_changed_paths(
831    graph: &RetainedModuleGraph,
832    root: &Path,
833    changed_exports: &[ChangedExport],
834    is_test_path: &dyn Fn(&str) -> bool,
835) -> Option<TestReachPaths> {
836    let graph = graph.as_graph();
837    let mut test_files: FxHashMap<FileId, bool> = FxHashMap::default();
838    let mut is_test_file = |file_id: FileId| -> bool {
839        *test_files.entry(file_id).or_insert_with(|| {
840            graph.test_entry_points.contains(&file_id)
841                || graph
842                    .modules
843                    .get(file_id.0 as usize)
844                    .is_some_and(|module| is_test_path(&relative_key_path(&module.path, root)))
845        })
846    };
847    let has_tests = !graph.test_entry_points.is_empty();
848    let exports: Vec<ExportTestReachPaths> = changed_exports
849        .iter()
850        .filter_map(|changed| classify_export_test_reach(graph, root, changed, &mut is_test_file))
851        .collect();
852    if exports.is_empty() {
853        return None;
854    }
855    let has_e2e_runner = E2E_RUNNER_PACKAGES.iter().any(|name| {
856        graph.package_usage.contains_key(*name) || graph.type_only_package_usage.contains_key(*name)
857    });
858    Some(TestReachPaths {
859        has_tests,
860        masking: graph.test_masking_state().into(),
861        has_e2e_runner,
862        exports,
863    })
864}
865
866/// Classify how tests reach one export. `None` for a type-only export, a
867/// re-export, an export of a test file, of a declaration file or of a
868/// stylesheet. `is_test_file` decides which referencing files are tests. In a
869/// project with no test entry points, the class is `NoneFile`.
870pub(crate) fn classify_export_test_reach(
871    graph: &ModuleGraph,
872    root: &Path,
873    changed: &ChangedExport,
874    is_test_file: &mut dyn FnMut(FileId) -> bool,
875) -> Option<ExportTestReachPaths> {
876    if changed.is_type_only || changed.is_re_export {
877        return None;
878    }
879    let module = graph.modules.get(changed.file_id.0 as usize)?;
880    let export = module.exports.get(changed.export_index)?;
881    if !is_reach_subject(&module.path) || is_test_file(changed.file_id) {
882        return None;
883    }
884    let result = if graph.test_entry_points.is_empty() {
885        GraphExportTestReachResult {
886            reach: GraphExportTestReach::NoneFile,
887            tests: Vec::new(),
888            mocked_targets: Vec::new(),
889        }
890    } else {
891        let test_flags: FxHashMap<FileId, bool> = export
892            .references
893            .iter()
894            .map(|reference| (reference.from_file, is_test_file(reference.from_file)))
895            .collect();
896        graph.export_test_reach(changed.file_id, export, &|file_id| {
897            test_flags.get(&file_id).copied().unwrap_or(false)
898        })
899    };
900    let paths_of = |files: &[FileId]| -> Vec<String> {
901        let mut paths: Vec<String> = files
902            .iter()
903            .filter_map(|file_id| graph.modules.get(file_id.0 as usize))
904            .map(|node| relative_key_path(&node.path, root))
905            .collect();
906        paths.sort_unstable();
907        paths
908    };
909    Some(ExportTestReachPaths {
910        file: changed.file.clone(),
911        export_name: changed.export_name.clone(),
912        line: changed.line,
913        declaration_lines: changed.declaration_lines,
914        literal_value: changed.literal_value,
915        reach: result.reach.into(),
916        tests: paths_of(&result.tests),
917        mocked_targets: paths_of(&result.mocked_targets),
918    })
919}
920
921/// Whether test reach applies to a file: runtime source code, not a
922/// declaration file or a stylesheet.
923fn is_reach_subject(path: &Path) -> bool {
924    if fallow_graph::graph::is_declaration_file_path(path) {
925        return false;
926    }
927    !path
928        .extension()
929        .and_then(|extension| extension.to_str())
930        .is_some_and(|extension| matches!(extension, "css" | "scss" | "less" | "sass"))
931}
932
933/// Compute direct non-diff internal consumer counts for absolute changed paths.
934#[must_use]
935pub fn internal_consumers_for_changed_paths(
936    graph: &RetainedModuleGraph,
937    root: &Path,
938    changed_files: &FxHashSet<PathBuf>,
939) -> Option<FxHashMap<String, u64>> {
940    let graph = graph.as_graph();
941    let changed_norm = normalized_changed_paths(changed_files);
942    let id_to_norm: FxHashMap<FileId, String> = graph
943        .modules
944        .iter()
945        .map(|module| (module.file_id, normalize_path(&module.path)))
946        .collect();
947
948    let mut map: FxHashMap<String, u64> = FxHashMap::default();
949    for module in &graph.modules {
950        let abs = normalize_path(&module.path);
951        if !changed_norm.contains(&abs) {
952            continue;
953        }
954        let count = graph
955            .importers_of(module.file_id)
956            .iter()
957            .filter(|imp| {
958                id_to_norm
959                    .get(imp)
960                    .is_none_or(|p| !changed_norm.contains(p))
961            })
962            .count() as u64;
963        map.insert(relative_key_path(&module.path, root), count);
964    }
965    Some(map)
966}
967
968/// Compute per-changed-file test adjacency for the review direction: whether a
969/// test file imports the changed module directly, and whether one of those tests
970/// is itself in the changed set. `is_test_path` classifies a root-relative,
971/// forward-slashed path; the caller owns that definition so the brief's
972/// weakening and direction surfaces agree on it. Changed test files get no
973/// entry of their own. `None` when no module matches a changed path.
974#[must_use]
975pub fn test_adjacency_for_changed_paths(
976    graph: &RetainedModuleGraph,
977    root: &Path,
978    changed_files: &FxHashSet<PathBuf>,
979    is_test_path: &dyn Fn(&str) -> bool,
980) -> Option<FxHashMap<String, TestAdjacency>> {
981    let graph = graph.as_graph();
982    // A project with no test files at all gets no adjacency claims: "no direct
983    // test" on every unit would say nothing about the change.
984    if !graph
985        .modules
986        .iter()
987        .any(|module| is_test_path(&relative_key_path(&module.path, root)))
988    {
989        return None;
990    }
991    let changed_norm = normalized_changed_paths(changed_files);
992    let mut map: FxHashMap<String, TestAdjacency> = FxHashMap::default();
993    for module in &graph.modules {
994        if !changed_norm.contains(&normalize_path(&module.path)) {
995            continue;
996        }
997        let rel = relative_key_path(&module.path, root);
998        if is_test_path(&rel) {
999            continue;
1000        }
1001        let mut adjacency = TestAdjacency::None;
1002        for importer in graph.importers_of(module.file_id) {
1003            let Some(node) = graph.modules.get(importer.0 as usize) else {
1004                continue;
1005            };
1006            if !is_test_path(&relative_key_path(&node.path, root)) {
1007                continue;
1008            }
1009            if changed_norm.contains(&normalize_path(&node.path)) {
1010                adjacency = TestAdjacency::Changed;
1011                break;
1012            }
1013            adjacency = TestAdjacency::Untouched;
1014        }
1015        map.insert(rel, adjacency);
1016    }
1017    if map.is_empty() {
1018        return None;
1019    }
1020    Some(map)
1021}
1022
1023/// In-repo importer counts for one third-party package.
1024#[derive(Debug, Clone, Default, PartialEq, Eq)]
1025pub struct PackageImporters {
1026    /// Modules that import the package (value imports only), id-sorted. Kept
1027    /// as ids rather than a count so a decision that batches several packages
1028    /// can take the union instead of double-counting a module that imports
1029    /// more than one of them.
1030    pub importers: Vec<FileId>,
1031    /// The subset of `importers` outside the changed set, id-sorted.
1032    pub out_of_diff: Vec<FileId>,
1033}
1034
1035/// Compute per-package in-repo importer counts for every third-party package
1036/// the graph saw an import of, value or type-only, split by whether the
1037/// importer is in the changed set. Type-only importers count because a major
1038/// bump of a types package is a compile-wide change even when no value
1039/// crosses the boundary. The dependency decision arm reads these so a new or
1040/// bumped `package.json` entry carries the modules it actually reaches. `None`
1041/// when the graph recorded no package usage.
1042#[must_use]
1043pub fn package_importers_for_changed_paths(
1044    graph: &RetainedModuleGraph,
1045    changed_files: &FxHashSet<PathBuf>,
1046) -> Option<FxHashMap<String, PackageImporters>> {
1047    let graph = graph.as_graph();
1048    if graph.package_usage.is_empty() && graph.type_only_package_usage.is_empty() {
1049        return None;
1050    }
1051    let changed_norm = normalized_changed_paths(changed_files);
1052    let id_to_norm: FxHashMap<FileId, String> = graph
1053        .modules
1054        .iter()
1055        .map(|module| (module.file_id, normalize_path(&module.path)))
1056        .collect();
1057    let mut usage: FxHashMap<&str, Vec<FileId>> = FxHashMap::default();
1058    for (package, files) in graph
1059        .package_usage
1060        .iter()
1061        .chain(graph.type_only_package_usage.iter())
1062    {
1063        usage
1064            .entry(package.as_str())
1065            .or_default()
1066            .extend(files.iter().copied());
1067    }
1068    let map = usage
1069        .into_iter()
1070        .map(|(package, files)| {
1071            let mut importers: Vec<FileId> = files;
1072            importers.sort_unstable_by_key(|id| id.0);
1073            importers.dedup();
1074            let out_of_diff: Vec<FileId> = importers
1075                .iter()
1076                .copied()
1077                .filter(|id| id_to_norm.get(id).is_none_or(|p| !changed_norm.contains(p)))
1078                .collect();
1079            (
1080                package.to_string(),
1081                PackageImporters {
1082                    importers,
1083                    out_of_diff,
1084                },
1085            )
1086        })
1087        .collect();
1088    Some(map)
1089}
1090
1091fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
1092    let path_to_id: FxHashMap<String, FileId> = graph
1093        .modules
1094        .iter()
1095        .map(|module| (normalize_path(&module.path), module.file_id))
1096        .collect();
1097
1098    changed_files
1099        .iter()
1100        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
1101        .collect()
1102}
1103
1104fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
1105    changed_files
1106        .iter()
1107        .map(|path| normalize_path(path))
1108        .collect()
1109}
1110
1111fn normalize_path(path: &Path) -> String {
1112    path.to_string_lossy().replace('\\', "/")
1113}
1114
1115/// Canonical path form used inside audit attribution keys: root-relative with
1116/// forward slashes on every platform. Paths outside `root` keep their
1117/// simplified absolute form so keys stay unique.
1118///
1119/// The engine keys its per-module maps with this function, and
1120/// `fallow-api` builds its lookup keys with the same function, so both sides
1121/// of the join always agree.
1122pub fn relative_key_path(path: &Path, root: &Path) -> String {
1123    let simple_path = dunce::simplified(path);
1124    let simple_root = dunce::simplified(root);
1125    simple_path
1126        .strip_prefix(simple_root)
1127        .unwrap_or(simple_path)
1128        .to_string_lossy()
1129        .replace('\\', "/")
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::{RetainedModuleGraph, module_value_exports};
1135    use fallow_graph::graph::ModuleGraph;
1136    use fallow_graph::resolve::{
1137        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
1138    };
1139    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1140    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1141    use std::path::PathBuf;
1142
1143    fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
1144        import_with_mechanism(target, imported_name, false)
1145    }
1146
1147    fn import_with_mechanism(
1148        target: FileId,
1149        imported_name: ImportedName,
1150        commonjs: bool,
1151    ) -> ResolvedImport {
1152        ResolvedImport {
1153            info: ImportInfo {
1154                source: "./target".to_string(),
1155                imported_name,
1156                local_name: "target".to_string(),
1157                is_type_only: false,
1158                is_type_only_star: false,
1159                from_style: false,
1160                span: oxc_span::Span::new(0, 10),
1161                source_span: oxc_span::Span::default(),
1162            },
1163            target: if commonjs {
1164                ResolveResult::CommonJsInternalModule(target)
1165            } else {
1166                ResolveResult::InternalModule(target)
1167            },
1168        }
1169    }
1170
1171    fn value_export(name: &str, span_start: u32) -> ExportInfo {
1172        ExportInfo {
1173            name: ExportName::Named(name.to_string()),
1174            local_name: Some(name.to_string()),
1175            is_type_only: false,
1176            visibility: VisibilityTag::None,
1177            expected_unused_reason: None,
1178            span: oxc_span::Span::new(span_start, span_start + 10),
1179            members: Vec::new(),
1180            is_side_effect_used: false,
1181            super_class: None,
1182            deprecated: false,
1183            deprecated_reason: None,
1184        }
1185    }
1186
1187    fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
1188        let files: Vec<_> = (0..3)
1189            .map(|id| DiscoveredFile {
1190                id: FileId(id),
1191                path: PathBuf::from(format!("/project/file{id}.ts")),
1192                size_bytes: 1,
1193            })
1194            .collect();
1195        let modules = vec![
1196            ResolvedModule {
1197                file_id: FileId(0),
1198                path: files[0].path.clone(),
1199                resolved_imports: vec![import(
1200                    FileId(2),
1201                    ImportedName::Named("target".to_string()),
1202                )],
1203                ..ResolvedModule::default()
1204            },
1205            ResolvedModule {
1206                file_id: FileId(1),
1207                path: files[1].path.clone(),
1208                resolved_imports: vec![import(
1209                    FileId(2),
1210                    if unmasked_root_imports_export {
1211                        ImportedName::Named("target".to_string())
1212                    } else {
1213                        ImportedName::SideEffect
1214                    },
1215                )],
1216                ..ResolvedModule::default()
1217            },
1218            ResolvedModule {
1219                file_id: FileId(2),
1220                path: files[2].path.clone(),
1221                exports: vec![value_export("target", 0)].into(),
1222                ..ResolvedModule::default()
1223            },
1224        ];
1225        let test_entry_points = vec![
1226            EntryPoint {
1227                path: files[0].path.clone(),
1228                source: EntryPointSource::TestFile,
1229            },
1230            EntryPoint {
1231                path: files[1].path.clone(),
1232                source: EntryPointSource::TestFile,
1233            },
1234        ];
1235        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
1236            &modules,
1237            &[ResolvedReplacedModuleTarget {
1238                source_file: FileId(0),
1239                target_file: FileId(2),
1240            }],
1241            &test_entry_points,
1242            &[],
1243            &test_entry_points,
1244            &files,
1245        );
1246        RetainedModuleGraph::from(graph)
1247    }
1248
1249    #[test]
1250    fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
1251        let graph = mixed_root_graph(false);
1252
1253        let exports = module_value_exports(&graph);
1254
1255        assert_eq!(exports.len(), 1);
1256        assert!(!exports[0].test_referenced);
1257    }
1258
1259    #[test]
1260    fn export_coverage_accepts_an_unmasked_correlated_reference() {
1261        let graph = mixed_root_graph(true);
1262
1263        let exports = module_value_exports(&graph);
1264
1265        assert_eq!(exports.len(), 1);
1266        assert!(exports[0].test_referenced);
1267    }
1268
1269    #[test]
1270    fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
1271        let files: Vec<_> = (0..2)
1272            .map(|id| DiscoveredFile {
1273                id: FileId(id),
1274                path: PathBuf::from(format!("/project/file{id}.ts")),
1275                size_bytes: 1,
1276            })
1277            .collect();
1278        let modules = vec![
1279            ResolvedModule {
1280                file_id: FileId(0),
1281                path: files[0].path.clone(),
1282                resolved_imports: vec![
1283                    import_with_mechanism(
1284                        FileId(1),
1285                        ImportedName::Named("esmOnly".to_string()),
1286                        false,
1287                    ),
1288                    import_with_mechanism(
1289                        FileId(1),
1290                        ImportedName::Named("required".to_string()),
1291                        true,
1292                    ),
1293                ],
1294                ..ResolvedModule::default()
1295            },
1296            ResolvedModule {
1297                file_id: FileId(1),
1298                path: files[1].path.clone(),
1299                exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
1300                ..ResolvedModule::default()
1301            },
1302        ];
1303        let test_entry_points = vec![EntryPoint {
1304            path: files[0].path.clone(),
1305            source: EntryPointSource::TestFile,
1306        }];
1307        let graph =
1308            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1309                &modules,
1310                &[ResolvedReplacedModuleTarget {
1311                    source_file: FileId(0),
1312                    target_file: FileId(1),
1313                }],
1314                &test_entry_points,
1315                &[],
1316                &test_entry_points,
1317                &files,
1318            ));
1319
1320        let exports = module_value_exports(&graph);
1321        let coverage: rustc_hash::FxHashMap<_, _> = exports
1322            .into_iter()
1323            .map(|export| (export.name, export.test_referenced))
1324            .collect();
1325
1326        assert_eq!(coverage.get("esmOnly"), Some(&false));
1327        assert_eq!(coverage.get("required"), Some(&true));
1328    }
1329
1330    #[test]
1331    fn type_only_test_import_does_not_credit_a_value_export() {
1332        let files: Vec<_> = (0..3)
1333            .map(|id| DiscoveredFile {
1334                id: FileId(id),
1335                path: PathBuf::from(format!("/project/file{id}.ts")),
1336                size_bytes: 1,
1337            })
1338            .collect();
1339        let mut type_only = import(FileId(1), ImportedName::Named("typed".to_string()));
1340        type_only.info.is_type_only = true;
1341        let modules = vec![
1342            ResolvedModule {
1343                file_id: FileId(0),
1344                path: files[0].path.clone(),
1345                resolved_imports: vec![
1346                    type_only,
1347                    import(FileId(2), ImportedName::Named("called".to_string())),
1348                ],
1349                ..ResolvedModule::default()
1350            },
1351            ResolvedModule {
1352                file_id: FileId(1),
1353                path: files[1].path.clone(),
1354                exports: vec![value_export("typed", 0)].into(),
1355                ..ResolvedModule::default()
1356            },
1357            ResolvedModule {
1358                file_id: FileId(2),
1359                path: files[2].path.clone(),
1360                exports: vec![value_export("called", 0)].into(),
1361                ..ResolvedModule::default()
1362            },
1363        ];
1364        let test_entry_points = vec![EntryPoint {
1365            path: files[0].path.clone(),
1366            source: EntryPointSource::TestFile,
1367        }];
1368        let graph =
1369            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1370                &modules,
1371                &[],
1372                &test_entry_points,
1373                &[],
1374                &test_entry_points,
1375                &files,
1376            ));
1377
1378        let coverage: rustc_hash::FxHashMap<_, _> = module_value_exports(&graph)
1379            .into_iter()
1380            .map(|export| (export.name, export.test_referenced))
1381            .collect();
1382
1383        assert_eq!(coverage.get("typed"), Some(&false));
1384        assert_eq!(coverage.get("called"), Some(&true));
1385    }
1386}