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