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    /// File containing the import statement.
124    pub source: FileId,
125    /// Symbols this importer pulls from the target module.
126    pub symbols: Vec<ImportedSymbolSummary>,
127}
128
129impl From<GraphDirectImporterSummary> for DirectImporterSummary {
130    fn from(summary: GraphDirectImporterSummary) -> Self {
131        Self {
132            source: summary.source,
133            symbols: summary.symbols.into_iter().map(Into::into).collect(),
134        }
135    }
136}
137
138/// Engine-owned symbol details for a direct import edge.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ImportedSymbolSummary {
141    /// Exported name as declared by the target module.
142    pub imported: String,
143    /// Local binding name at the import site.
144    pub local: String,
145    /// True for type-only imports, which do not count as value usage.
146    pub type_only: bool,
147}
148
149impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
150    fn from(symbol: GraphImportedSymbolSummary) -> Self {
151        Self {
152            imported: symbol.imported,
153            local: symbol.local,
154            type_only: symbol.type_only,
155        }
156    }
157}
158
159/// Engine-owned snapshot of one value export in a module graph.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ModuleValueExport {
162    /// File declaring the export.
163    pub file_id: FileId,
164    /// Exported name.
165    pub name: String,
166    /// Byte offset where the export declaration starts.
167    pub span_start: u32,
168    /// True when a test file references this export.
169    pub test_referenced: bool,
170}
171
172/// Engine-owned impact closure with file ids resolved to paths.
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct ImpactClosurePaths {
175    /// Changed files that are part of the diff under review.
176    pub in_diff: Vec<String>,
177    /// Files affected through the import closure but absent from the diff.
178    pub affected_not_shown: Vec<String>,
179    /// Changed contracts whose consumers were not updated in the same diff.
180    pub coordination_gap: Vec<CoordinationGapPaths>,
181}
182
183impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
184    fn from(paths: GraphImpactClosurePaths) -> Self {
185        Self {
186            in_diff: paths.in_diff,
187            affected_not_shown: paths.affected_not_shown,
188            coordination_gap: paths
189                .coordination_gap
190                .into_iter()
191                .map(CoordinationGapPaths::from)
192                .collect(),
193        }
194    }
195}
196
197/// Engine-owned coordination gap between a changed contract and consumer.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct CoordinationGapPaths {
200    /// Path of the changed file whose contract moved.
201    pub changed_file: String,
202    /// Path of the consumer that was not updated alongside it.
203    pub consumer_file: String,
204    /// Symbols the consumer imports from the changed file.
205    pub consumed_symbols: Vec<String>,
206}
207
208impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
209    fn from(paths: GraphCoordinationGapPaths) -> Self {
210        Self {
211            changed_file: paths.changed_file,
212            consumer_file: paths.consumer_file,
213            consumed_symbols: paths.consumed_symbols,
214        }
215    }
216}
217
218/// Engine-owned review partition and dependency-sensible order.
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct PartitionOrderPaths {
221    /// Changed files partitioned into per-module review units.
222    pub units: Vec<ReviewUnitPaths>,
223    /// File paths in dependency-sensible review order (dependencies first).
224    pub order: Vec<String>,
225}
226
227impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
228    fn from(paths: GraphPartitionOrderPaths) -> Self {
229        Self {
230            units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
231            order: paths.order,
232        }
233    }
234}
235
236/// Engine-owned changed-file review unit.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct ReviewUnitPaths {
239    /// Directory the unit's files share.
240    pub module_dir: String,
241    /// Changed files grouped into this unit.
242    pub files: Vec<String>,
243}
244
245impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
246    fn from(paths: GraphReviewUnitPaths) -> Self {
247        Self {
248            module_dir: paths.module_dir,
249            files: paths.files,
250        }
251    }
252}
253
254/// Engine-owned focus facts for one changed file.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct FocusFileFactsPaths {
257    /// Path of the changed file the facts describe.
258    pub file: String,
259    /// Count of distinct files importing this file (blast radius), excluding
260    /// the file itself.
261    pub fan_in: u32,
262    /// Count of distinct files this file imports, excluding the file itself.
263    pub fan_out: u32,
264    /// True when the file has a dynamic-import edge in either direction, so
265    /// its static reachability signal is not complete. Conservative: a file
266    /// that MAY be dynamically wired carries the flag.
267    pub dynamic_dispatch: bool,
268    /// True when the file's reachability runs through re-export indirection,
269    /// so direct importer counts understate its reach.
270    pub re_export_indirection: bool,
271}
272
273impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
274    fn from(paths: GraphFocusFileFactsPaths) -> Self {
275        Self {
276            file: paths.file,
277            fan_in: paths.fan_in,
278            fan_out: paths.fan_out,
279            dynamic_dispatch: paths.dynamic_dispatch,
280            re_export_indirection: paths.re_export_indirection,
281        }
282    }
283}
284
285/// Return value exports with test-reference state without exposing graph node
286/// internals to downstream crates.
287#[must_use]
288pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
289    let test_coverage = graph.static_test_coverage();
290    let graph = graph.as_graph();
291
292    graph
293        .modules
294        .iter()
295        .flat_map(|node| {
296            node.exports
297                .iter()
298                .filter(|export| !export.is_type_only)
299                .map(|export| ModuleValueExport {
300                    file_id: node.file_id,
301                    name: export.name.to_string(),
302                    span_start: export.span.start,
303                    test_referenced: test_coverage.covers_any_reference(export),
304                })
305        })
306        .collect()
307}
308
309/// Compute a path-resolved impact closure for absolute changed paths.
310#[must_use]
311pub fn impact_closure_for_changed_paths(
312    graph: &RetainedModuleGraph,
313    root: &Path,
314    changed_files: &FxHashSet<PathBuf>,
315) -> Option<ImpactClosurePaths> {
316    let graph = graph.as_graph();
317    let changed_ids = changed_file_ids(graph, changed_files);
318    if changed_ids.is_empty() {
319        return None;
320    }
321
322    let closure = graph.impact_closure(&changed_ids);
323    Some(graph.closure_with_paths(&closure, root).into())
324}
325
326/// Compute path-resolved partition order for absolute changed paths.
327#[must_use]
328pub fn partition_order_for_changed_paths(
329    graph: &RetainedModuleGraph,
330    root: &Path,
331    changed_files: &FxHashSet<PathBuf>,
332) -> Option<PartitionOrderPaths> {
333    let graph = graph.as_graph();
334    let changed_ids = changed_file_ids(graph, changed_files);
335    if changed_ids.is_empty() {
336        return None;
337    }
338
339    let partition = graph.partition_order(&changed_ids);
340    Some(graph.partition_order_with_paths(&partition, root).into())
341}
342
343/// Compute path-resolved focus graph facts for absolute changed paths.
344#[must_use]
345pub fn focus_facts_for_changed_paths(
346    graph: &RetainedModuleGraph,
347    root: &Path,
348    changed_files: &FxHashSet<PathBuf>,
349) -> Option<Vec<FocusFileFactsPaths>> {
350    let graph = graph.as_graph();
351    let changed_ids = changed_file_ids(graph, changed_files);
352    if changed_ids.is_empty() {
353        return None;
354    }
355
356    let facts = graph.focus_file_facts(&changed_ids);
357    Some(
358        graph
359            .focus_facts_with_paths(&facts, root)
360            .into_iter()
361            .map(FocusFileFactsPaths::from)
362            .collect(),
363    )
364}
365
366/// Compute changed-file export line anchors without exposing graph nodes.
367#[must_use]
368pub fn export_lines_for_changed_paths(
369    graph: &RetainedModuleGraph,
370    root: &Path,
371    changed_files: &FxHashSet<PathBuf>,
372) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
373    let graph = graph.as_graph();
374    let changed_norm = normalized_changed_paths(changed_files);
375    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
376    for module in &graph.modules {
377        let abs = normalize_path(&module.path);
378        if !changed_norm.contains(&abs) || module.exports.is_empty() {
379            continue;
380        }
381        let Ok(content) = std::fs::read_to_string(&module.path) else {
382            continue;
383        };
384        let offsets = fallow_types::extract::compute_line_offsets(&content);
385        let exports: Vec<(String, u32)> = module
386            .exports
387            .iter()
388            .map(|export| {
389                let (line, _) =
390                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
391                (export.name.to_string(), line)
392            })
393            .collect();
394        map.insert(relative_key_path(&module.path, root), exports);
395    }
396    Some(map)
397}
398
399/// Compute direct non-diff internal consumer counts for absolute changed paths.
400#[must_use]
401pub fn internal_consumers_for_changed_paths(
402    graph: &RetainedModuleGraph,
403    root: &Path,
404    changed_files: &FxHashSet<PathBuf>,
405) -> Option<FxHashMap<String, u64>> {
406    let graph = graph.as_graph();
407    let changed_norm = normalized_changed_paths(changed_files);
408    let id_to_norm: FxHashMap<FileId, String> = graph
409        .modules
410        .iter()
411        .map(|module| (module.file_id, normalize_path(&module.path)))
412        .collect();
413
414    let mut map: FxHashMap<String, u64> = FxHashMap::default();
415    for module in &graph.modules {
416        let abs = normalize_path(&module.path);
417        if !changed_norm.contains(&abs) {
418            continue;
419        }
420        let count = graph
421            .importers_of(module.file_id)
422            .iter()
423            .filter(|imp| {
424                id_to_norm
425                    .get(imp)
426                    .is_none_or(|p| !changed_norm.contains(p))
427            })
428            .count() as u64;
429        map.insert(relative_key_path(&module.path, root), count);
430    }
431    Some(map)
432}
433
434fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
435    let path_to_id: FxHashMap<String, FileId> = graph
436        .modules
437        .iter()
438        .map(|module| (normalize_path(&module.path), module.file_id))
439        .collect();
440
441    changed_files
442        .iter()
443        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
444        .collect()
445}
446
447fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
448    changed_files
449        .iter()
450        .map(|path| normalize_path(path))
451        .collect()
452}
453
454fn normalize_path(path: &Path) -> String {
455    path.to_string_lossy().replace('\\', "/")
456}
457
458fn relative_key_path(path: &Path, root: &Path) -> String {
459    let simple_path = dunce::simplified(path);
460    let simple_root = dunce::simplified(root);
461    simple_path
462        .strip_prefix(simple_root)
463        .unwrap_or(simple_path)
464        .to_string_lossy()
465        .replace('\\', "/")
466}
467
468#[cfg(test)]
469mod tests {
470    use super::{RetainedModuleGraph, module_value_exports};
471    use fallow_graph::graph::ModuleGraph;
472    use fallow_graph::resolve::{
473        ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
474    };
475    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
476    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
477    use std::path::PathBuf;
478
479    fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
480        import_with_mechanism(target, imported_name, false)
481    }
482
483    fn import_with_mechanism(
484        target: FileId,
485        imported_name: ImportedName,
486        commonjs: bool,
487    ) -> ResolvedImport {
488        ResolvedImport {
489            info: ImportInfo {
490                source: "./target".to_string(),
491                imported_name,
492                local_name: "target".to_string(),
493                is_type_only: false,
494                is_type_only_star: false,
495                from_style: false,
496                span: oxc_span::Span::new(0, 10),
497                source_span: oxc_span::Span::default(),
498            },
499            target: if commonjs {
500                ResolveResult::CommonJsInternalModule(target)
501            } else {
502                ResolveResult::InternalModule(target)
503            },
504        }
505    }
506
507    fn value_export(name: &str, span_start: u32) -> ExportInfo {
508        ExportInfo {
509            name: ExportName::Named(name.to_string()),
510            local_name: Some(name.to_string()),
511            is_type_only: false,
512            visibility: VisibilityTag::None,
513            expected_unused_reason: None,
514            span: oxc_span::Span::new(span_start, span_start + 10),
515            members: Vec::new(),
516            is_side_effect_used: false,
517            super_class: None,
518        }
519    }
520
521    fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
522        let files: Vec<_> = (0..3)
523            .map(|id| DiscoveredFile {
524                id: FileId(id),
525                path: PathBuf::from(format!("/project/file{id}.ts")),
526                size_bytes: 1,
527            })
528            .collect();
529        let modules = vec![
530            ResolvedModule {
531                file_id: FileId(0),
532                path: files[0].path.clone(),
533                resolved_imports: vec![import(
534                    FileId(2),
535                    ImportedName::Named("target".to_string()),
536                )],
537                ..ResolvedModule::default()
538            },
539            ResolvedModule {
540                file_id: FileId(1),
541                path: files[1].path.clone(),
542                resolved_imports: vec![import(
543                    FileId(2),
544                    if unmasked_root_imports_export {
545                        ImportedName::Named("target".to_string())
546                    } else {
547                        ImportedName::SideEffect
548                    },
549                )],
550                ..ResolvedModule::default()
551            },
552            ResolvedModule {
553                file_id: FileId(2),
554                path: files[2].path.clone(),
555                exports: vec![value_export("target", 0)].into(),
556                ..ResolvedModule::default()
557            },
558        ];
559        let test_entry_points = vec![
560            EntryPoint {
561                path: files[0].path.clone(),
562                source: EntryPointSource::TestFile,
563            },
564            EntryPoint {
565                path: files[1].path.clone(),
566                source: EntryPointSource::TestFile,
567            },
568        ];
569        let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
570            &modules,
571            &[ResolvedReplacedModuleTarget {
572                source_file: FileId(0),
573                target_file: FileId(2),
574            }],
575            &test_entry_points,
576            &[],
577            &test_entry_points,
578            &files,
579        );
580        RetainedModuleGraph::from(graph)
581    }
582
583    #[test]
584    fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
585        let graph = mixed_root_graph(false);
586
587        let exports = module_value_exports(&graph);
588
589        assert_eq!(exports.len(), 1);
590        assert!(!exports[0].test_referenced);
591    }
592
593    #[test]
594    fn export_coverage_accepts_an_unmasked_correlated_reference() {
595        let graph = mixed_root_graph(true);
596
597        let exports = module_value_exports(&graph);
598
599        assert_eq!(exports.len(), 1);
600        assert!(exports[0].test_referenced);
601    }
602
603    #[test]
604    fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
605        let files: Vec<_> = (0..2)
606            .map(|id| DiscoveredFile {
607                id: FileId(id),
608                path: PathBuf::from(format!("/project/file{id}.ts")),
609                size_bytes: 1,
610            })
611            .collect();
612        let modules = vec![
613            ResolvedModule {
614                file_id: FileId(0),
615                path: files[0].path.clone(),
616                resolved_imports: vec![
617                    import_with_mechanism(
618                        FileId(1),
619                        ImportedName::Named("esmOnly".to_string()),
620                        false,
621                    ),
622                    import_with_mechanism(
623                        FileId(1),
624                        ImportedName::Named("required".to_string()),
625                        true,
626                    ),
627                ],
628                ..ResolvedModule::default()
629            },
630            ResolvedModule {
631                file_id: FileId(1),
632                path: files[1].path.clone(),
633                exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
634                ..ResolvedModule::default()
635            },
636        ];
637        let test_entry_points = vec![EntryPoint {
638            path: files[0].path.clone(),
639            source: EntryPointSource::TestFile,
640        }];
641        let graph =
642            RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
643                &modules,
644                &[ResolvedReplacedModuleTarget {
645                    source_file: FileId(0),
646                    target_file: FileId(1),
647                }],
648                &test_entry_points,
649                &[],
650                &test_entry_points,
651                &files,
652            ));
653
654        let exports = module_value_exports(&graph);
655        let coverage: rustc_hash::FxHashMap<_, _> = exports
656            .into_iter()
657            .map(|export| (export.name, export.test_referenced))
658            .collect();
659
660        assert_eq!(coverage.get("esmOnly"), Some(&false));
661        assert_eq!(coverage.get("required"), Some(&true));
662    }
663}