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_types::discover::FileId;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use fallow_graph::graph::{
14    CoordinationGapPaths as GraphCoordinationGapPaths,
15    FocusFileFactsPaths as GraphFocusFileFactsPaths, ImpactClosurePaths as GraphImpactClosurePaths,
16    ModuleGraph, PartitionOrderPaths as GraphPartitionOrderPaths,
17    ReviewUnitPaths as GraphReviewUnitPaths,
18};
19use fallow_graph::graph::{
20    DirectImporterSummary as GraphDirectImporterSummary,
21    ImportedSymbolSummary as GraphImportedSymbolSummary,
22};
23
24/// Engine-owned retained graph handle.
25///
26/// Downstream crates can request stable graph facts through engine helpers
27/// without depending on `fallow-graph` node internals.
28#[derive(Debug)]
29pub struct RetainedModuleGraph {
30    inner: ModuleGraph,
31}
32
33impl RetainedModuleGraph {
34    /// Wrap a freshly built module graph for engine result contracts.
35    #[must_use]
36    const fn new(inner: ModuleGraph) -> Self {
37        Self { inner }
38    }
39
40    pub(crate) const fn as_graph(&self) -> &ModuleGraph {
41        &self.inner
42    }
43
44    /// Borrow the shared static test-coverage view for health analysis.
45    pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
46        StaticTestCoverage::new(&self.inner)
47    }
48
49    /// Number of modules in the retained graph.
50    #[must_use]
51    pub fn module_count(&self) -> usize {
52        self.inner.module_count()
53    }
54
55    /// Number of edges in the retained graph.
56    #[must_use]
57    pub fn edge_count(&self) -> usize {
58        self.inner.edge_count()
59    }
60
61    /// Build public export keys for a precomputed public-entry set.
62    #[must_use]
63    pub(crate) fn public_export_keys(
64        &self,
65        public_entries: &FxHashSet<FileId>,
66        root: &Path,
67    ) -> FxHashSet<String> {
68        self.inner.public_export_keys(public_entries, root)
69    }
70
71    /// Count direct importer modules for one file id.
72    #[must_use]
73    pub fn direct_importer_count(&self, file_id: FileId) -> usize {
74        self.inner
75            .reverse_deps
76            .get(file_id.0 as usize)
77            .map_or(0, Vec::len)
78    }
79
80    /// Summaries for modules that directly import one file.
81    #[must_use]
82    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
83        self.inner
84            .direct_importer_summaries(target)
85            .into_iter()
86            .map(DirectImporterSummary::from)
87            .collect()
88    }
89}
90
91/// Engine-owned view of root-correlated static test reachability.
92///
93/// This keeps health consumers on one contract while the graph crate owns the
94/// traversal and replacement-mask representation.
95#[derive(Clone, Copy)]
96pub(crate) struct StaticTestCoverage<'a> {
97    graph: &'a ModuleGraph,
98}
99
100impl<'a> StaticTestCoverage<'a> {
101    pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
102        Self { graph }
103    }
104
105    pub(crate) fn covers_file(self, file_id: FileId) -> bool {
106        self.graph.is_test_reachable(file_id)
107    }
108
109    pub(crate) fn covers_reference(self, reference: &fallow_graph::graph::SymbolReference) -> bool {
110        self.graph.is_test_reference_covered(reference)
111    }
112}
113
114impl From<ModuleGraph> for RetainedModuleGraph {
115    fn from(inner: ModuleGraph) -> Self {
116        Self::new(inner)
117    }
118}
119
120/// Engine-owned importer details for one file that directly imports a target module.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct DirectImporterSummary {
123    pub source: FileId,
124    pub symbols: Vec<ImportedSymbolSummary>,
125}
126
127impl From<GraphDirectImporterSummary> for DirectImporterSummary {
128    fn from(summary: GraphDirectImporterSummary) -> Self {
129        Self {
130            source: summary.source,
131            symbols: summary.symbols.into_iter().map(Into::into).collect(),
132        }
133    }
134}
135
136/// Engine-owned symbol details for a direct import edge.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ImportedSymbolSummary {
139    pub imported: String,
140    pub local: String,
141    pub type_only: bool,
142}
143
144impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
145    fn from(symbol: GraphImportedSymbolSummary) -> Self {
146        Self {
147            imported: symbol.imported,
148            local: symbol.local,
149            type_only: symbol.type_only,
150        }
151    }
152}
153
154/// Engine-owned snapshot of one value export in a module graph.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ModuleValueExport {
157    pub file_id: FileId,
158    pub name: String,
159    pub span_start: u32,
160    pub test_referenced: bool,
161}
162
163/// Engine-owned impact closure with file ids resolved to paths.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct ImpactClosurePaths {
166    pub in_diff: Vec<String>,
167    pub affected_not_shown: Vec<String>,
168    pub coordination_gap: Vec<CoordinationGapPaths>,
169}
170
171impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
172    fn from(paths: GraphImpactClosurePaths) -> Self {
173        Self {
174            in_diff: paths.in_diff,
175            affected_not_shown: paths.affected_not_shown,
176            coordination_gap: paths
177                .coordination_gap
178                .into_iter()
179                .map(CoordinationGapPaths::from)
180                .collect(),
181        }
182    }
183}
184
185/// Engine-owned coordination gap between a changed contract and consumer.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct CoordinationGapPaths {
188    pub changed_file: String,
189    pub consumer_file: String,
190    pub consumed_symbols: Vec<String>,
191}
192
193impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
194    fn from(paths: GraphCoordinationGapPaths) -> Self {
195        Self {
196            changed_file: paths.changed_file,
197            consumer_file: paths.consumer_file,
198            consumed_symbols: paths.consumed_symbols,
199        }
200    }
201}
202
203/// Engine-owned review partition and dependency-sensible order.
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub struct PartitionOrderPaths {
206    pub units: Vec<ReviewUnitPaths>,
207    pub order: Vec<String>,
208}
209
210impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
211    fn from(paths: GraphPartitionOrderPaths) -> Self {
212        Self {
213            units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
214            order: paths.order,
215        }
216    }
217}
218
219/// Engine-owned changed-file review unit.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ReviewUnitPaths {
222    pub module_dir: String,
223    pub files: Vec<String>,
224}
225
226impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
227    fn from(paths: GraphReviewUnitPaths) -> Self {
228        Self {
229            module_dir: paths.module_dir,
230            files: paths.files,
231        }
232    }
233}
234
235/// Engine-owned focus facts for one changed file.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct FocusFileFactsPaths {
238    pub file: String,
239    pub fan_in: u32,
240    pub fan_out: u32,
241    pub dynamic_dispatch: bool,
242    pub re_export_indirection: bool,
243}
244
245impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
246    fn from(paths: GraphFocusFileFactsPaths) -> Self {
247        Self {
248            file: paths.file,
249            fan_in: paths.fan_in,
250            fan_out: paths.fan_out,
251            dynamic_dispatch: paths.dynamic_dispatch,
252            re_export_indirection: paths.re_export_indirection,
253        }
254    }
255}
256
257/// Return value exports with test-reference state without exposing graph node
258/// internals to downstream crates.
259#[must_use]
260pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
261    let test_coverage = graph.static_test_coverage();
262    let graph = graph.as_graph();
263
264    graph
265        .modules
266        .iter()
267        .flat_map(|node| {
268            node.exports
269                .iter()
270                .filter(|export| !export.is_type_only)
271                .map(|export| ModuleValueExport {
272                    file_id: node.file_id,
273                    name: export.name.to_string(),
274                    span_start: export.span.start,
275                    test_referenced: export
276                        .references
277                        .iter()
278                        .any(|reference| test_coverage.covers_reference(reference)),
279                })
280        })
281        .collect()
282}
283
284/// Compute a path-resolved impact closure for absolute changed paths.
285#[must_use]
286pub fn impact_closure_for_changed_paths(
287    graph: &RetainedModuleGraph,
288    root: &Path,
289    changed_files: &FxHashSet<PathBuf>,
290) -> Option<ImpactClosurePaths> {
291    let graph = graph.as_graph();
292    let changed_ids = changed_file_ids(graph, changed_files);
293    if changed_ids.is_empty() {
294        return None;
295    }
296
297    let closure = graph.impact_closure(&changed_ids);
298    Some(graph.closure_with_paths(&closure, root).into())
299}
300
301/// Compute path-resolved partition order for absolute changed paths.
302#[must_use]
303pub fn partition_order_for_changed_paths(
304    graph: &RetainedModuleGraph,
305    root: &Path,
306    changed_files: &FxHashSet<PathBuf>,
307) -> Option<PartitionOrderPaths> {
308    let graph = graph.as_graph();
309    let changed_ids = changed_file_ids(graph, changed_files);
310    if changed_ids.is_empty() {
311        return None;
312    }
313
314    let partition = graph.partition_order(&changed_ids);
315    Some(graph.partition_order_with_paths(&partition, root).into())
316}
317
318/// Compute path-resolved focus graph facts for absolute changed paths.
319#[must_use]
320pub fn focus_facts_for_changed_paths(
321    graph: &RetainedModuleGraph,
322    root: &Path,
323    changed_files: &FxHashSet<PathBuf>,
324) -> Option<Vec<FocusFileFactsPaths>> {
325    let graph = graph.as_graph();
326    let changed_ids = changed_file_ids(graph, changed_files);
327    if changed_ids.is_empty() {
328        return None;
329    }
330
331    let facts = graph.focus_file_facts(&changed_ids);
332    Some(
333        graph
334            .focus_facts_with_paths(&facts, root)
335            .into_iter()
336            .map(FocusFileFactsPaths::from)
337            .collect(),
338    )
339}
340
341/// Compute changed-file export line anchors without exposing graph nodes.
342#[must_use]
343pub fn export_lines_for_changed_paths(
344    graph: &RetainedModuleGraph,
345    root: &Path,
346    changed_files: &FxHashSet<PathBuf>,
347) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
348    let graph = graph.as_graph();
349    let changed_norm = normalized_changed_paths(changed_files);
350    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
351    for module in &graph.modules {
352        let abs = normalize_path(&module.path);
353        if !changed_norm.contains(&abs) || module.exports.is_empty() {
354            continue;
355        }
356        let Ok(content) = std::fs::read_to_string(&module.path) else {
357            continue;
358        };
359        let offsets = fallow_types::extract::compute_line_offsets(&content);
360        let exports: Vec<(String, u32)> = module
361            .exports
362            .iter()
363            .map(|export| {
364                let (line, _) =
365                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
366                (export.name.to_string(), line)
367            })
368            .collect();
369        map.insert(relative_key_path(&module.path, root), exports);
370    }
371    Some(map)
372}
373
374/// Compute direct non-diff internal consumer counts for absolute changed paths.
375#[must_use]
376pub fn internal_consumers_for_changed_paths(
377    graph: &RetainedModuleGraph,
378    root: &Path,
379    changed_files: &FxHashSet<PathBuf>,
380) -> Option<FxHashMap<String, u64>> {
381    let graph = graph.as_graph();
382    let changed_norm = normalized_changed_paths(changed_files);
383    let id_to_norm: FxHashMap<FileId, String> = graph
384        .modules
385        .iter()
386        .map(|module| (module.file_id, normalize_path(&module.path)))
387        .collect();
388
389    let mut map: FxHashMap<String, u64> = FxHashMap::default();
390    for module in &graph.modules {
391        let abs = normalize_path(&module.path);
392        if !changed_norm.contains(&abs) {
393            continue;
394        }
395        let count = graph
396            .importers_of(module.file_id)
397            .iter()
398            .filter(|imp| {
399                id_to_norm
400                    .get(imp)
401                    .is_none_or(|p| !changed_norm.contains(p))
402            })
403            .count() as u64;
404        map.insert(relative_key_path(&module.path, root), count);
405    }
406    Some(map)
407}
408
409fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
410    let path_to_id: FxHashMap<String, FileId> = graph
411        .modules
412        .iter()
413        .map(|module| (normalize_path(&module.path), module.file_id))
414        .collect();
415
416    changed_files
417        .iter()
418        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
419        .collect()
420}
421
422fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
423    changed_files
424        .iter()
425        .map(|path| normalize_path(path))
426        .collect()
427}
428
429fn normalize_path(path: &Path) -> String {
430    path.to_string_lossy().replace('\\', "/")
431}
432
433fn relative_key_path(path: &Path, root: &Path) -> String {
434    let simple_path = dunce::simplified(path);
435    let simple_root = dunce::simplified(root);
436    simple_path
437        .strip_prefix(simple_root)
438        .unwrap_or(simple_path)
439        .to_string_lossy()
440        .replace('\\', "/")
441}
442
443#[cfg(test)]
444mod tests {
445    use super::{RetainedModuleGraph, module_value_exports};
446    use fallow_graph::graph::ModuleGraph;
447    use fallow_graph::resolve::{
448        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
449    };
450    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
451    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
452    use std::path::PathBuf;
453
454    fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
455        import_with_mechanism(target, imported_name, false)
456    }
457
458    fn import_with_mechanism(
459        target: FileId,
460        imported_name: ImportedName,
461        commonjs: bool,
462    ) -> ResolvedImport {
463        ResolvedImport {
464            info: ImportInfo {
465                source: "./target".to_string(),
466                imported_name,
467                local_name: "target".to_string(),
468                is_type_only: false,
469                from_style: false,
470                span: oxc_span::Span::new(0, 10),
471                source_span: oxc_span::Span::default(),
472            },
473            target: if commonjs {
474                ResolveResult::CommonJsInternalModule(target)
475            } else {
476                ResolveResult::InternalModule(target)
477            },
478        }
479    }
480
481    fn value_export(name: &str, span_start: u32) -> ExportInfo {
482        ExportInfo {
483            name: ExportName::Named(name.to_string()),
484            local_name: Some(name.to_string()),
485            is_type_only: false,
486            visibility: VisibilityTag::None,
487            expected_unused_reason: None,
488            span: oxc_span::Span::new(span_start, span_start + 10),
489            members: Vec::new(),
490            is_side_effect_used: false,
491            super_class: None,
492        }
493    }
494
495    fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
496        let files: Vec<_> = (0..3)
497            .map(|id| DiscoveredFile {
498                id: FileId(id),
499                path: PathBuf::from(format!("/project/file{id}.ts")),
500                size_bytes: 1,
501            })
502            .collect();
503        let modules = vec![
504            ResolvedModule {
505                file_id: FileId(0),
506                path: files[0].path.clone(),
507                resolved_imports: vec![import(
508                    FileId(2),
509                    ImportedName::Named("target".to_string()),
510                )],
511                ..ResolvedModule::default()
512            },
513            ResolvedModule {
514                file_id: FileId(1),
515                path: files[1].path.clone(),
516                resolved_imports: vec![import(
517                    FileId(2),
518                    if unmasked_root_imports_export {
519                        ImportedName::Named("target".to_string())
520                    } else {
521                        ImportedName::SideEffect
522                    },
523                )],
524                ..ResolvedModule::default()
525            },
526            ResolvedModule {
527                file_id: FileId(2),
528                path: files[2].path.clone(),
529                exports: vec![value_export("target", 0)],
530                ..ResolvedModule::default()
531            },
532        ];
533        let test_entry_points = vec![
534            EntryPoint {
535                path: files[0].path.clone(),
536                source: EntryPointSource::TestFile,
537            },
538            EntryPoint {
539                path: files[1].path.clone(),
540                source: EntryPointSource::TestFile,
541            },
542        ];
543        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
544            &modules,
545            &[ResolvedReplacedModuleTarget {
546                source_file: FileId(0),
547                target_file: FileId(2),
548            }],
549            &test_entry_points,
550            &[],
551            &test_entry_points,
552            &files,
553        );
554        RetainedModuleGraph::from(graph)
555    }
556
557    #[test]
558    fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
559        let graph = mixed_root_graph(false);
560
561        let exports = module_value_exports(&graph);
562
563        assert_eq!(exports.len(), 1);
564        assert!(!exports[0].test_referenced);
565    }
566
567    #[test]
568    fn export_coverage_accepts_an_unmasked_correlated_reference() {
569        let graph = mixed_root_graph(true);
570
571        let exports = module_value_exports(&graph);
572
573        assert_eq!(exports.len(), 1);
574        assert!(exports[0].test_referenced);
575    }
576
577    #[test]
578    fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
579        let files: Vec<_> = (0..2)
580            .map(|id| DiscoveredFile {
581                id: FileId(id),
582                path: PathBuf::from(format!("/project/file{id}.ts")),
583                size_bytes: 1,
584            })
585            .collect();
586        let modules = vec![
587            ResolvedModule {
588                file_id: FileId(0),
589                path: files[0].path.clone(),
590                resolved_imports: vec![
591                    import_with_mechanism(
592                        FileId(1),
593                        ImportedName::Named("esmOnly".to_string()),
594                        false,
595                    ),
596                    import_with_mechanism(
597                        FileId(1),
598                        ImportedName::Named("required".to_string()),
599                        true,
600                    ),
601                ],
602                ..ResolvedModule::default()
603            },
604            ResolvedModule {
605                file_id: FileId(1),
606                path: files[1].path.clone(),
607                exports: vec![value_export("esmOnly", 0), value_export("required", 20)],
608                ..ResolvedModule::default()
609            },
610        ];
611        let test_entry_points = vec![EntryPoint {
612            path: files[0].path.clone(),
613            source: EntryPointSource::TestFile,
614        }];
615        let graph =
616            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
617                &modules,
618                &[ResolvedReplacedModuleTarget {
619                    source_file: FileId(0),
620                    target_file: FileId(1),
621                }],
622                &test_entry_points,
623                &[],
624                &test_entry_points,
625                &files,
626            ));
627
628        let exports = module_value_exports(&graph);
629        let coverage: rustc_hash::FxHashMap<_, _> = exports
630            .into_iter()
631            .map(|export| (export.name, export.test_referenced))
632            .collect();
633
634        assert_eq!(coverage.get("esmOnly"), Some(&false));
635        assert_eq!(coverage.get("required"), Some(&true));
636    }
637}