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};
24use fallow_graph::graph::{
25 ExportTestReach as GraphExportTestReach, ExportTestReachResult as GraphExportTestReachResult,
26 TestMaskingState as GraphTestMaskingState,
27};
28
29#[derive(Debug)]
34pub struct RetainedModuleGraph {
35 inner: ModuleGraph,
36}
37
38impl RetainedModuleGraph {
39 #[must_use]
41 const fn new(inner: ModuleGraph) -> Self {
42 Self { inner }
43 }
44
45 pub(crate) const fn as_graph(&self) -> &ModuleGraph {
46 &self.inner
47 }
48
49 pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
51 StaticTestCoverage::new(&self.inner)
52 }
53
54 #[must_use]
56 pub fn module_count(&self) -> usize {
57 self.inner.module_count()
58 }
59
60 #[must_use]
62 pub fn edge_count(&self) -> usize {
63 self.inner.edge_count()
64 }
65
66 #[must_use]
68 pub(crate) fn public_export_keys(
69 &self,
70 public_entries: &FxHashSet<FileId>,
71 root: &Path,
72 ) -> FxHashSet<String> {
73 self.inner.public_export_keys(public_entries, root)
74 }
75
76 #[must_use]
78 pub fn direct_importer_count(&self, file_id: FileId) -> usize {
79 self.inner
80 .reverse_deps
81 .get(file_id.0 as usize)
82 .map_or(0, Vec::len)
83 }
84
85 #[must_use]
87 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
88 self.inner
89 .direct_importer_summaries(target)
90 .into_iter()
91 .map(DirectImporterSummary::from)
92 .collect()
93 }
94}
95
96#[derive(Clone, Copy)]
106pub(crate) struct StaticTestCoverage<'a> {
107 graph: &'a ModuleGraph,
108 file_map: Option<&'a FxHashMap<FileId, FileId>>,
109}
110
111impl<'a> StaticTestCoverage<'a> {
112 pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
113 Self {
114 graph,
115 file_map: None,
116 }
117 }
118
119 pub(crate) const fn mapped(
122 graph: &'a ModuleGraph,
123 file_map: &'a FxHashMap<FileId, FileId>,
124 ) -> Self {
125 Self {
126 graph,
127 file_map: Some(file_map),
128 }
129 }
130
131 fn graph_file_id(self, file_id: FileId) -> Option<FileId> {
132 match self.file_map {
133 Some(file_map) => file_map.get(&file_id).copied(),
134 None => Some(file_id),
135 }
136 }
137
138 fn graph_export<'b>(
140 self,
141 file_id: FileId,
142 export: &'b fallow_graph::graph::ExportSymbol,
143 ) -> Option<(FileId, &'b fallow_graph::graph::ExportSymbol)>
144 where
145 'a: 'b,
146 {
147 if self.file_map.is_none() {
148 return Some((file_id, export));
149 }
150 let graph_file_id = self.graph_file_id(file_id)?;
151 let node = self.graph.modules.get(graph_file_id.0 as usize)?;
152 node.exports
153 .iter()
154 .find(|candidate| candidate.name == export.name)
155 .map(|candidate| (graph_file_id, candidate))
156 }
157
158 pub(crate) fn covers_file(self, file_id: FileId) -> bool {
159 self.graph_file_id(file_id)
160 .is_some_and(|graph_file_id| self.graph.is_test_reachable(graph_file_id))
161 }
162
163 pub(crate) fn covers_any_reference(
164 self,
165 file_id: FileId,
166 export: &fallow_graph::graph::ExportSymbol,
167 ) -> bool {
168 self.graph_export(file_id, export)
169 .is_some_and(|(_, export)| self.graph.is_any_test_reference_covered(export))
170 }
171
172 pub(crate) fn is_mocked_only(
175 self,
176 file_id: FileId,
177 export: &fallow_graph::graph::ExportSymbol,
178 ) -> bool {
179 if self.graph.test_masking_state() != GraphTestMaskingState::Active {
180 return false;
181 }
182 self.graph_export(file_id, export)
183 .is_some_and(|(graph_file_id, export)| {
184 self.graph
185 .export_test_reach(graph_file_id, export, &|_| false)
186 .reach
187 == GraphExportTestReach::MockedOnly
188 })
189 }
190}
191
192impl From<ModuleGraph> for RetainedModuleGraph {
193 fn from(inner: ModuleGraph) -> Self {
194 Self::new(inner)
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct DirectImporterSummary {
201 pub source: FileId,
203 pub symbols: Vec<ImportedSymbolSummary>,
205}
206
207impl From<GraphDirectImporterSummary> for DirectImporterSummary {
208 fn from(summary: GraphDirectImporterSummary) -> Self {
209 Self {
210 source: summary.source,
211 symbols: summary.symbols.into_iter().map(Into::into).collect(),
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ImportedSymbolSummary {
219 pub imported: String,
221 pub local: String,
223 pub type_only: bool,
225}
226
227impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
228 fn from(symbol: GraphImportedSymbolSummary) -> Self {
229 Self {
230 imported: symbol.imported,
231 local: symbol.local,
232 type_only: symbol.type_only,
233 }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct ModuleValueExport {
240 pub file_id: FileId,
242 pub name: String,
244 pub span_start: u32,
246 pub test_referenced: bool,
248}
249
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct ImpactClosurePaths {
253 pub in_diff: Vec<String>,
255 pub affected_not_shown: Vec<String>,
257 pub coordination_gap: Vec<CoordinationGapPaths>,
259}
260
261impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
262 fn from(paths: GraphImpactClosurePaths) -> Self {
263 Self {
264 in_diff: paths.in_diff,
265 affected_not_shown: paths.affected_not_shown,
266 coordination_gap: paths
267 .coordination_gap
268 .into_iter()
269 .map(CoordinationGapPaths::from)
270 .collect(),
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct CoordinationGapPaths {
278 pub changed_file: String,
280 pub consumer_file: String,
282 pub consumed_symbols: Vec<String>,
284}
285
286impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
287 fn from(paths: GraphCoordinationGapPaths) -> Self {
288 Self {
289 changed_file: paths.changed_file,
290 consumer_file: paths.consumer_file,
291 consumed_symbols: paths.consumed_symbols,
292 }
293 }
294}
295
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct PartitionOrderPaths {
299 pub units: Vec<ReviewUnitPaths>,
301 pub order: Vec<String>,
303 pub independent_slices: Vec<Vec<String>>,
306}
307
308impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
309 fn from(paths: GraphPartitionOrderPaths) -> Self {
310 Self {
311 units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
312 order: paths.order,
313 independent_slices: paths.independent_slices,
314 }
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct ReviewUnitPaths {
321 pub module_dir: String,
323 pub files: Vec<String>,
325}
326
327impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
328 fn from(paths: GraphReviewUnitPaths) -> Self {
329 Self {
330 module_dir: paths.module_dir,
331 files: paths.files,
332 }
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct FocusFileFactsPaths {
339 pub file: String,
341 pub fan_in: u32,
344 pub fan_out: u32,
346 pub dynamic_dispatch: bool,
350 pub re_export_indirection: bool,
353}
354
355impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
356 fn from(paths: GraphFocusFileFactsPaths) -> Self {
357 Self {
358 file: paths.file,
359 fan_in: paths.fan_in,
360 fan_out: paths.fan_out,
361 dynamic_dispatch: paths.dynamic_dispatch,
362 re_export_indirection: paths.re_export_indirection,
363 }
364 }
365}
366
367#[must_use]
370pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
371 module_value_exports_with_test_reach(graph, None)
372}
373
374#[must_use]
380pub fn module_value_exports_with_test_reach(
381 graph: &RetainedModuleGraph,
382 test_reach: Option<&crate::health::TestReachGraph>,
383) -> Vec<ModuleValueExport> {
384 let test_coverage = test_reach.map_or_else(
385 || graph.static_test_coverage(),
386 crate::health::TestReachGraph::static_test_coverage,
387 );
388 let graph = graph.as_graph();
389
390 graph
391 .modules
392 .iter()
393 .flat_map(|node| {
394 node.exports
395 .iter()
396 .filter(|export| !export.is_type_only)
397 .map(|export| ModuleValueExport {
398 file_id: node.file_id,
399 name: export.name.to_string(),
400 span_start: export.span.start,
401 test_referenced: test_coverage.covers_any_reference(node.file_id, export),
402 })
403 })
404 .collect()
405}
406
407#[must_use]
409pub fn impact_closure_for_changed_paths(
410 graph: &RetainedModuleGraph,
411 root: &Path,
412 changed_files: &FxHashSet<PathBuf>,
413) -> Option<ImpactClosurePaths> {
414 let graph = graph.as_graph();
415 let changed_ids = changed_file_ids(graph, changed_files);
416 if changed_ids.is_empty() {
417 return None;
418 }
419
420 let closure = graph.impact_closure(&changed_ids);
421 Some(graph.closure_with_paths(&closure, root).into())
422}
423
424#[must_use]
426pub fn partition_order_for_changed_paths(
427 graph: &RetainedModuleGraph,
428 root: &Path,
429 changed_files: &FxHashSet<PathBuf>,
430) -> Option<PartitionOrderPaths> {
431 let graph = graph.as_graph();
432 let changed_ids = changed_file_ids(graph, changed_files);
433 if changed_ids.is_empty() {
434 return None;
435 }
436
437 let partition = graph.partition_order(&changed_ids);
438 Some(graph.partition_order_with_paths(&partition, root).into())
439}
440
441#[must_use]
443pub fn focus_facts_for_changed_paths(
444 graph: &RetainedModuleGraph,
445 root: &Path,
446 changed_files: &FxHashSet<PathBuf>,
447) -> Option<Vec<FocusFileFactsPaths>> {
448 let graph = graph.as_graph();
449 let changed_ids = changed_file_ids(graph, changed_files);
450 if changed_ids.is_empty() {
451 return None;
452 }
453
454 let facts = graph.focus_file_facts(&changed_ids);
455 Some(
456 graph
457 .focus_facts_with_paths(&facts, root)
458 .into_iter()
459 .map(FocusFileFactsPaths::from)
460 .collect(),
461 )
462}
463
464#[must_use]
466pub fn export_lines_for_changed_paths(
467 graph: &RetainedModuleGraph,
468 root: &Path,
469 changed_files: &FxHashSet<PathBuf>,
470) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
471 let graph = graph.as_graph();
472 let changed_norm = normalized_changed_paths(changed_files);
473 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
474 for module in &graph.modules {
475 let abs = normalize_path(&module.path);
476 if !changed_norm.contains(&abs) || module.exports.is_empty() {
477 continue;
478 }
479 let Ok(content) = std::fs::read_to_string(&module.path) else {
480 continue;
481 };
482 let offsets = fallow_types::extract::compute_line_offsets(&content);
483 let exports: Vec<(String, u32)> = module
484 .exports
485 .iter()
486 .map(|export| {
487 let (line, _) =
488 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
489 (export.name.to_string(), line)
490 })
491 .collect();
492 map.insert(relative_key_path(&module.path, root), exports);
493 }
494 Some(map)
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ConsumerSearchGapPaths {
501 pub consumer_file: String,
503 pub check: crate::search_visibility::SearchCheck,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct ExportSearchGapPaths {
510 pub file: String,
512 pub export_name: String,
514 pub line: u32,
516 pub declaration_lines: (u32, u32),
519 pub consumers: Vec<ConsumerSearchGapPaths>,
522}
523
524#[must_use]
527pub fn export_names_in_source(path: &Path, source: &str) -> FxHashSet<String> {
528 let source = source.strip_prefix('\u{feff}').unwrap_or(source);
529 let module = fallow_extract::parse_source_to_module(FileId(0), path, source, 0, false);
530 module
531 .exports
532 .iter()
533 .map(|export| export.name.to_string())
534 .collect()
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct ChangedExport {
544 file_id: FileId,
545 export_index: usize,
546 pub file: String,
548 pub export_name: String,
550 pub line: u32,
552 pub declaration_lines: (u32, u32),
555 pub is_type_only: bool,
557 pub literal_value: bool,
560 pub is_re_export: bool,
562}
563
564#[must_use]
570pub fn changed_exports_for_changed_paths(
571 graph: &RetainedModuleGraph,
572 root: &Path,
573 changed_files: &FxHashSet<PathBuf>,
574) -> Option<Vec<ChangedExport>> {
575 let graph = graph.as_graph();
576 let mut changed_ids = changed_file_ids(graph, changed_files);
577 if changed_ids.is_empty() {
578 return None;
579 }
580 changed_ids.sort_unstable_by_key(|id| id.0);
581 let mut changed_exports = Vec::new();
582 for file_id in changed_ids {
583 let Some(module) = graph.modules.get(file_id.0 as usize) else {
584 continue;
585 };
586 if module.exports.is_empty() {
587 continue;
588 }
589 let Ok(source) = std::fs::read_to_string(&module.path) else {
590 continue;
591 };
592 changed_exports.extend(module_exports(graph, root, file_id, &source));
593 }
594 Some(changed_exports)
595}
596
597pub(crate) fn module_exports(
600 graph: &ModuleGraph,
601 root: &Path,
602 file_id: FileId,
603 source: &str,
604) -> Vec<ChangedExport> {
605 let Some(module) = graph.modules.get(file_id.0 as usize) else {
606 return Vec::new();
607 };
608 if module.exports.is_empty() {
609 return Vec::new();
610 }
611 let offsets = fallow_types::extract::compute_line_offsets(source);
612 let starts: Vec<u32> = module
613 .exports
614 .iter()
615 .map(|export| export.span.start)
616 .collect();
617 let declarations = crate::search_visibility::export_declarations(&module.path, source, &starts);
618 let file = relative_key_path(&module.path, root);
619 module
620 .exports
621 .iter()
622 .enumerate()
623 .map(|(export_index, export)| {
624 let line =
625 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start).0;
626 let declaration = declarations.get(&export.span.start);
627 ChangedExport {
628 file_id,
629 export_index,
630 file: file.clone(),
631 export_name: export.name.to_string(),
632 line,
633 declaration_lines: declaration.map_or((line, line), |found| found.lines),
634 is_type_only: export.is_type_only,
635 literal_value: declaration.is_some_and(|found| found.literal_value),
636 is_re_export: declaration.is_some_and(|found| found.is_re_export),
637 }
638 })
639 .collect()
640}
641
642#[must_use]
649pub fn search_gaps_for_changed_paths(
650 graph: &RetainedModuleGraph,
651 root: &Path,
652 changed_files: &FxHashSet<PathBuf>,
653 changed_exports: &[ChangedExport],
654) -> Option<Vec<ExportSearchGapPaths>> {
655 use crate::search_visibility::{SearchTarget, SourceCache};
656 use fallow_graph::graph::ExportNamespace;
657 use fallow_types::trace::SearchVisibility;
658
659 let graph = graph.as_graph();
660 let changed_set: FxHashSet<FileId> =
661 changed_file_ids(graph, changed_files).into_iter().collect();
662 if changed_set.is_empty() {
663 return None;
664 }
665 let mut sources = SourceCache::new(graph);
666 let mut gaps = Vec::new();
667 for changed in changed_exports {
668 let Some(export) = graph
669 .modules
670 .get(changed.file_id.0 as usize)
671 .and_then(|module| module.exports.get(changed.export_index))
672 else {
673 continue;
674 };
675 let mut seen = FxHashSet::default();
676 let candidates: Vec<_> = export
677 .references
678 .iter()
679 .filter(|reference| {
680 !changed_set.contains(&reference.from_file)
681 && graph
682 .modules
683 .get(reference.from_file.0 as usize)
684 .is_some_and(fallow_graph::graph::ModuleNode::is_reachable)
685 && seen.insert(reference.from_file)
686 })
687 .collect();
688 if candidates.is_empty() {
689 continue;
690 }
691 let namespace = if export.is_type_only {
692 ExportNamespace::Type
693 } else {
694 ExportNamespace::Value
695 };
696 let target = SearchTarget::new(
697 graph,
698 &mut sources,
699 changed.file_id,
700 &changed.export_name,
701 namespace,
702 );
703 let mut consumers: Vec<ConsumerSearchGapPaths> = candidates
704 .into_iter()
705 .filter_map(|reference| {
706 let check = target.check(graph, &mut sources, reference)?;
707 if check.visibility == SearchVisibility::Found {
708 return None;
709 }
710 let consumer = graph.modules.get(reference.from_file.0 as usize)?;
711 Some(ConsumerSearchGapPaths {
712 consumer_file: relative_key_path(&consumer.path, root),
713 check,
714 })
715 })
716 .collect();
717 if consumers.is_empty() {
718 continue;
719 }
720 consumers.sort_by(|a, b| a.consumer_file.cmp(&b.consumer_file));
721 gaps.push(ExportSearchGapPaths {
722 file: changed.file.clone(),
723 export_name: changed.export_name.clone(),
724 line: changed.line,
725 declaration_lines: changed.declaration_lines,
726 consumers,
727 });
728 }
729 Some(gaps)
730}
731
732const E2E_RUNNER_PACKAGES: &[&str] = &["@playwright/test", "playwright", "cypress"];
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
738#[serde(rename_all = "kebab-case")]
739pub enum ExportTestReach {
740 Direct,
742 Transitive,
744 MockedOnly,
747 None,
749 NoneFile,
751}
752
753impl From<GraphExportTestReach> for ExportTestReach {
754 fn from(reach: GraphExportTestReach) -> Self {
755 match reach {
756 GraphExportTestReach::Direct => Self::Direct,
757 GraphExportTestReach::Transitive => Self::Transitive,
758 GraphExportTestReach::MockedOnly => Self::MockedOnly,
759 GraphExportTestReach::None => Self::None,
760 GraphExportTestReach::NoneFile => Self::NoneFile,
761 }
762 }
763}
764
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
767pub enum TestMaskingState {
768 Active,
770 NoMocks,
772 Capped,
774}
775
776impl From<GraphTestMaskingState> for TestMaskingState {
777 fn from(state: GraphTestMaskingState) -> Self {
778 match state {
779 GraphTestMaskingState::Active => Self::Active,
780 GraphTestMaskingState::NoMocks => Self::NoMocks,
781 GraphTestMaskingState::Capped => Self::Capped,
782 }
783 }
784}
785
786#[derive(Debug, Clone, PartialEq, Eq)]
788pub struct ExportTestReachPaths {
789 pub file: String,
791 pub export_name: String,
793 pub line: u32,
795 pub declaration_lines: (u32, u32),
797 pub literal_value: bool,
799 pub reach: ExportTestReach,
801 pub tests: Vec<String>,
804 pub mocked_targets: Vec<String>,
806}
807
808#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct TestReachPaths {
811 pub has_tests: bool,
814 pub masking: TestMaskingState,
816 pub has_e2e_runner: bool,
818 pub exports: Vec<ExportTestReachPaths>,
820}
821
822#[must_use]
830pub fn test_reach_for_changed_paths(
831 graph: &RetainedModuleGraph,
832 root: &Path,
833 changed_exports: &[ChangedExport],
834 is_test_path: &dyn Fn(&str) -> bool,
835) -> Option<TestReachPaths> {
836 let graph = graph.as_graph();
837 let mut test_files: FxHashMap<FileId, bool> = FxHashMap::default();
838 let mut is_test_file = |file_id: FileId| -> bool {
839 *test_files.entry(file_id).or_insert_with(|| {
840 graph.test_entry_points.contains(&file_id)
841 || graph
842 .modules
843 .get(file_id.0 as usize)
844 .is_some_and(|module| is_test_path(&relative_key_path(&module.path, root)))
845 })
846 };
847 let has_tests = !graph.test_entry_points.is_empty();
848 let exports: Vec<ExportTestReachPaths> = changed_exports
849 .iter()
850 .filter_map(|changed| classify_export_test_reach(graph, root, changed, &mut is_test_file))
851 .collect();
852 if exports.is_empty() {
853 return None;
854 }
855 let has_e2e_runner = E2E_RUNNER_PACKAGES.iter().any(|name| {
856 graph.package_usage.contains_key(*name) || graph.type_only_package_usage.contains_key(*name)
857 });
858 Some(TestReachPaths {
859 has_tests,
860 masking: graph.test_masking_state().into(),
861 has_e2e_runner,
862 exports,
863 })
864}
865
866pub(crate) fn classify_export_test_reach(
871 graph: &ModuleGraph,
872 root: &Path,
873 changed: &ChangedExport,
874 is_test_file: &mut dyn FnMut(FileId) -> bool,
875) -> Option<ExportTestReachPaths> {
876 if changed.is_type_only || changed.is_re_export {
877 return None;
878 }
879 let module = graph.modules.get(changed.file_id.0 as usize)?;
880 let export = module.exports.get(changed.export_index)?;
881 if !is_reach_subject(&module.path) || is_test_file(changed.file_id) {
882 return None;
883 }
884 let result = if graph.test_entry_points.is_empty() {
885 GraphExportTestReachResult {
886 reach: GraphExportTestReach::NoneFile,
887 tests: Vec::new(),
888 mocked_targets: Vec::new(),
889 }
890 } else {
891 let test_flags: FxHashMap<FileId, bool> = export
892 .references
893 .iter()
894 .map(|reference| (reference.from_file, is_test_file(reference.from_file)))
895 .collect();
896 graph.export_test_reach(changed.file_id, export, &|file_id| {
897 test_flags.get(&file_id).copied().unwrap_or(false)
898 })
899 };
900 let paths_of = |files: &[FileId]| -> Vec<String> {
901 let mut paths: Vec<String> = files
902 .iter()
903 .filter_map(|file_id| graph.modules.get(file_id.0 as usize))
904 .map(|node| relative_key_path(&node.path, root))
905 .collect();
906 paths.sort_unstable();
907 paths
908 };
909 Some(ExportTestReachPaths {
910 file: changed.file.clone(),
911 export_name: changed.export_name.clone(),
912 line: changed.line,
913 declaration_lines: changed.declaration_lines,
914 literal_value: changed.literal_value,
915 reach: result.reach.into(),
916 tests: paths_of(&result.tests),
917 mocked_targets: paths_of(&result.mocked_targets),
918 })
919}
920
921fn is_reach_subject(path: &Path) -> bool {
924 if fallow_graph::graph::is_declaration_file_path(path) {
925 return false;
926 }
927 !path
928 .extension()
929 .and_then(|extension| extension.to_str())
930 .is_some_and(|extension| matches!(extension, "css" | "scss" | "less" | "sass"))
931}
932
933#[must_use]
935pub fn internal_consumers_for_changed_paths(
936 graph: &RetainedModuleGraph,
937 root: &Path,
938 changed_files: &FxHashSet<PathBuf>,
939) -> Option<FxHashMap<String, u64>> {
940 let graph = graph.as_graph();
941 let changed_norm = normalized_changed_paths(changed_files);
942 let id_to_norm: FxHashMap<FileId, String> = graph
943 .modules
944 .iter()
945 .map(|module| (module.file_id, normalize_path(&module.path)))
946 .collect();
947
948 let mut map: FxHashMap<String, u64> = FxHashMap::default();
949 for module in &graph.modules {
950 let abs = normalize_path(&module.path);
951 if !changed_norm.contains(&abs) {
952 continue;
953 }
954 let count = graph
955 .importers_of(module.file_id)
956 .iter()
957 .filter(|imp| {
958 id_to_norm
959 .get(imp)
960 .is_none_or(|p| !changed_norm.contains(p))
961 })
962 .count() as u64;
963 map.insert(relative_key_path(&module.path, root), count);
964 }
965 Some(map)
966}
967
968#[must_use]
975pub fn test_adjacency_for_changed_paths(
976 graph: &RetainedModuleGraph,
977 root: &Path,
978 changed_files: &FxHashSet<PathBuf>,
979 is_test_path: &dyn Fn(&str) -> bool,
980) -> Option<FxHashMap<String, TestAdjacency>> {
981 let graph = graph.as_graph();
982 if !graph
985 .modules
986 .iter()
987 .any(|module| is_test_path(&relative_key_path(&module.path, root)))
988 {
989 return None;
990 }
991 let changed_norm = normalized_changed_paths(changed_files);
992 let mut map: FxHashMap<String, TestAdjacency> = FxHashMap::default();
993 for module in &graph.modules {
994 if !changed_norm.contains(&normalize_path(&module.path)) {
995 continue;
996 }
997 let rel = relative_key_path(&module.path, root);
998 if is_test_path(&rel) {
999 continue;
1000 }
1001 let mut adjacency = TestAdjacency::None;
1002 for importer in graph.importers_of(module.file_id) {
1003 let Some(node) = graph.modules.get(importer.0 as usize) else {
1004 continue;
1005 };
1006 if !is_test_path(&relative_key_path(&node.path, root)) {
1007 continue;
1008 }
1009 if changed_norm.contains(&normalize_path(&node.path)) {
1010 adjacency = TestAdjacency::Changed;
1011 break;
1012 }
1013 adjacency = TestAdjacency::Untouched;
1014 }
1015 map.insert(rel, adjacency);
1016 }
1017 if map.is_empty() {
1018 return None;
1019 }
1020 Some(map)
1021}
1022
1023#[derive(Debug, Clone, Default, PartialEq, Eq)]
1025pub struct PackageImporters {
1026 pub importers: Vec<FileId>,
1031 pub out_of_diff: Vec<FileId>,
1033}
1034
1035#[must_use]
1043pub fn package_importers_for_changed_paths(
1044 graph: &RetainedModuleGraph,
1045 changed_files: &FxHashSet<PathBuf>,
1046) -> Option<FxHashMap<String, PackageImporters>> {
1047 let graph = graph.as_graph();
1048 if graph.package_usage.is_empty() && graph.type_only_package_usage.is_empty() {
1049 return None;
1050 }
1051 let changed_norm = normalized_changed_paths(changed_files);
1052 let id_to_norm: FxHashMap<FileId, String> = graph
1053 .modules
1054 .iter()
1055 .map(|module| (module.file_id, normalize_path(&module.path)))
1056 .collect();
1057 let mut usage: FxHashMap<&str, Vec<FileId>> = FxHashMap::default();
1058 for (package, files) in graph
1059 .package_usage
1060 .iter()
1061 .chain(graph.type_only_package_usage.iter())
1062 {
1063 usage
1064 .entry(package.as_str())
1065 .or_default()
1066 .extend(files.iter().copied());
1067 }
1068 let map = usage
1069 .into_iter()
1070 .map(|(package, files)| {
1071 let mut importers: Vec<FileId> = files;
1072 importers.sort_unstable_by_key(|id| id.0);
1073 importers.dedup();
1074 let out_of_diff: Vec<FileId> = importers
1075 .iter()
1076 .copied()
1077 .filter(|id| id_to_norm.get(id).is_none_or(|p| !changed_norm.contains(p)))
1078 .collect();
1079 (
1080 package.to_string(),
1081 PackageImporters {
1082 importers,
1083 out_of_diff,
1084 },
1085 )
1086 })
1087 .collect();
1088 Some(map)
1089}
1090
1091fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
1092 let path_to_id: FxHashMap<String, FileId> = graph
1093 .modules
1094 .iter()
1095 .map(|module| (normalize_path(&module.path), module.file_id))
1096 .collect();
1097
1098 changed_files
1099 .iter()
1100 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
1101 .collect()
1102}
1103
1104fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
1105 changed_files
1106 .iter()
1107 .map(|path| normalize_path(path))
1108 .collect()
1109}
1110
1111fn normalize_path(path: &Path) -> String {
1112 path.to_string_lossy().replace('\\', "/")
1113}
1114
1115pub fn relative_key_path(path: &Path, root: &Path) -> String {
1123 let simple_path = dunce::simplified(path);
1124 let simple_root = dunce::simplified(root);
1125 simple_path
1126 .strip_prefix(simple_root)
1127 .unwrap_or(simple_path)
1128 .to_string_lossy()
1129 .replace('\\', "/")
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::{RetainedModuleGraph, module_value_exports};
1135 use fallow_graph::graph::ModuleGraph;
1136 use fallow_graph::resolve::{
1137 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
1138 };
1139 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1140 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1141 use std::path::PathBuf;
1142
1143 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
1144 import_with_mechanism(target, imported_name, false)
1145 }
1146
1147 fn import_with_mechanism(
1148 target: FileId,
1149 imported_name: ImportedName,
1150 commonjs: bool,
1151 ) -> ResolvedImport {
1152 ResolvedImport {
1153 info: ImportInfo {
1154 source: "./target".to_string(),
1155 imported_name,
1156 local_name: "target".to_string(),
1157 is_type_only: false,
1158 is_type_only_star: false,
1159 from_style: false,
1160 span: oxc_span::Span::new(0, 10),
1161 source_span: oxc_span::Span::default(),
1162 },
1163 target: if commonjs {
1164 ResolveResult::CommonJsInternalModule(target)
1165 } else {
1166 ResolveResult::InternalModule(target)
1167 },
1168 }
1169 }
1170
1171 fn value_export(name: &str, span_start: u32) -> ExportInfo {
1172 ExportInfo {
1173 name: ExportName::Named(name.to_string()),
1174 local_name: Some(name.to_string()),
1175 is_type_only: false,
1176 visibility: VisibilityTag::None,
1177 expected_unused_reason: None,
1178 span: oxc_span::Span::new(span_start, span_start + 10),
1179 members: Vec::new(),
1180 is_side_effect_used: false,
1181 super_class: None,
1182 deprecated: false,
1183 deprecated_reason: None,
1184 }
1185 }
1186
1187 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
1188 let files: Vec<_> = (0..3)
1189 .map(|id| DiscoveredFile {
1190 id: FileId(id),
1191 path: PathBuf::from(format!("/project/file{id}.ts")),
1192 size_bytes: 1,
1193 })
1194 .collect();
1195 let modules = vec![
1196 ResolvedModule {
1197 file_id: FileId(0),
1198 path: files[0].path.clone(),
1199 resolved_imports: vec![import(
1200 FileId(2),
1201 ImportedName::Named("target".to_string()),
1202 )],
1203 ..ResolvedModule::default()
1204 },
1205 ResolvedModule {
1206 file_id: FileId(1),
1207 path: files[1].path.clone(),
1208 resolved_imports: vec![import(
1209 FileId(2),
1210 if unmasked_root_imports_export {
1211 ImportedName::Named("target".to_string())
1212 } else {
1213 ImportedName::SideEffect
1214 },
1215 )],
1216 ..ResolvedModule::default()
1217 },
1218 ResolvedModule {
1219 file_id: FileId(2),
1220 path: files[2].path.clone(),
1221 exports: vec![value_export("target", 0)].into(),
1222 ..ResolvedModule::default()
1223 },
1224 ];
1225 let test_entry_points = vec![
1226 EntryPoint {
1227 path: files[0].path.clone(),
1228 source: EntryPointSource::TestFile,
1229 },
1230 EntryPoint {
1231 path: files[1].path.clone(),
1232 source: EntryPointSource::TestFile,
1233 },
1234 ];
1235 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
1236 &modules,
1237 &[ResolvedReplacedModuleTarget {
1238 source_file: FileId(0),
1239 target_file: FileId(2),
1240 }],
1241 &test_entry_points,
1242 &[],
1243 &test_entry_points,
1244 &files,
1245 );
1246 RetainedModuleGraph::from(graph)
1247 }
1248
1249 #[test]
1250 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
1251 let graph = mixed_root_graph(false);
1252
1253 let exports = module_value_exports(&graph);
1254
1255 assert_eq!(exports.len(), 1);
1256 assert!(!exports[0].test_referenced);
1257 }
1258
1259 #[test]
1260 fn export_coverage_accepts_an_unmasked_correlated_reference() {
1261 let graph = mixed_root_graph(true);
1262
1263 let exports = module_value_exports(&graph);
1264
1265 assert_eq!(exports.len(), 1);
1266 assert!(exports[0].test_referenced);
1267 }
1268
1269 #[test]
1270 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
1271 let files: Vec<_> = (0..2)
1272 .map(|id| DiscoveredFile {
1273 id: FileId(id),
1274 path: PathBuf::from(format!("/project/file{id}.ts")),
1275 size_bytes: 1,
1276 })
1277 .collect();
1278 let modules = vec![
1279 ResolvedModule {
1280 file_id: FileId(0),
1281 path: files[0].path.clone(),
1282 resolved_imports: vec![
1283 import_with_mechanism(
1284 FileId(1),
1285 ImportedName::Named("esmOnly".to_string()),
1286 false,
1287 ),
1288 import_with_mechanism(
1289 FileId(1),
1290 ImportedName::Named("required".to_string()),
1291 true,
1292 ),
1293 ],
1294 ..ResolvedModule::default()
1295 },
1296 ResolvedModule {
1297 file_id: FileId(1),
1298 path: files[1].path.clone(),
1299 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
1300 ..ResolvedModule::default()
1301 },
1302 ];
1303 let test_entry_points = vec![EntryPoint {
1304 path: files[0].path.clone(),
1305 source: EntryPointSource::TestFile,
1306 }];
1307 let graph =
1308 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1309 &modules,
1310 &[ResolvedReplacedModuleTarget {
1311 source_file: FileId(0),
1312 target_file: FileId(1),
1313 }],
1314 &test_entry_points,
1315 &[],
1316 &test_entry_points,
1317 &files,
1318 ));
1319
1320 let exports = module_value_exports(&graph);
1321 let coverage: rustc_hash::FxHashMap<_, _> = exports
1322 .into_iter()
1323 .map(|export| (export.name, export.test_referenced))
1324 .collect();
1325
1326 assert_eq!(coverage.get("esmOnly"), Some(&false));
1327 assert_eq!(coverage.get("required"), Some(&true));
1328 }
1329
1330 #[test]
1331 fn type_only_test_import_does_not_credit_a_value_export() {
1332 let files: Vec<_> = (0..3)
1333 .map(|id| DiscoveredFile {
1334 id: FileId(id),
1335 path: PathBuf::from(format!("/project/file{id}.ts")),
1336 size_bytes: 1,
1337 })
1338 .collect();
1339 let mut type_only = import(FileId(1), ImportedName::Named("typed".to_string()));
1340 type_only.info.is_type_only = true;
1341 let modules = vec![
1342 ResolvedModule {
1343 file_id: FileId(0),
1344 path: files[0].path.clone(),
1345 resolved_imports: vec![
1346 type_only,
1347 import(FileId(2), ImportedName::Named("called".to_string())),
1348 ],
1349 ..ResolvedModule::default()
1350 },
1351 ResolvedModule {
1352 file_id: FileId(1),
1353 path: files[1].path.clone(),
1354 exports: vec![value_export("typed", 0)].into(),
1355 ..ResolvedModule::default()
1356 },
1357 ResolvedModule {
1358 file_id: FileId(2),
1359 path: files[2].path.clone(),
1360 exports: vec![value_export("called", 0)].into(),
1361 ..ResolvedModule::default()
1362 },
1363 ];
1364 let test_entry_points = vec![EntryPoint {
1365 path: files[0].path.clone(),
1366 source: EntryPointSource::TestFile,
1367 }];
1368 let graph =
1369 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1370 &modules,
1371 &[],
1372 &test_entry_points,
1373 &[],
1374 &test_entry_points,
1375 &files,
1376 ));
1377
1378 let coverage: rustc_hash::FxHashMap<_, _> = module_value_exports(&graph)
1379 .into_iter()
1380 .map(|export| (export.name, export.test_referenced))
1381 .collect();
1382
1383 assert_eq!(coverage.get("typed"), Some(&false));
1384 assert_eq!(coverage.get("called"), Some(&true));
1385 }
1386}