1#![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};
24
25#[derive(Debug)]
30pub struct RetainedModuleGraph {
31 inner: ModuleGraph,
32}
33
34impl RetainedModuleGraph {
35 #[must_use]
37 const fn new(inner: ModuleGraph) -> Self {
38 Self { inner }
39 }
40
41 pub(crate) const fn as_graph(&self) -> &ModuleGraph {
42 &self.inner
43 }
44
45 pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
47 StaticTestCoverage::new(&self.inner)
48 }
49
50 #[must_use]
52 pub fn module_count(&self) -> usize {
53 self.inner.module_count()
54 }
55
56 #[must_use]
58 pub fn edge_count(&self) -> usize {
59 self.inner.edge_count()
60 }
61
62 #[must_use]
64 pub(crate) fn public_export_keys(
65 &self,
66 public_entries: &FxHashSet<FileId>,
67 root: &Path,
68 ) -> FxHashSet<String> {
69 self.inner.public_export_keys(public_entries, root)
70 }
71
72 #[must_use]
74 pub fn direct_importer_count(&self, file_id: FileId) -> usize {
75 self.inner
76 .reverse_deps
77 .get(file_id.0 as usize)
78 .map_or(0, Vec::len)
79 }
80
81 #[must_use]
83 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
84 self.inner
85 .direct_importer_summaries(target)
86 .into_iter()
87 .map(DirectImporterSummary::from)
88 .collect()
89 }
90}
91
92#[derive(Clone, Copy)]
97pub(crate) struct StaticTestCoverage<'a> {
98 graph: &'a ModuleGraph,
99}
100
101impl<'a> StaticTestCoverage<'a> {
102 pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
103 Self { graph }
104 }
105
106 pub(crate) fn covers_file(self, file_id: FileId) -> bool {
107 self.graph.is_test_reachable(file_id)
108 }
109
110 pub(crate) fn covers_any_reference(self, export: &fallow_graph::graph::ExportSymbol) -> bool {
111 self.graph.is_any_test_reference_covered(export)
112 }
113}
114
115impl From<ModuleGraph> for RetainedModuleGraph {
116 fn from(inner: ModuleGraph) -> Self {
117 Self::new(inner)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct DirectImporterSummary {
124 pub source: FileId,
126 pub symbols: Vec<ImportedSymbolSummary>,
128}
129
130impl From<GraphDirectImporterSummary> for DirectImporterSummary {
131 fn from(summary: GraphDirectImporterSummary) -> Self {
132 Self {
133 source: summary.source,
134 symbols: summary.symbols.into_iter().map(Into::into).collect(),
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ImportedSymbolSummary {
142 pub imported: String,
144 pub local: String,
146 pub type_only: bool,
148}
149
150impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
151 fn from(symbol: GraphImportedSymbolSummary) -> Self {
152 Self {
153 imported: symbol.imported,
154 local: symbol.local,
155 type_only: symbol.type_only,
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ModuleValueExport {
163 pub file_id: FileId,
165 pub name: String,
167 pub span_start: u32,
169 pub test_referenced: bool,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct ImpactClosurePaths {
176 pub in_diff: Vec<String>,
178 pub affected_not_shown: Vec<String>,
180 pub coordination_gap: Vec<CoordinationGapPaths>,
182}
183
184impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
185 fn from(paths: GraphImpactClosurePaths) -> Self {
186 Self {
187 in_diff: paths.in_diff,
188 affected_not_shown: paths.affected_not_shown,
189 coordination_gap: paths
190 .coordination_gap
191 .into_iter()
192 .map(CoordinationGapPaths::from)
193 .collect(),
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct CoordinationGapPaths {
201 pub changed_file: String,
203 pub consumer_file: String,
205 pub consumed_symbols: Vec<String>,
207}
208
209impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
210 fn from(paths: GraphCoordinationGapPaths) -> Self {
211 Self {
212 changed_file: paths.changed_file,
213 consumer_file: paths.consumer_file,
214 consumed_symbols: paths.consumed_symbols,
215 }
216 }
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub struct PartitionOrderPaths {
222 pub units: Vec<ReviewUnitPaths>,
224 pub order: Vec<String>,
226 pub independent_slices: Vec<Vec<String>>,
229}
230
231impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
232 fn from(paths: GraphPartitionOrderPaths) -> Self {
233 Self {
234 units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
235 order: paths.order,
236 independent_slices: paths.independent_slices,
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ReviewUnitPaths {
244 pub module_dir: String,
246 pub files: Vec<String>,
248}
249
250impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
251 fn from(paths: GraphReviewUnitPaths) -> Self {
252 Self {
253 module_dir: paths.module_dir,
254 files: paths.files,
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct FocusFileFactsPaths {
262 pub file: String,
264 pub fan_in: u32,
267 pub fan_out: u32,
269 pub dynamic_dispatch: bool,
273 pub re_export_indirection: bool,
276}
277
278impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
279 fn from(paths: GraphFocusFileFactsPaths) -> Self {
280 Self {
281 file: paths.file,
282 fan_in: paths.fan_in,
283 fan_out: paths.fan_out,
284 dynamic_dispatch: paths.dynamic_dispatch,
285 re_export_indirection: paths.re_export_indirection,
286 }
287 }
288}
289
290#[must_use]
293pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
294 let test_coverage = graph.static_test_coverage();
295 let graph = graph.as_graph();
296
297 graph
298 .modules
299 .iter()
300 .flat_map(|node| {
301 node.exports
302 .iter()
303 .filter(|export| !export.is_type_only)
304 .map(|export| ModuleValueExport {
305 file_id: node.file_id,
306 name: export.name.to_string(),
307 span_start: export.span.start,
308 test_referenced: test_coverage.covers_any_reference(export),
309 })
310 })
311 .collect()
312}
313
314#[must_use]
316pub fn impact_closure_for_changed_paths(
317 graph: &RetainedModuleGraph,
318 root: &Path,
319 changed_files: &FxHashSet<PathBuf>,
320) -> Option<ImpactClosurePaths> {
321 let graph = graph.as_graph();
322 let changed_ids = changed_file_ids(graph, changed_files);
323 if changed_ids.is_empty() {
324 return None;
325 }
326
327 let closure = graph.impact_closure(&changed_ids);
328 Some(graph.closure_with_paths(&closure, root).into())
329}
330
331#[must_use]
333pub fn partition_order_for_changed_paths(
334 graph: &RetainedModuleGraph,
335 root: &Path,
336 changed_files: &FxHashSet<PathBuf>,
337) -> Option<PartitionOrderPaths> {
338 let graph = graph.as_graph();
339 let changed_ids = changed_file_ids(graph, changed_files);
340 if changed_ids.is_empty() {
341 return None;
342 }
343
344 let partition = graph.partition_order(&changed_ids);
345 Some(graph.partition_order_with_paths(&partition, root).into())
346}
347
348#[must_use]
350pub fn focus_facts_for_changed_paths(
351 graph: &RetainedModuleGraph,
352 root: &Path,
353 changed_files: &FxHashSet<PathBuf>,
354) -> Option<Vec<FocusFileFactsPaths>> {
355 let graph = graph.as_graph();
356 let changed_ids = changed_file_ids(graph, changed_files);
357 if changed_ids.is_empty() {
358 return None;
359 }
360
361 let facts = graph.focus_file_facts(&changed_ids);
362 Some(
363 graph
364 .focus_facts_with_paths(&facts, root)
365 .into_iter()
366 .map(FocusFileFactsPaths::from)
367 .collect(),
368 )
369}
370
371#[must_use]
373pub fn export_lines_for_changed_paths(
374 graph: &RetainedModuleGraph,
375 root: &Path,
376 changed_files: &FxHashSet<PathBuf>,
377) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
378 let graph = graph.as_graph();
379 let changed_norm = normalized_changed_paths(changed_files);
380 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
381 for module in &graph.modules {
382 let abs = normalize_path(&module.path);
383 if !changed_norm.contains(&abs) || module.exports.is_empty() {
384 continue;
385 }
386 let Ok(content) = std::fs::read_to_string(&module.path) else {
387 continue;
388 };
389 let offsets = fallow_types::extract::compute_line_offsets(&content);
390 let exports: Vec<(String, u32)> = module
391 .exports
392 .iter()
393 .map(|export| {
394 let (line, _) =
395 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
396 (export.name.to_string(), line)
397 })
398 .collect();
399 map.insert(relative_key_path(&module.path, root), exports);
400 }
401 Some(map)
402}
403
404#[must_use]
406pub fn internal_consumers_for_changed_paths(
407 graph: &RetainedModuleGraph,
408 root: &Path,
409 changed_files: &FxHashSet<PathBuf>,
410) -> Option<FxHashMap<String, u64>> {
411 let graph = graph.as_graph();
412 let changed_norm = normalized_changed_paths(changed_files);
413 let id_to_norm: FxHashMap<FileId, String> = graph
414 .modules
415 .iter()
416 .map(|module| (module.file_id, normalize_path(&module.path)))
417 .collect();
418
419 let mut map: FxHashMap<String, u64> = FxHashMap::default();
420 for module in &graph.modules {
421 let abs = normalize_path(&module.path);
422 if !changed_norm.contains(&abs) {
423 continue;
424 }
425 let count = graph
426 .importers_of(module.file_id)
427 .iter()
428 .filter(|imp| {
429 id_to_norm
430 .get(imp)
431 .is_none_or(|p| !changed_norm.contains(p))
432 })
433 .count() as u64;
434 map.insert(relative_key_path(&module.path, root), count);
435 }
436 Some(map)
437}
438
439#[must_use]
446pub fn test_adjacency_for_changed_paths(
447 graph: &RetainedModuleGraph,
448 root: &Path,
449 changed_files: &FxHashSet<PathBuf>,
450 is_test_path: &dyn Fn(&str) -> bool,
451) -> Option<FxHashMap<String, TestAdjacency>> {
452 let graph = graph.as_graph();
453 if !graph
456 .modules
457 .iter()
458 .any(|module| is_test_path(&relative_key_path(&module.path, root)))
459 {
460 return None;
461 }
462 let changed_norm = normalized_changed_paths(changed_files);
463 let mut map: FxHashMap<String, TestAdjacency> = FxHashMap::default();
464 for module in &graph.modules {
465 if !changed_norm.contains(&normalize_path(&module.path)) {
466 continue;
467 }
468 let rel = relative_key_path(&module.path, root);
469 if is_test_path(&rel) {
470 continue;
471 }
472 let mut adjacency = TestAdjacency::None;
473 for importer in graph.importers_of(module.file_id) {
474 let Some(node) = graph.modules.get(importer.0 as usize) else {
475 continue;
476 };
477 if !is_test_path(&relative_key_path(&node.path, root)) {
478 continue;
479 }
480 if changed_norm.contains(&normalize_path(&node.path)) {
481 adjacency = TestAdjacency::Changed;
482 break;
483 }
484 adjacency = TestAdjacency::Untouched;
485 }
486 map.insert(rel, adjacency);
487 }
488 if map.is_empty() {
489 return None;
490 }
491 Some(map)
492}
493
494#[derive(Debug, Clone, Default, PartialEq, Eq)]
496pub struct PackageImporters {
497 pub importers: Vec<FileId>,
502 pub out_of_diff: Vec<FileId>,
504}
505
506#[must_use]
514pub fn package_importers_for_changed_paths(
515 graph: &RetainedModuleGraph,
516 changed_files: &FxHashSet<PathBuf>,
517) -> Option<FxHashMap<String, PackageImporters>> {
518 let graph = graph.as_graph();
519 if graph.package_usage.is_empty() && graph.type_only_package_usage.is_empty() {
520 return None;
521 }
522 let changed_norm = normalized_changed_paths(changed_files);
523 let id_to_norm: FxHashMap<FileId, String> = graph
524 .modules
525 .iter()
526 .map(|module| (module.file_id, normalize_path(&module.path)))
527 .collect();
528 let mut usage: FxHashMap<&str, Vec<FileId>> = FxHashMap::default();
529 for (package, files) in graph
530 .package_usage
531 .iter()
532 .chain(graph.type_only_package_usage.iter())
533 {
534 usage
535 .entry(package.as_str())
536 .or_default()
537 .extend(files.iter().copied());
538 }
539 let map = usage
540 .into_iter()
541 .map(|(package, files)| {
542 let mut importers: Vec<FileId> = files;
543 importers.sort_unstable_by_key(|id| id.0);
544 importers.dedup();
545 let out_of_diff: Vec<FileId> = importers
546 .iter()
547 .copied()
548 .filter(|id| id_to_norm.get(id).is_none_or(|p| !changed_norm.contains(p)))
549 .collect();
550 (
551 package.to_string(),
552 PackageImporters {
553 importers,
554 out_of_diff,
555 },
556 )
557 })
558 .collect();
559 Some(map)
560}
561
562fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
563 let path_to_id: FxHashMap<String, FileId> = graph
564 .modules
565 .iter()
566 .map(|module| (normalize_path(&module.path), module.file_id))
567 .collect();
568
569 changed_files
570 .iter()
571 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
572 .collect()
573}
574
575fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
576 changed_files
577 .iter()
578 .map(|path| normalize_path(path))
579 .collect()
580}
581
582fn normalize_path(path: &Path) -> String {
583 path.to_string_lossy().replace('\\', "/")
584}
585
586pub fn relative_key_path(path: &Path, root: &Path) -> String {
594 let simple_path = dunce::simplified(path);
595 let simple_root = dunce::simplified(root);
596 simple_path
597 .strip_prefix(simple_root)
598 .unwrap_or(simple_path)
599 .to_string_lossy()
600 .replace('\\', "/")
601}
602
603#[cfg(test)]
604mod tests {
605 use super::{RetainedModuleGraph, module_value_exports};
606 use fallow_graph::graph::ModuleGraph;
607 use fallow_graph::resolve::{
608 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
609 };
610 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
611 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
612 use std::path::PathBuf;
613
614 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
615 import_with_mechanism(target, imported_name, false)
616 }
617
618 fn import_with_mechanism(
619 target: FileId,
620 imported_name: ImportedName,
621 commonjs: bool,
622 ) -> ResolvedImport {
623 ResolvedImport {
624 info: ImportInfo {
625 source: "./target".to_string(),
626 imported_name,
627 local_name: "target".to_string(),
628 is_type_only: false,
629 is_type_only_star: false,
630 from_style: false,
631 span: oxc_span::Span::new(0, 10),
632 source_span: oxc_span::Span::default(),
633 },
634 target: if commonjs {
635 ResolveResult::CommonJsInternalModule(target)
636 } else {
637 ResolveResult::InternalModule(target)
638 },
639 }
640 }
641
642 fn value_export(name: &str, span_start: u32) -> ExportInfo {
643 ExportInfo {
644 name: ExportName::Named(name.to_string()),
645 local_name: Some(name.to_string()),
646 is_type_only: false,
647 visibility: VisibilityTag::None,
648 expected_unused_reason: None,
649 span: oxc_span::Span::new(span_start, span_start + 10),
650 members: Vec::new(),
651 is_side_effect_used: false,
652 super_class: None,
653 deprecated: false,
654 deprecated_reason: None,
655 }
656 }
657
658 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
659 let files: Vec<_> = (0..3)
660 .map(|id| DiscoveredFile {
661 id: FileId(id),
662 path: PathBuf::from(format!("/project/file{id}.ts")),
663 size_bytes: 1,
664 })
665 .collect();
666 let modules = vec![
667 ResolvedModule {
668 file_id: FileId(0),
669 path: files[0].path.clone(),
670 resolved_imports: vec![import(
671 FileId(2),
672 ImportedName::Named("target".to_string()),
673 )],
674 ..ResolvedModule::default()
675 },
676 ResolvedModule {
677 file_id: FileId(1),
678 path: files[1].path.clone(),
679 resolved_imports: vec![import(
680 FileId(2),
681 if unmasked_root_imports_export {
682 ImportedName::Named("target".to_string())
683 } else {
684 ImportedName::SideEffect
685 },
686 )],
687 ..ResolvedModule::default()
688 },
689 ResolvedModule {
690 file_id: FileId(2),
691 path: files[2].path.clone(),
692 exports: vec![value_export("target", 0)].into(),
693 ..ResolvedModule::default()
694 },
695 ];
696 let test_entry_points = vec![
697 EntryPoint {
698 path: files[0].path.clone(),
699 source: EntryPointSource::TestFile,
700 },
701 EntryPoint {
702 path: files[1].path.clone(),
703 source: EntryPointSource::TestFile,
704 },
705 ];
706 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
707 &modules,
708 &[ResolvedReplacedModuleTarget {
709 source_file: FileId(0),
710 target_file: FileId(2),
711 }],
712 &test_entry_points,
713 &[],
714 &test_entry_points,
715 &files,
716 );
717 RetainedModuleGraph::from(graph)
718 }
719
720 #[test]
721 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
722 let graph = mixed_root_graph(false);
723
724 let exports = module_value_exports(&graph);
725
726 assert_eq!(exports.len(), 1);
727 assert!(!exports[0].test_referenced);
728 }
729
730 #[test]
731 fn export_coverage_accepts_an_unmasked_correlated_reference() {
732 let graph = mixed_root_graph(true);
733
734 let exports = module_value_exports(&graph);
735
736 assert_eq!(exports.len(), 1);
737 assert!(exports[0].test_referenced);
738 }
739
740 #[test]
741 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
742 let files: Vec<_> = (0..2)
743 .map(|id| DiscoveredFile {
744 id: FileId(id),
745 path: PathBuf::from(format!("/project/file{id}.ts")),
746 size_bytes: 1,
747 })
748 .collect();
749 let modules = vec![
750 ResolvedModule {
751 file_id: FileId(0),
752 path: files[0].path.clone(),
753 resolved_imports: vec![
754 import_with_mechanism(
755 FileId(1),
756 ImportedName::Named("esmOnly".to_string()),
757 false,
758 ),
759 import_with_mechanism(
760 FileId(1),
761 ImportedName::Named("required".to_string()),
762 true,
763 ),
764 ],
765 ..ResolvedModule::default()
766 },
767 ResolvedModule {
768 file_id: FileId(1),
769 path: files[1].path.clone(),
770 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
771 ..ResolvedModule::default()
772 },
773 ];
774 let test_entry_points = vec![EntryPoint {
775 path: files[0].path.clone(),
776 source: EntryPointSource::TestFile,
777 }];
778 let graph =
779 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
780 &modules,
781 &[ResolvedReplacedModuleTarget {
782 source_file: FileId(0),
783 target_file: FileId(1),
784 }],
785 &test_entry_points,
786 &[],
787 &test_entry_points,
788 &files,
789 ));
790
791 let exports = module_value_exports(&graph);
792 let coverage: rustc_hash::FxHashMap<_, _> = exports
793 .into_iter()
794 .map(|export| (export.name, export.test_referenced))
795 .collect();
796
797 assert_eq!(coverage.get("esmOnly"), Some(&false));
798 assert_eq!(coverage.get("required"), Some(&true));
799 }
800}