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)]
101pub(crate) struct StaticTestCoverage<'a> {
102 graph: &'a ModuleGraph,
103}
104
105impl<'a> StaticTestCoverage<'a> {
106 pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
107 Self { graph }
108 }
109
110 pub(crate) fn covers_file(self, file_id: FileId) -> bool {
111 self.graph.is_test_reachable(file_id)
112 }
113
114 pub(crate) fn covers_any_reference(self, export: &fallow_graph::graph::ExportSymbol) -> bool {
115 self.graph.is_any_test_reference_covered(export)
116 }
117
118 pub(crate) fn is_mocked_only(
121 self,
122 file_id: FileId,
123 export: &fallow_graph::graph::ExportSymbol,
124 ) -> bool {
125 self.graph.test_masking_state() == GraphTestMaskingState::Active
126 && self
127 .graph
128 .export_test_reach(file_id, export, &|_| false)
129 .reach
130 == GraphExportTestReach::MockedOnly
131 }
132}
133
134impl From<ModuleGraph> for RetainedModuleGraph {
135 fn from(inner: ModuleGraph) -> Self {
136 Self::new(inner)
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct DirectImporterSummary {
143 pub source: FileId,
145 pub symbols: Vec<ImportedSymbolSummary>,
147}
148
149impl From<GraphDirectImporterSummary> for DirectImporterSummary {
150 fn from(summary: GraphDirectImporterSummary) -> Self {
151 Self {
152 source: summary.source,
153 symbols: summary.symbols.into_iter().map(Into::into).collect(),
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ImportedSymbolSummary {
161 pub imported: String,
163 pub local: String,
165 pub type_only: bool,
167}
168
169impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
170 fn from(symbol: GraphImportedSymbolSummary) -> Self {
171 Self {
172 imported: symbol.imported,
173 local: symbol.local,
174 type_only: symbol.type_only,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ModuleValueExport {
182 pub file_id: FileId,
184 pub name: String,
186 pub span_start: u32,
188 pub test_referenced: bool,
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
194pub struct ImpactClosurePaths {
195 pub in_diff: Vec<String>,
197 pub affected_not_shown: Vec<String>,
199 pub coordination_gap: Vec<CoordinationGapPaths>,
201}
202
203impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
204 fn from(paths: GraphImpactClosurePaths) -> Self {
205 Self {
206 in_diff: paths.in_diff,
207 affected_not_shown: paths.affected_not_shown,
208 coordination_gap: paths
209 .coordination_gap
210 .into_iter()
211 .map(CoordinationGapPaths::from)
212 .collect(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct CoordinationGapPaths {
220 pub changed_file: String,
222 pub consumer_file: String,
224 pub consumed_symbols: Vec<String>,
226}
227
228impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
229 fn from(paths: GraphCoordinationGapPaths) -> Self {
230 Self {
231 changed_file: paths.changed_file,
232 consumer_file: paths.consumer_file,
233 consumed_symbols: paths.consumed_symbols,
234 }
235 }
236}
237
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub struct PartitionOrderPaths {
241 pub units: Vec<ReviewUnitPaths>,
243 pub order: Vec<String>,
245 pub independent_slices: Vec<Vec<String>>,
248}
249
250impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
251 fn from(paths: GraphPartitionOrderPaths) -> Self {
252 Self {
253 units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
254 order: paths.order,
255 independent_slices: paths.independent_slices,
256 }
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct ReviewUnitPaths {
263 pub module_dir: String,
265 pub files: Vec<String>,
267}
268
269impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
270 fn from(paths: GraphReviewUnitPaths) -> Self {
271 Self {
272 module_dir: paths.module_dir,
273 files: paths.files,
274 }
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct FocusFileFactsPaths {
281 pub file: String,
283 pub fan_in: u32,
286 pub fan_out: u32,
288 pub dynamic_dispatch: bool,
292 pub re_export_indirection: bool,
295}
296
297impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
298 fn from(paths: GraphFocusFileFactsPaths) -> Self {
299 Self {
300 file: paths.file,
301 fan_in: paths.fan_in,
302 fan_out: paths.fan_out,
303 dynamic_dispatch: paths.dynamic_dispatch,
304 re_export_indirection: paths.re_export_indirection,
305 }
306 }
307}
308
309#[must_use]
312pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
313 let test_coverage = graph.static_test_coverage();
314 let graph = graph.as_graph();
315
316 graph
317 .modules
318 .iter()
319 .flat_map(|node| {
320 node.exports
321 .iter()
322 .filter(|export| !export.is_type_only)
323 .map(|export| ModuleValueExport {
324 file_id: node.file_id,
325 name: export.name.to_string(),
326 span_start: export.span.start,
327 test_referenced: test_coverage.covers_any_reference(export),
328 })
329 })
330 .collect()
331}
332
333#[must_use]
335pub fn impact_closure_for_changed_paths(
336 graph: &RetainedModuleGraph,
337 root: &Path,
338 changed_files: &FxHashSet<PathBuf>,
339) -> Option<ImpactClosurePaths> {
340 let graph = graph.as_graph();
341 let changed_ids = changed_file_ids(graph, changed_files);
342 if changed_ids.is_empty() {
343 return None;
344 }
345
346 let closure = graph.impact_closure(&changed_ids);
347 Some(graph.closure_with_paths(&closure, root).into())
348}
349
350#[must_use]
352pub fn partition_order_for_changed_paths(
353 graph: &RetainedModuleGraph,
354 root: &Path,
355 changed_files: &FxHashSet<PathBuf>,
356) -> Option<PartitionOrderPaths> {
357 let graph = graph.as_graph();
358 let changed_ids = changed_file_ids(graph, changed_files);
359 if changed_ids.is_empty() {
360 return None;
361 }
362
363 let partition = graph.partition_order(&changed_ids);
364 Some(graph.partition_order_with_paths(&partition, root).into())
365}
366
367#[must_use]
369pub fn focus_facts_for_changed_paths(
370 graph: &RetainedModuleGraph,
371 root: &Path,
372 changed_files: &FxHashSet<PathBuf>,
373) -> Option<Vec<FocusFileFactsPaths>> {
374 let graph = graph.as_graph();
375 let changed_ids = changed_file_ids(graph, changed_files);
376 if changed_ids.is_empty() {
377 return None;
378 }
379
380 let facts = graph.focus_file_facts(&changed_ids);
381 Some(
382 graph
383 .focus_facts_with_paths(&facts, root)
384 .into_iter()
385 .map(FocusFileFactsPaths::from)
386 .collect(),
387 )
388}
389
390#[must_use]
392pub fn export_lines_for_changed_paths(
393 graph: &RetainedModuleGraph,
394 root: &Path,
395 changed_files: &FxHashSet<PathBuf>,
396) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
397 let graph = graph.as_graph();
398 let changed_norm = normalized_changed_paths(changed_files);
399 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
400 for module in &graph.modules {
401 let abs = normalize_path(&module.path);
402 if !changed_norm.contains(&abs) || module.exports.is_empty() {
403 continue;
404 }
405 let Ok(content) = std::fs::read_to_string(&module.path) else {
406 continue;
407 };
408 let offsets = fallow_types::extract::compute_line_offsets(&content);
409 let exports: Vec<(String, u32)> = module
410 .exports
411 .iter()
412 .map(|export| {
413 let (line, _) =
414 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
415 (export.name.to_string(), line)
416 })
417 .collect();
418 map.insert(relative_key_path(&module.path, root), exports);
419 }
420 Some(map)
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct ConsumerSearchGapPaths {
427 pub consumer_file: String,
429 pub check: crate::search_visibility::SearchCheck,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct ExportSearchGapPaths {
436 pub file: String,
438 pub export_name: String,
440 pub line: u32,
442 pub declaration_lines: (u32, u32),
445 pub consumers: Vec<ConsumerSearchGapPaths>,
448}
449
450#[must_use]
453pub fn export_names_in_source(path: &Path, source: &str) -> FxHashSet<String> {
454 let source = source.strip_prefix('\u{feff}').unwrap_or(source);
455 let module = fallow_extract::parse_source_to_module(FileId(0), path, source, 0, false);
456 module
457 .exports
458 .iter()
459 .map(|export| export.name.to_string())
460 .collect()
461}
462
463#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct ChangedExport {
470 file_id: FileId,
471 export_index: usize,
472 pub file: String,
474 pub export_name: String,
476 pub line: u32,
478 pub declaration_lines: (u32, u32),
481 pub is_type_only: bool,
483 pub literal_value: bool,
486 pub is_re_export: bool,
488}
489
490#[must_use]
496pub fn changed_exports_for_changed_paths(
497 graph: &RetainedModuleGraph,
498 root: &Path,
499 changed_files: &FxHashSet<PathBuf>,
500) -> Option<Vec<ChangedExport>> {
501 let graph = graph.as_graph();
502 let mut changed_ids = changed_file_ids(graph, changed_files);
503 if changed_ids.is_empty() {
504 return None;
505 }
506 changed_ids.sort_unstable_by_key(|id| id.0);
507 let mut changed_exports = Vec::new();
508 for file_id in changed_ids {
509 let Some(module) = graph.modules.get(file_id.0 as usize) else {
510 continue;
511 };
512 if module.exports.is_empty() {
513 continue;
514 }
515 let Ok(source) = std::fs::read_to_string(&module.path) else {
516 continue;
517 };
518 changed_exports.extend(module_exports(graph, root, file_id, &source));
519 }
520 Some(changed_exports)
521}
522
523pub(crate) fn module_exports(
526 graph: &ModuleGraph,
527 root: &Path,
528 file_id: FileId,
529 source: &str,
530) -> Vec<ChangedExport> {
531 let Some(module) = graph.modules.get(file_id.0 as usize) else {
532 return Vec::new();
533 };
534 if module.exports.is_empty() {
535 return Vec::new();
536 }
537 let offsets = fallow_types::extract::compute_line_offsets(source);
538 let starts: Vec<u32> = module
539 .exports
540 .iter()
541 .map(|export| export.span.start)
542 .collect();
543 let declarations = crate::search_visibility::export_declarations(&module.path, source, &starts);
544 let file = relative_key_path(&module.path, root);
545 module
546 .exports
547 .iter()
548 .enumerate()
549 .map(|(export_index, export)| {
550 let line =
551 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start).0;
552 let declaration = declarations.get(&export.span.start);
553 ChangedExport {
554 file_id,
555 export_index,
556 file: file.clone(),
557 export_name: export.name.to_string(),
558 line,
559 declaration_lines: declaration.map_or((line, line), |found| found.lines),
560 is_type_only: export.is_type_only,
561 literal_value: declaration.is_some_and(|found| found.literal_value),
562 is_re_export: declaration.is_some_and(|found| found.is_re_export),
563 }
564 })
565 .collect()
566}
567
568#[must_use]
575pub fn search_gaps_for_changed_paths(
576 graph: &RetainedModuleGraph,
577 root: &Path,
578 changed_files: &FxHashSet<PathBuf>,
579 changed_exports: &[ChangedExport],
580) -> Option<Vec<ExportSearchGapPaths>> {
581 use crate::search_visibility::{SearchTarget, SourceCache};
582 use fallow_graph::graph::ExportNamespace;
583 use fallow_types::trace::SearchVisibility;
584
585 let graph = graph.as_graph();
586 let changed_set: FxHashSet<FileId> =
587 changed_file_ids(graph, changed_files).into_iter().collect();
588 if changed_set.is_empty() {
589 return None;
590 }
591 let mut sources = SourceCache::new(graph);
592 let mut gaps = Vec::new();
593 for changed in changed_exports {
594 let Some(export) = graph
595 .modules
596 .get(changed.file_id.0 as usize)
597 .and_then(|module| module.exports.get(changed.export_index))
598 else {
599 continue;
600 };
601 let mut seen = FxHashSet::default();
602 let candidates: Vec<_> = export
603 .references
604 .iter()
605 .filter(|reference| {
606 !changed_set.contains(&reference.from_file)
607 && graph
608 .modules
609 .get(reference.from_file.0 as usize)
610 .is_some_and(fallow_graph::graph::ModuleNode::is_reachable)
611 && seen.insert(reference.from_file)
612 })
613 .collect();
614 if candidates.is_empty() {
615 continue;
616 }
617 let namespace = if export.is_type_only {
618 ExportNamespace::Type
619 } else {
620 ExportNamespace::Value
621 };
622 let target = SearchTarget::new(
623 graph,
624 &mut sources,
625 changed.file_id,
626 &changed.export_name,
627 namespace,
628 );
629 let mut consumers: Vec<ConsumerSearchGapPaths> = candidates
630 .into_iter()
631 .filter_map(|reference| {
632 let check = target.check(graph, &mut sources, reference)?;
633 if check.visibility == SearchVisibility::Found {
634 return None;
635 }
636 let consumer = graph.modules.get(reference.from_file.0 as usize)?;
637 Some(ConsumerSearchGapPaths {
638 consumer_file: relative_key_path(&consumer.path, root),
639 check,
640 })
641 })
642 .collect();
643 if consumers.is_empty() {
644 continue;
645 }
646 consumers.sort_by(|a, b| a.consumer_file.cmp(&b.consumer_file));
647 gaps.push(ExportSearchGapPaths {
648 file: changed.file.clone(),
649 export_name: changed.export_name.clone(),
650 line: changed.line,
651 declaration_lines: changed.declaration_lines,
652 consumers,
653 });
654 }
655 Some(gaps)
656}
657
658const E2E_RUNNER_PACKAGES: &[&str] = &["@playwright/test", "playwright", "cypress"];
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
664#[serde(rename_all = "kebab-case")]
665pub enum ExportTestReach {
666 Direct,
668 Transitive,
670 MockedOnly,
673 None,
675 NoneFile,
677}
678
679impl From<GraphExportTestReach> for ExportTestReach {
680 fn from(reach: GraphExportTestReach) -> Self {
681 match reach {
682 GraphExportTestReach::Direct => Self::Direct,
683 GraphExportTestReach::Transitive => Self::Transitive,
684 GraphExportTestReach::MockedOnly => Self::MockedOnly,
685 GraphExportTestReach::None => Self::None,
686 GraphExportTestReach::NoneFile => Self::NoneFile,
687 }
688 }
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
693pub enum TestMaskingState {
694 Active,
696 NoMocks,
698 Capped,
700}
701
702impl From<GraphTestMaskingState> for TestMaskingState {
703 fn from(state: GraphTestMaskingState) -> Self {
704 match state {
705 GraphTestMaskingState::Active => Self::Active,
706 GraphTestMaskingState::NoMocks => Self::NoMocks,
707 GraphTestMaskingState::Capped => Self::Capped,
708 }
709 }
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct ExportTestReachPaths {
715 pub file: String,
717 pub export_name: String,
719 pub line: u32,
721 pub declaration_lines: (u32, u32),
723 pub literal_value: bool,
725 pub reach: ExportTestReach,
727 pub tests: Vec<String>,
730 pub mocked_targets: Vec<String>,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq)]
736pub struct TestReachPaths {
737 pub has_tests: bool,
740 pub masking: TestMaskingState,
742 pub has_e2e_runner: bool,
744 pub exports: Vec<ExportTestReachPaths>,
746}
747
748#[must_use]
756pub fn test_reach_for_changed_paths(
757 graph: &RetainedModuleGraph,
758 root: &Path,
759 changed_exports: &[ChangedExport],
760 is_test_path: &dyn Fn(&str) -> bool,
761) -> Option<TestReachPaths> {
762 let graph = graph.as_graph();
763 let mut test_files: FxHashMap<FileId, bool> = FxHashMap::default();
764 let mut is_test_file = |file_id: FileId| -> bool {
765 *test_files.entry(file_id).or_insert_with(|| {
766 graph.test_entry_points.contains(&file_id)
767 || graph
768 .modules
769 .get(file_id.0 as usize)
770 .is_some_and(|module| is_test_path(&relative_key_path(&module.path, root)))
771 })
772 };
773 let has_tests = !graph.test_entry_points.is_empty();
774 let exports: Vec<ExportTestReachPaths> = changed_exports
775 .iter()
776 .filter_map(|changed| classify_export_test_reach(graph, root, changed, &mut is_test_file))
777 .collect();
778 if exports.is_empty() {
779 return None;
780 }
781 let has_e2e_runner = E2E_RUNNER_PACKAGES.iter().any(|name| {
782 graph.package_usage.contains_key(*name) || graph.type_only_package_usage.contains_key(*name)
783 });
784 Some(TestReachPaths {
785 has_tests,
786 masking: graph.test_masking_state().into(),
787 has_e2e_runner,
788 exports,
789 })
790}
791
792pub(crate) fn classify_export_test_reach(
797 graph: &ModuleGraph,
798 root: &Path,
799 changed: &ChangedExport,
800 is_test_file: &mut dyn FnMut(FileId) -> bool,
801) -> Option<ExportTestReachPaths> {
802 if changed.is_type_only || changed.is_re_export {
803 return None;
804 }
805 let module = graph.modules.get(changed.file_id.0 as usize)?;
806 let export = module.exports.get(changed.export_index)?;
807 if !is_reach_subject(&module.path) || is_test_file(changed.file_id) {
808 return None;
809 }
810 let result = if graph.test_entry_points.is_empty() {
811 GraphExportTestReachResult {
812 reach: GraphExportTestReach::NoneFile,
813 tests: Vec::new(),
814 mocked_targets: Vec::new(),
815 }
816 } else {
817 let test_flags: FxHashMap<FileId, bool> = export
818 .references
819 .iter()
820 .map(|reference| (reference.from_file, is_test_file(reference.from_file)))
821 .collect();
822 graph.export_test_reach(changed.file_id, export, &|file_id| {
823 test_flags.get(&file_id).copied().unwrap_or(false)
824 })
825 };
826 let paths_of = |files: &[FileId]| -> Vec<String> {
827 let mut paths: Vec<String> = files
828 .iter()
829 .filter_map(|file_id| graph.modules.get(file_id.0 as usize))
830 .map(|node| relative_key_path(&node.path, root))
831 .collect();
832 paths.sort_unstable();
833 paths
834 };
835 Some(ExportTestReachPaths {
836 file: changed.file.clone(),
837 export_name: changed.export_name.clone(),
838 line: changed.line,
839 declaration_lines: changed.declaration_lines,
840 literal_value: changed.literal_value,
841 reach: result.reach.into(),
842 tests: paths_of(&result.tests),
843 mocked_targets: paths_of(&result.mocked_targets),
844 })
845}
846
847fn is_reach_subject(path: &Path) -> bool {
850 if fallow_graph::graph::is_declaration_file_path(path) {
851 return false;
852 }
853 !path
854 .extension()
855 .and_then(|extension| extension.to_str())
856 .is_some_and(|extension| matches!(extension, "css" | "scss" | "less" | "sass"))
857}
858
859#[must_use]
861pub fn internal_consumers_for_changed_paths(
862 graph: &RetainedModuleGraph,
863 root: &Path,
864 changed_files: &FxHashSet<PathBuf>,
865) -> Option<FxHashMap<String, u64>> {
866 let graph = graph.as_graph();
867 let changed_norm = normalized_changed_paths(changed_files);
868 let id_to_norm: FxHashMap<FileId, String> = graph
869 .modules
870 .iter()
871 .map(|module| (module.file_id, normalize_path(&module.path)))
872 .collect();
873
874 let mut map: FxHashMap<String, u64> = FxHashMap::default();
875 for module in &graph.modules {
876 let abs = normalize_path(&module.path);
877 if !changed_norm.contains(&abs) {
878 continue;
879 }
880 let count = graph
881 .importers_of(module.file_id)
882 .iter()
883 .filter(|imp| {
884 id_to_norm
885 .get(imp)
886 .is_none_or(|p| !changed_norm.contains(p))
887 })
888 .count() as u64;
889 map.insert(relative_key_path(&module.path, root), count);
890 }
891 Some(map)
892}
893
894#[must_use]
901pub fn test_adjacency_for_changed_paths(
902 graph: &RetainedModuleGraph,
903 root: &Path,
904 changed_files: &FxHashSet<PathBuf>,
905 is_test_path: &dyn Fn(&str) -> bool,
906) -> Option<FxHashMap<String, TestAdjacency>> {
907 let graph = graph.as_graph();
908 if !graph
911 .modules
912 .iter()
913 .any(|module| is_test_path(&relative_key_path(&module.path, root)))
914 {
915 return None;
916 }
917 let changed_norm = normalized_changed_paths(changed_files);
918 let mut map: FxHashMap<String, TestAdjacency> = FxHashMap::default();
919 for module in &graph.modules {
920 if !changed_norm.contains(&normalize_path(&module.path)) {
921 continue;
922 }
923 let rel = relative_key_path(&module.path, root);
924 if is_test_path(&rel) {
925 continue;
926 }
927 let mut adjacency = TestAdjacency::None;
928 for importer in graph.importers_of(module.file_id) {
929 let Some(node) = graph.modules.get(importer.0 as usize) else {
930 continue;
931 };
932 if !is_test_path(&relative_key_path(&node.path, root)) {
933 continue;
934 }
935 if changed_norm.contains(&normalize_path(&node.path)) {
936 adjacency = TestAdjacency::Changed;
937 break;
938 }
939 adjacency = TestAdjacency::Untouched;
940 }
941 map.insert(rel, adjacency);
942 }
943 if map.is_empty() {
944 return None;
945 }
946 Some(map)
947}
948
949#[derive(Debug, Clone, Default, PartialEq, Eq)]
951pub struct PackageImporters {
952 pub importers: Vec<FileId>,
957 pub out_of_diff: Vec<FileId>,
959}
960
961#[must_use]
969pub fn package_importers_for_changed_paths(
970 graph: &RetainedModuleGraph,
971 changed_files: &FxHashSet<PathBuf>,
972) -> Option<FxHashMap<String, PackageImporters>> {
973 let graph = graph.as_graph();
974 if graph.package_usage.is_empty() && graph.type_only_package_usage.is_empty() {
975 return None;
976 }
977 let changed_norm = normalized_changed_paths(changed_files);
978 let id_to_norm: FxHashMap<FileId, String> = graph
979 .modules
980 .iter()
981 .map(|module| (module.file_id, normalize_path(&module.path)))
982 .collect();
983 let mut usage: FxHashMap<&str, Vec<FileId>> = FxHashMap::default();
984 for (package, files) in graph
985 .package_usage
986 .iter()
987 .chain(graph.type_only_package_usage.iter())
988 {
989 usage
990 .entry(package.as_str())
991 .or_default()
992 .extend(files.iter().copied());
993 }
994 let map = usage
995 .into_iter()
996 .map(|(package, files)| {
997 let mut importers: Vec<FileId> = files;
998 importers.sort_unstable_by_key(|id| id.0);
999 importers.dedup();
1000 let out_of_diff: Vec<FileId> = importers
1001 .iter()
1002 .copied()
1003 .filter(|id| id_to_norm.get(id).is_none_or(|p| !changed_norm.contains(p)))
1004 .collect();
1005 (
1006 package.to_string(),
1007 PackageImporters {
1008 importers,
1009 out_of_diff,
1010 },
1011 )
1012 })
1013 .collect();
1014 Some(map)
1015}
1016
1017fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
1018 let path_to_id: FxHashMap<String, FileId> = graph
1019 .modules
1020 .iter()
1021 .map(|module| (normalize_path(&module.path), module.file_id))
1022 .collect();
1023
1024 changed_files
1025 .iter()
1026 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
1027 .collect()
1028}
1029
1030fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
1031 changed_files
1032 .iter()
1033 .map(|path| normalize_path(path))
1034 .collect()
1035}
1036
1037fn normalize_path(path: &Path) -> String {
1038 path.to_string_lossy().replace('\\', "/")
1039}
1040
1041pub fn relative_key_path(path: &Path, root: &Path) -> String {
1049 let simple_path = dunce::simplified(path);
1050 let simple_root = dunce::simplified(root);
1051 simple_path
1052 .strip_prefix(simple_root)
1053 .unwrap_or(simple_path)
1054 .to_string_lossy()
1055 .replace('\\', "/")
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::{RetainedModuleGraph, module_value_exports};
1061 use fallow_graph::graph::ModuleGraph;
1062 use fallow_graph::resolve::{
1063 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
1064 };
1065 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1066 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1067 use std::path::PathBuf;
1068
1069 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
1070 import_with_mechanism(target, imported_name, false)
1071 }
1072
1073 fn import_with_mechanism(
1074 target: FileId,
1075 imported_name: ImportedName,
1076 commonjs: bool,
1077 ) -> ResolvedImport {
1078 ResolvedImport {
1079 info: ImportInfo {
1080 source: "./target".to_string(),
1081 imported_name,
1082 local_name: "target".to_string(),
1083 is_type_only: false,
1084 is_type_only_star: false,
1085 from_style: false,
1086 span: oxc_span::Span::new(0, 10),
1087 source_span: oxc_span::Span::default(),
1088 },
1089 target: if commonjs {
1090 ResolveResult::CommonJsInternalModule(target)
1091 } else {
1092 ResolveResult::InternalModule(target)
1093 },
1094 }
1095 }
1096
1097 fn value_export(name: &str, span_start: u32) -> ExportInfo {
1098 ExportInfo {
1099 name: ExportName::Named(name.to_string()),
1100 local_name: Some(name.to_string()),
1101 is_type_only: false,
1102 visibility: VisibilityTag::None,
1103 expected_unused_reason: None,
1104 span: oxc_span::Span::new(span_start, span_start + 10),
1105 members: Vec::new(),
1106 is_side_effect_used: false,
1107 super_class: None,
1108 deprecated: false,
1109 deprecated_reason: None,
1110 }
1111 }
1112
1113 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
1114 let files: Vec<_> = (0..3)
1115 .map(|id| DiscoveredFile {
1116 id: FileId(id),
1117 path: PathBuf::from(format!("/project/file{id}.ts")),
1118 size_bytes: 1,
1119 })
1120 .collect();
1121 let modules = vec![
1122 ResolvedModule {
1123 file_id: FileId(0),
1124 path: files[0].path.clone(),
1125 resolved_imports: vec![import(
1126 FileId(2),
1127 ImportedName::Named("target".to_string()),
1128 )],
1129 ..ResolvedModule::default()
1130 },
1131 ResolvedModule {
1132 file_id: FileId(1),
1133 path: files[1].path.clone(),
1134 resolved_imports: vec![import(
1135 FileId(2),
1136 if unmasked_root_imports_export {
1137 ImportedName::Named("target".to_string())
1138 } else {
1139 ImportedName::SideEffect
1140 },
1141 )],
1142 ..ResolvedModule::default()
1143 },
1144 ResolvedModule {
1145 file_id: FileId(2),
1146 path: files[2].path.clone(),
1147 exports: vec![value_export("target", 0)].into(),
1148 ..ResolvedModule::default()
1149 },
1150 ];
1151 let test_entry_points = vec![
1152 EntryPoint {
1153 path: files[0].path.clone(),
1154 source: EntryPointSource::TestFile,
1155 },
1156 EntryPoint {
1157 path: files[1].path.clone(),
1158 source: EntryPointSource::TestFile,
1159 },
1160 ];
1161 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
1162 &modules,
1163 &[ResolvedReplacedModuleTarget {
1164 source_file: FileId(0),
1165 target_file: FileId(2),
1166 }],
1167 &test_entry_points,
1168 &[],
1169 &test_entry_points,
1170 &files,
1171 );
1172 RetainedModuleGraph::from(graph)
1173 }
1174
1175 #[test]
1176 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
1177 let graph = mixed_root_graph(false);
1178
1179 let exports = module_value_exports(&graph);
1180
1181 assert_eq!(exports.len(), 1);
1182 assert!(!exports[0].test_referenced);
1183 }
1184
1185 #[test]
1186 fn export_coverage_accepts_an_unmasked_correlated_reference() {
1187 let graph = mixed_root_graph(true);
1188
1189 let exports = module_value_exports(&graph);
1190
1191 assert_eq!(exports.len(), 1);
1192 assert!(exports[0].test_referenced);
1193 }
1194
1195 #[test]
1196 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
1197 let files: Vec<_> = (0..2)
1198 .map(|id| DiscoveredFile {
1199 id: FileId(id),
1200 path: PathBuf::from(format!("/project/file{id}.ts")),
1201 size_bytes: 1,
1202 })
1203 .collect();
1204 let modules = vec![
1205 ResolvedModule {
1206 file_id: FileId(0),
1207 path: files[0].path.clone(),
1208 resolved_imports: vec![
1209 import_with_mechanism(
1210 FileId(1),
1211 ImportedName::Named("esmOnly".to_string()),
1212 false,
1213 ),
1214 import_with_mechanism(
1215 FileId(1),
1216 ImportedName::Named("required".to_string()),
1217 true,
1218 ),
1219 ],
1220 ..ResolvedModule::default()
1221 },
1222 ResolvedModule {
1223 file_id: FileId(1),
1224 path: files[1].path.clone(),
1225 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
1226 ..ResolvedModule::default()
1227 },
1228 ];
1229 let test_entry_points = vec![EntryPoint {
1230 path: files[0].path.clone(),
1231 source: EntryPointSource::TestFile,
1232 }];
1233 let graph =
1234 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1235 &modules,
1236 &[ResolvedReplacedModuleTarget {
1237 source_file: FileId(0),
1238 target_file: FileId(1),
1239 }],
1240 &test_entry_points,
1241 &[],
1242 &test_entry_points,
1243 &files,
1244 ));
1245
1246 let exports = module_value_exports(&graph);
1247 let coverage: rustc_hash::FxHashMap<_, _> = exports
1248 .into_iter()
1249 .map(|export| (export.name, export.test_referenced))
1250 .collect();
1251
1252 assert_eq!(coverage.get("esmOnly"), Some(&false));
1253 assert_eq!(coverage.get("required"), Some(&true));
1254 }
1255
1256 #[test]
1257 fn type_only_test_import_does_not_credit_a_value_export() {
1258 let files: Vec<_> = (0..3)
1259 .map(|id| DiscoveredFile {
1260 id: FileId(id),
1261 path: PathBuf::from(format!("/project/file{id}.ts")),
1262 size_bytes: 1,
1263 })
1264 .collect();
1265 let mut type_only = import(FileId(1), ImportedName::Named("typed".to_string()));
1266 type_only.info.is_type_only = true;
1267 let modules = vec![
1268 ResolvedModule {
1269 file_id: FileId(0),
1270 path: files[0].path.clone(),
1271 resolved_imports: vec![
1272 type_only,
1273 import(FileId(2), ImportedName::Named("called".to_string())),
1274 ],
1275 ..ResolvedModule::default()
1276 },
1277 ResolvedModule {
1278 file_id: FileId(1),
1279 path: files[1].path.clone(),
1280 exports: vec![value_export("typed", 0)].into(),
1281 ..ResolvedModule::default()
1282 },
1283 ResolvedModule {
1284 file_id: FileId(2),
1285 path: files[2].path.clone(),
1286 exports: vec![value_export("called", 0)].into(),
1287 ..ResolvedModule::default()
1288 },
1289 ];
1290 let test_entry_points = vec![EntryPoint {
1291 path: files[0].path.clone(),
1292 source: EntryPointSource::TestFile,
1293 }];
1294 let graph =
1295 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
1296 &modules,
1297 &[],
1298 &test_entry_points,
1299 &[],
1300 &test_entry_points,
1301 &files,
1302 ));
1303
1304 let coverage: rustc_hash::FxHashMap<_, _> = module_value_exports(&graph)
1305 .into_iter()
1306 .map(|export| (export.name, export.test_referenced))
1307 .collect();
1308
1309 assert_eq!(coverage.get("typed"), Some(&false));
1310 assert_eq!(coverage.get("called"), Some(&true));
1311 }
1312}