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_any_reference(self, export: &fallow_graph::graph::ExportSymbol) -> bool {
110        self.graph.is_any_test_reference_covered(export)
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: test_coverage.covers_any_reference(export),
276                })
277        })
278        .collect()
279}
280
281/// Compute a path-resolved impact closure for absolute changed paths.
282#[must_use]
283pub fn impact_closure_for_changed_paths(
284    graph: &RetainedModuleGraph,
285    root: &Path,
286    changed_files: &FxHashSet<PathBuf>,
287) -> Option<ImpactClosurePaths> {
288    let graph = graph.as_graph();
289    let changed_ids = changed_file_ids(graph, changed_files);
290    if changed_ids.is_empty() {
291        return None;
292    }
293
294    let closure = graph.impact_closure(&changed_ids);
295    Some(graph.closure_with_paths(&closure, root).into())
296}
297
298/// Compute path-resolved partition order for absolute changed paths.
299#[must_use]
300pub fn partition_order_for_changed_paths(
301    graph: &RetainedModuleGraph,
302    root: &Path,
303    changed_files: &FxHashSet<PathBuf>,
304) -> Option<PartitionOrderPaths> {
305    let graph = graph.as_graph();
306    let changed_ids = changed_file_ids(graph, changed_files);
307    if changed_ids.is_empty() {
308        return None;
309    }
310
311    let partition = graph.partition_order(&changed_ids);
312    Some(graph.partition_order_with_paths(&partition, root).into())
313}
314
315/// Compute path-resolved focus graph facts for absolute changed paths.
316#[must_use]
317pub fn focus_facts_for_changed_paths(
318    graph: &RetainedModuleGraph,
319    root: &Path,
320    changed_files: &FxHashSet<PathBuf>,
321) -> Option<Vec<FocusFileFactsPaths>> {
322    let graph = graph.as_graph();
323    let changed_ids = changed_file_ids(graph, changed_files);
324    if changed_ids.is_empty() {
325        return None;
326    }
327
328    let facts = graph.focus_file_facts(&changed_ids);
329    Some(
330        graph
331            .focus_facts_with_paths(&facts, root)
332            .into_iter()
333            .map(FocusFileFactsPaths::from)
334            .collect(),
335    )
336}
337
338/// Compute changed-file export line anchors without exposing graph nodes.
339#[must_use]
340pub fn export_lines_for_changed_paths(
341    graph: &RetainedModuleGraph,
342    root: &Path,
343    changed_files: &FxHashSet<PathBuf>,
344) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
345    let graph = graph.as_graph();
346    let changed_norm = normalized_changed_paths(changed_files);
347    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
348    for module in &graph.modules {
349        let abs = normalize_path(&module.path);
350        if !changed_norm.contains(&abs) || module.exports.is_empty() {
351            continue;
352        }
353        let Ok(content) = std::fs::read_to_string(&module.path) else {
354            continue;
355        };
356        let offsets = fallow_types::extract::compute_line_offsets(&content);
357        let exports: Vec<(String, u32)> = module
358            .exports
359            .iter()
360            .map(|export| {
361                let (line, _) =
362                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
363                (export.name.to_string(), line)
364            })
365            .collect();
366        map.insert(relative_key_path(&module.path, root), exports);
367    }
368    Some(map)
369}
370
371/// Compute direct non-diff internal consumer counts for absolute changed paths.
372#[must_use]
373pub fn internal_consumers_for_changed_paths(
374    graph: &RetainedModuleGraph,
375    root: &Path,
376    changed_files: &FxHashSet<PathBuf>,
377) -> Option<FxHashMap<String, u64>> {
378    let graph = graph.as_graph();
379    let changed_norm = normalized_changed_paths(changed_files);
380    let id_to_norm: FxHashMap<FileId, String> = graph
381        .modules
382        .iter()
383        .map(|module| (module.file_id, normalize_path(&module.path)))
384        .collect();
385
386    let mut map: FxHashMap<String, u64> = FxHashMap::default();
387    for module in &graph.modules {
388        let abs = normalize_path(&module.path);
389        if !changed_norm.contains(&abs) {
390            continue;
391        }
392        let count = graph
393            .importers_of(module.file_id)
394            .iter()
395            .filter(|imp| {
396                id_to_norm
397                    .get(imp)
398                    .is_none_or(|p| !changed_norm.contains(p))
399            })
400            .count() as u64;
401        map.insert(relative_key_path(&module.path, root), count);
402    }
403    Some(map)
404}
405
406fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
407    let path_to_id: FxHashMap<String, FileId> = graph
408        .modules
409        .iter()
410        .map(|module| (normalize_path(&module.path), module.file_id))
411        .collect();
412
413    changed_files
414        .iter()
415        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
416        .collect()
417}
418
419fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
420    changed_files
421        .iter()
422        .map(|path| normalize_path(path))
423        .collect()
424}
425
426fn normalize_path(path: &Path) -> String {
427    path.to_string_lossy().replace('\\', "/")
428}
429
430fn relative_key_path(path: &Path, root: &Path) -> String {
431    let simple_path = dunce::simplified(path);
432    let simple_root = dunce::simplified(root);
433    simple_path
434        .strip_prefix(simple_root)
435        .unwrap_or(simple_path)
436        .to_string_lossy()
437        .replace('\\', "/")
438}
439
440#[cfg(test)]
441mod tests {
442    use super::{RetainedModuleGraph, module_value_exports};
443    use fallow_graph::graph::ModuleGraph;
444    use fallow_graph::resolve::{
445        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
446    };
447    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
448    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
449    use std::path::PathBuf;
450
451    fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
452        import_with_mechanism(target, imported_name, false)
453    }
454
455    fn import_with_mechanism(
456        target: FileId,
457        imported_name: ImportedName,
458        commonjs: bool,
459    ) -> ResolvedImport {
460        ResolvedImport {
461            info: ImportInfo {
462                source: "./target".to_string(),
463                imported_name,
464                local_name: "target".to_string(),
465                is_type_only: false,
466                from_style: false,
467                span: oxc_span::Span::new(0, 10),
468                source_span: oxc_span::Span::default(),
469            },
470            target: if commonjs {
471                ResolveResult::CommonJsInternalModule(target)
472            } else {
473                ResolveResult::InternalModule(target)
474            },
475        }
476    }
477
478    fn value_export(name: &str, span_start: u32) -> ExportInfo {
479        ExportInfo {
480            name: ExportName::Named(name.to_string()),
481            local_name: Some(name.to_string()),
482            is_type_only: false,
483            visibility: VisibilityTag::None,
484            expected_unused_reason: None,
485            span: oxc_span::Span::new(span_start, span_start + 10),
486            members: Vec::new(),
487            is_side_effect_used: false,
488            super_class: None,
489        }
490    }
491
492    fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
493        let files: Vec<_> = (0..3)
494            .map(|id| DiscoveredFile {
495                id: FileId(id),
496                path: PathBuf::from(format!("/project/file{id}.ts")),
497                size_bytes: 1,
498            })
499            .collect();
500        let modules = vec![
501            ResolvedModule {
502                file_id: FileId(0),
503                path: files[0].path.clone(),
504                resolved_imports: vec![import(
505                    FileId(2),
506                    ImportedName::Named("target".to_string()),
507                )],
508                ..ResolvedModule::default()
509            },
510            ResolvedModule {
511                file_id: FileId(1),
512                path: files[1].path.clone(),
513                resolved_imports: vec![import(
514                    FileId(2),
515                    if unmasked_root_imports_export {
516                        ImportedName::Named("target".to_string())
517                    } else {
518                        ImportedName::SideEffect
519                    },
520                )],
521                ..ResolvedModule::default()
522            },
523            ResolvedModule {
524                file_id: FileId(2),
525                path: files[2].path.clone(),
526                exports: vec![value_export("target", 0)],
527                ..ResolvedModule::default()
528            },
529        ];
530        let test_entry_points = vec![
531            EntryPoint {
532                path: files[0].path.clone(),
533                source: EntryPointSource::TestFile,
534            },
535            EntryPoint {
536                path: files[1].path.clone(),
537                source: EntryPointSource::TestFile,
538            },
539        ];
540        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
541            &modules,
542            &[ResolvedReplacedModuleTarget {
543                source_file: FileId(0),
544                target_file: FileId(2),
545            }],
546            &test_entry_points,
547            &[],
548            &test_entry_points,
549            &files,
550        );
551        RetainedModuleGraph::from(graph)
552    }
553
554    #[test]
555    fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
556        let graph = mixed_root_graph(false);
557
558        let exports = module_value_exports(&graph);
559
560        assert_eq!(exports.len(), 1);
561        assert!(!exports[0].test_referenced);
562    }
563
564    #[test]
565    fn export_coverage_accepts_an_unmasked_correlated_reference() {
566        let graph = mixed_root_graph(true);
567
568        let exports = module_value_exports(&graph);
569
570        assert_eq!(exports.len(), 1);
571        assert!(exports[0].test_referenced);
572    }
573
574    #[test]
575    fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
576        let files: Vec<_> = (0..2)
577            .map(|id| DiscoveredFile {
578                id: FileId(id),
579                path: PathBuf::from(format!("/project/file{id}.ts")),
580                size_bytes: 1,
581            })
582            .collect();
583        let modules = vec![
584            ResolvedModule {
585                file_id: FileId(0),
586                path: files[0].path.clone(),
587                resolved_imports: vec![
588                    import_with_mechanism(
589                        FileId(1),
590                        ImportedName::Named("esmOnly".to_string()),
591                        false,
592                    ),
593                    import_with_mechanism(
594                        FileId(1),
595                        ImportedName::Named("required".to_string()),
596                        true,
597                    ),
598                ],
599                ..ResolvedModule::default()
600            },
601            ResolvedModule {
602                file_id: FileId(1),
603                path: files[1].path.clone(),
604                exports: vec![value_export("esmOnly", 0), value_export("required", 20)],
605                ..ResolvedModule::default()
606            },
607        ];
608        let test_entry_points = vec![EntryPoint {
609            path: files[0].path.clone(),
610            source: EntryPointSource::TestFile,
611        }];
612        let graph =
613            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
614                &modules,
615                &[ResolvedReplacedModuleTarget {
616                    source_file: FileId(0),
617                    target_file: FileId(1),
618                }],
619                &test_entry_points,
620                &[],
621                &test_entry_points,
622                &files,
623            ));
624
625        let exports = module_value_exports(&graph);
626        let coverage: rustc_hash::FxHashMap<_, _> = exports
627            .into_iter()
628            .map(|export| (export.name, export.test_referenced))
629            .collect();
630
631        assert_eq!(coverage.get("esmOnly"), Some(&false));
632        assert_eq!(coverage.get("required"), Some(&true));
633    }
634}