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
586fn relative_key_path(path: &Path, root: &Path) -> String {
587 let simple_path = dunce::simplified(path);
588 let simple_root = dunce::simplified(root);
589 simple_path
590 .strip_prefix(simple_root)
591 .unwrap_or(simple_path)
592 .to_string_lossy()
593 .replace('\\', "/")
594}
595
596#[cfg(test)]
597mod tests {
598 use super::{RetainedModuleGraph, module_value_exports};
599 use fallow_graph::graph::ModuleGraph;
600 use fallow_graph::resolve::{
601 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
602 };
603 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
604 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
605 use std::path::PathBuf;
606
607 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
608 import_with_mechanism(target, imported_name, false)
609 }
610
611 fn import_with_mechanism(
612 target: FileId,
613 imported_name: ImportedName,
614 commonjs: bool,
615 ) -> ResolvedImport {
616 ResolvedImport {
617 info: ImportInfo {
618 source: "./target".to_string(),
619 imported_name,
620 local_name: "target".to_string(),
621 is_type_only: false,
622 is_type_only_star: false,
623 from_style: false,
624 span: oxc_span::Span::new(0, 10),
625 source_span: oxc_span::Span::default(),
626 },
627 target: if commonjs {
628 ResolveResult::CommonJsInternalModule(target)
629 } else {
630 ResolveResult::InternalModule(target)
631 },
632 }
633 }
634
635 fn value_export(name: &str, span_start: u32) -> ExportInfo {
636 ExportInfo {
637 name: ExportName::Named(name.to_string()),
638 local_name: Some(name.to_string()),
639 is_type_only: false,
640 visibility: VisibilityTag::None,
641 expected_unused_reason: None,
642 span: oxc_span::Span::new(span_start, span_start + 10),
643 members: Vec::new(),
644 is_side_effect_used: false,
645 super_class: None,
646 }
647 }
648
649 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
650 let files: Vec<_> = (0..3)
651 .map(|id| DiscoveredFile {
652 id: FileId(id),
653 path: PathBuf::from(format!("/project/file{id}.ts")),
654 size_bytes: 1,
655 })
656 .collect();
657 let modules = vec![
658 ResolvedModule {
659 file_id: FileId(0),
660 path: files[0].path.clone(),
661 resolved_imports: vec![import(
662 FileId(2),
663 ImportedName::Named("target".to_string()),
664 )],
665 ..ResolvedModule::default()
666 },
667 ResolvedModule {
668 file_id: FileId(1),
669 path: files[1].path.clone(),
670 resolved_imports: vec![import(
671 FileId(2),
672 if unmasked_root_imports_export {
673 ImportedName::Named("target".to_string())
674 } else {
675 ImportedName::SideEffect
676 },
677 )],
678 ..ResolvedModule::default()
679 },
680 ResolvedModule {
681 file_id: FileId(2),
682 path: files[2].path.clone(),
683 exports: vec![value_export("target", 0)].into(),
684 ..ResolvedModule::default()
685 },
686 ];
687 let test_entry_points = vec![
688 EntryPoint {
689 path: files[0].path.clone(),
690 source: EntryPointSource::TestFile,
691 },
692 EntryPoint {
693 path: files[1].path.clone(),
694 source: EntryPointSource::TestFile,
695 },
696 ];
697 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
698 &modules,
699 &[ResolvedReplacedModuleTarget {
700 source_file: FileId(0),
701 target_file: FileId(2),
702 }],
703 &test_entry_points,
704 &[],
705 &test_entry_points,
706 &files,
707 );
708 RetainedModuleGraph::from(graph)
709 }
710
711 #[test]
712 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
713 let graph = mixed_root_graph(false);
714
715 let exports = module_value_exports(&graph);
716
717 assert_eq!(exports.len(), 1);
718 assert!(!exports[0].test_referenced);
719 }
720
721 #[test]
722 fn export_coverage_accepts_an_unmasked_correlated_reference() {
723 let graph = mixed_root_graph(true);
724
725 let exports = module_value_exports(&graph);
726
727 assert_eq!(exports.len(), 1);
728 assert!(exports[0].test_referenced);
729 }
730
731 #[test]
732 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
733 let files: Vec<_> = (0..2)
734 .map(|id| DiscoveredFile {
735 id: FileId(id),
736 path: PathBuf::from(format!("/project/file{id}.ts")),
737 size_bytes: 1,
738 })
739 .collect();
740 let modules = vec![
741 ResolvedModule {
742 file_id: FileId(0),
743 path: files[0].path.clone(),
744 resolved_imports: vec![
745 import_with_mechanism(
746 FileId(1),
747 ImportedName::Named("esmOnly".to_string()),
748 false,
749 ),
750 import_with_mechanism(
751 FileId(1),
752 ImportedName::Named("required".to_string()),
753 true,
754 ),
755 ],
756 ..ResolvedModule::default()
757 },
758 ResolvedModule {
759 file_id: FileId(1),
760 path: files[1].path.clone(),
761 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
762 ..ResolvedModule::default()
763 },
764 ];
765 let test_entry_points = vec![EntryPoint {
766 path: files[0].path.clone(),
767 source: EntryPointSource::TestFile,
768 }];
769 let graph =
770 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
771 &modules,
772 &[ResolvedReplacedModuleTarget {
773 source_file: FileId(0),
774 target_file: FileId(1),
775 }],
776 &test_entry_points,
777 &[],
778 &test_entry_points,
779 &files,
780 ));
781
782 let exports = module_value_exports(&graph);
783 let coverage: rustc_hash::FxHashMap<_, _> = exports
784 .into_iter()
785 .map(|export| (export.name, export.test_referenced))
786 .collect();
787
788 assert_eq!(coverage.get("esmOnly"), Some(&false));
789 assert_eq!(coverage.get("required"), Some(&true));
790 }
791}