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_types::discover::FileId;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use fallow_graph::graph::{
14 CoordinationGapPaths as GraphCoordinationGapPaths,
15 FocusFileFactsPaths as GraphFocusFileFactsPaths, ImpactClosurePaths as GraphImpactClosurePaths,
16 ModuleGraph, PartitionOrderPaths as GraphPartitionOrderPaths,
17 ReviewUnitPaths as GraphReviewUnitPaths,
18};
19use fallow_graph::graph::{
20 DirectImporterSummary as GraphDirectImporterSummary,
21 ImportedSymbolSummary as GraphImportedSymbolSummary,
22};
23
24#[derive(Debug)]
29pub struct RetainedModuleGraph {
30 inner: ModuleGraph,
31}
32
33impl RetainedModuleGraph {
34 #[must_use]
36 const fn new(inner: ModuleGraph) -> Self {
37 Self { inner }
38 }
39
40 pub(crate) const fn as_graph(&self) -> &ModuleGraph {
41 &self.inner
42 }
43
44 pub(crate) const fn static_test_coverage(&self) -> StaticTestCoverage<'_> {
46 StaticTestCoverage::new(&self.inner)
47 }
48
49 #[must_use]
51 pub fn module_count(&self) -> usize {
52 self.inner.module_count()
53 }
54
55 #[must_use]
57 pub fn edge_count(&self) -> usize {
58 self.inner.edge_count()
59 }
60
61 #[must_use]
63 pub(crate) fn public_export_keys(
64 &self,
65 public_entries: &FxHashSet<FileId>,
66 root: &Path,
67 ) -> FxHashSet<String> {
68 self.inner.public_export_keys(public_entries, root)
69 }
70
71 #[must_use]
73 pub fn direct_importer_count(&self, file_id: FileId) -> usize {
74 self.inner
75 .reverse_deps
76 .get(file_id.0 as usize)
77 .map_or(0, Vec::len)
78 }
79
80 #[must_use]
82 pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
83 self.inner
84 .direct_importer_summaries(target)
85 .into_iter()
86 .map(DirectImporterSummary::from)
87 .collect()
88 }
89}
90
91#[derive(Clone, Copy)]
96pub(crate) struct StaticTestCoverage<'a> {
97 graph: &'a ModuleGraph,
98}
99
100impl<'a> StaticTestCoverage<'a> {
101 pub(crate) const fn new(graph: &'a ModuleGraph) -> Self {
102 Self { graph }
103 }
104
105 pub(crate) fn covers_file(self, file_id: FileId) -> bool {
106 self.graph.is_test_reachable(file_id)
107 }
108
109 pub(crate) fn covers_any_reference(self, export: &fallow_graph::graph::ExportSymbol) -> bool {
110 self.graph.is_any_test_reference_covered(export)
111 }
112}
113
114impl From<ModuleGraph> for RetainedModuleGraph {
115 fn from(inner: ModuleGraph) -> Self {
116 Self::new(inner)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct DirectImporterSummary {
123 pub source: FileId,
125 pub symbols: Vec<ImportedSymbolSummary>,
127}
128
129impl From<GraphDirectImporterSummary> for DirectImporterSummary {
130 fn from(summary: GraphDirectImporterSummary) -> Self {
131 Self {
132 source: summary.source,
133 symbols: summary.symbols.into_iter().map(Into::into).collect(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ImportedSymbolSummary {
141 pub imported: String,
143 pub local: String,
145 pub type_only: bool,
147}
148
149impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
150 fn from(symbol: GraphImportedSymbolSummary) -> Self {
151 Self {
152 imported: symbol.imported,
153 local: symbol.local,
154 type_only: symbol.type_only,
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ModuleValueExport {
162 pub file_id: FileId,
164 pub name: String,
166 pub span_start: u32,
168 pub test_referenced: bool,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct ImpactClosurePaths {
175 pub in_diff: Vec<String>,
177 pub affected_not_shown: Vec<String>,
179 pub coordination_gap: Vec<CoordinationGapPaths>,
181}
182
183impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
184 fn from(paths: GraphImpactClosurePaths) -> Self {
185 Self {
186 in_diff: paths.in_diff,
187 affected_not_shown: paths.affected_not_shown,
188 coordination_gap: paths
189 .coordination_gap
190 .into_iter()
191 .map(CoordinationGapPaths::from)
192 .collect(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct CoordinationGapPaths {
200 pub changed_file: String,
202 pub consumer_file: String,
204 pub consumed_symbols: Vec<String>,
206}
207
208impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
209 fn from(paths: GraphCoordinationGapPaths) -> Self {
210 Self {
211 changed_file: paths.changed_file,
212 consumer_file: paths.consumer_file,
213 consumed_symbols: paths.consumed_symbols,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct PartitionOrderPaths {
221 pub units: Vec<ReviewUnitPaths>,
223 pub order: Vec<String>,
225}
226
227impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
228 fn from(paths: GraphPartitionOrderPaths) -> Self {
229 Self {
230 units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
231 order: paths.order,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct ReviewUnitPaths {
239 pub module_dir: String,
241 pub files: Vec<String>,
243}
244
245impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
246 fn from(paths: GraphReviewUnitPaths) -> Self {
247 Self {
248 module_dir: paths.module_dir,
249 files: paths.files,
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct FocusFileFactsPaths {
257 pub file: String,
259 pub fan_in: u32,
262 pub fan_out: u32,
264 pub dynamic_dispatch: bool,
268 pub re_export_indirection: bool,
271}
272
273impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
274 fn from(paths: GraphFocusFileFactsPaths) -> Self {
275 Self {
276 file: paths.file,
277 fan_in: paths.fan_in,
278 fan_out: paths.fan_out,
279 dynamic_dispatch: paths.dynamic_dispatch,
280 re_export_indirection: paths.re_export_indirection,
281 }
282 }
283}
284
285#[must_use]
288pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
289 let test_coverage = graph.static_test_coverage();
290 let graph = graph.as_graph();
291
292 graph
293 .modules
294 .iter()
295 .flat_map(|node| {
296 node.exports
297 .iter()
298 .filter(|export| !export.is_type_only)
299 .map(|export| ModuleValueExport {
300 file_id: node.file_id,
301 name: export.name.to_string(),
302 span_start: export.span.start,
303 test_referenced: test_coverage.covers_any_reference(export),
304 })
305 })
306 .collect()
307}
308
309#[must_use]
311pub fn impact_closure_for_changed_paths(
312 graph: &RetainedModuleGraph,
313 root: &Path,
314 changed_files: &FxHashSet<PathBuf>,
315) -> Option<ImpactClosurePaths> {
316 let graph = graph.as_graph();
317 let changed_ids = changed_file_ids(graph, changed_files);
318 if changed_ids.is_empty() {
319 return None;
320 }
321
322 let closure = graph.impact_closure(&changed_ids);
323 Some(graph.closure_with_paths(&closure, root).into())
324}
325
326#[must_use]
328pub fn partition_order_for_changed_paths(
329 graph: &RetainedModuleGraph,
330 root: &Path,
331 changed_files: &FxHashSet<PathBuf>,
332) -> Option<PartitionOrderPaths> {
333 let graph = graph.as_graph();
334 let changed_ids = changed_file_ids(graph, changed_files);
335 if changed_ids.is_empty() {
336 return None;
337 }
338
339 let partition = graph.partition_order(&changed_ids);
340 Some(graph.partition_order_with_paths(&partition, root).into())
341}
342
343#[must_use]
345pub fn focus_facts_for_changed_paths(
346 graph: &RetainedModuleGraph,
347 root: &Path,
348 changed_files: &FxHashSet<PathBuf>,
349) -> Option<Vec<FocusFileFactsPaths>> {
350 let graph = graph.as_graph();
351 let changed_ids = changed_file_ids(graph, changed_files);
352 if changed_ids.is_empty() {
353 return None;
354 }
355
356 let facts = graph.focus_file_facts(&changed_ids);
357 Some(
358 graph
359 .focus_facts_with_paths(&facts, root)
360 .into_iter()
361 .map(FocusFileFactsPaths::from)
362 .collect(),
363 )
364}
365
366#[must_use]
368pub fn export_lines_for_changed_paths(
369 graph: &RetainedModuleGraph,
370 root: &Path,
371 changed_files: &FxHashSet<PathBuf>,
372) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
373 let graph = graph.as_graph();
374 let changed_norm = normalized_changed_paths(changed_files);
375 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
376 for module in &graph.modules {
377 let abs = normalize_path(&module.path);
378 if !changed_norm.contains(&abs) || module.exports.is_empty() {
379 continue;
380 }
381 let Ok(content) = std::fs::read_to_string(&module.path) else {
382 continue;
383 };
384 let offsets = fallow_types::extract::compute_line_offsets(&content);
385 let exports: Vec<(String, u32)> = module
386 .exports
387 .iter()
388 .map(|export| {
389 let (line, _) =
390 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
391 (export.name.to_string(), line)
392 })
393 .collect();
394 map.insert(relative_key_path(&module.path, root), exports);
395 }
396 Some(map)
397}
398
399#[must_use]
401pub fn internal_consumers_for_changed_paths(
402 graph: &RetainedModuleGraph,
403 root: &Path,
404 changed_files: &FxHashSet<PathBuf>,
405) -> Option<FxHashMap<String, u64>> {
406 let graph = graph.as_graph();
407 let changed_norm = normalized_changed_paths(changed_files);
408 let id_to_norm: FxHashMap<FileId, String> = graph
409 .modules
410 .iter()
411 .map(|module| (module.file_id, normalize_path(&module.path)))
412 .collect();
413
414 let mut map: FxHashMap<String, u64> = FxHashMap::default();
415 for module in &graph.modules {
416 let abs = normalize_path(&module.path);
417 if !changed_norm.contains(&abs) {
418 continue;
419 }
420 let count = graph
421 .importers_of(module.file_id)
422 .iter()
423 .filter(|imp| {
424 id_to_norm
425 .get(imp)
426 .is_none_or(|p| !changed_norm.contains(p))
427 })
428 .count() as u64;
429 map.insert(relative_key_path(&module.path, root), count);
430 }
431 Some(map)
432}
433
434fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
435 let path_to_id: FxHashMap<String, FileId> = graph
436 .modules
437 .iter()
438 .map(|module| (normalize_path(&module.path), module.file_id))
439 .collect();
440
441 changed_files
442 .iter()
443 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
444 .collect()
445}
446
447fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
448 changed_files
449 .iter()
450 .map(|path| normalize_path(path))
451 .collect()
452}
453
454fn normalize_path(path: &Path) -> String {
455 path.to_string_lossy().replace('\\', "/")
456}
457
458fn relative_key_path(path: &Path, root: &Path) -> String {
459 let simple_path = dunce::simplified(path);
460 let simple_root = dunce::simplified(root);
461 simple_path
462 .strip_prefix(simple_root)
463 .unwrap_or(simple_path)
464 .to_string_lossy()
465 .replace('\\', "/")
466}
467
468#[cfg(test)]
469mod tests {
470 use super::{RetainedModuleGraph, module_value_exports};
471 use fallow_graph::graph::ModuleGraph;
472 use fallow_graph::resolve::{
473 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
474 };
475 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
476 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
477 use std::path::PathBuf;
478
479 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
480 import_with_mechanism(target, imported_name, false)
481 }
482
483 fn import_with_mechanism(
484 target: FileId,
485 imported_name: ImportedName,
486 commonjs: bool,
487 ) -> ResolvedImport {
488 ResolvedImport {
489 info: ImportInfo {
490 source: "./target".to_string(),
491 imported_name,
492 local_name: "target".to_string(),
493 is_type_only: false,
494 is_type_only_star: false,
495 from_style: false,
496 span: oxc_span::Span::new(0, 10),
497 source_span: oxc_span::Span::default(),
498 },
499 target: if commonjs {
500 ResolveResult::CommonJsInternalModule(target)
501 } else {
502 ResolveResult::InternalModule(target)
503 },
504 }
505 }
506
507 fn value_export(name: &str, span_start: u32) -> ExportInfo {
508 ExportInfo {
509 name: ExportName::Named(name.to_string()),
510 local_name: Some(name.to_string()),
511 is_type_only: false,
512 visibility: VisibilityTag::None,
513 expected_unused_reason: None,
514 span: oxc_span::Span::new(span_start, span_start + 10),
515 members: Vec::new(),
516 is_side_effect_used: false,
517 super_class: None,
518 }
519 }
520
521 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
522 let files: Vec<_> = (0..3)
523 .map(|id| DiscoveredFile {
524 id: FileId(id),
525 path: PathBuf::from(format!("/project/file{id}.ts")),
526 size_bytes: 1,
527 })
528 .collect();
529 let modules = vec![
530 ResolvedModule {
531 file_id: FileId(0),
532 path: files[0].path.clone(),
533 resolved_imports: vec![import(
534 FileId(2),
535 ImportedName::Named("target".to_string()),
536 )],
537 ..ResolvedModule::default()
538 },
539 ResolvedModule {
540 file_id: FileId(1),
541 path: files[1].path.clone(),
542 resolved_imports: vec![import(
543 FileId(2),
544 if unmasked_root_imports_export {
545 ImportedName::Named("target".to_string())
546 } else {
547 ImportedName::SideEffect
548 },
549 )],
550 ..ResolvedModule::default()
551 },
552 ResolvedModule {
553 file_id: FileId(2),
554 path: files[2].path.clone(),
555 exports: vec![value_export("target", 0)].into(),
556 ..ResolvedModule::default()
557 },
558 ];
559 let test_entry_points = vec![
560 EntryPoint {
561 path: files[0].path.clone(),
562 source: EntryPointSource::TestFile,
563 },
564 EntryPoint {
565 path: files[1].path.clone(),
566 source: EntryPointSource::TestFile,
567 },
568 ];
569 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
570 &modules,
571 &[ResolvedReplacedModuleTarget {
572 source_file: FileId(0),
573 target_file: FileId(2),
574 }],
575 &test_entry_points,
576 &[],
577 &test_entry_points,
578 &files,
579 );
580 RetainedModuleGraph::from(graph)
581 }
582
583 #[test]
584 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
585 let graph = mixed_root_graph(false);
586
587 let exports = module_value_exports(&graph);
588
589 assert_eq!(exports.len(), 1);
590 assert!(!exports[0].test_referenced);
591 }
592
593 #[test]
594 fn export_coverage_accepts_an_unmasked_correlated_reference() {
595 let graph = mixed_root_graph(true);
596
597 let exports = module_value_exports(&graph);
598
599 assert_eq!(exports.len(), 1);
600 assert!(exports[0].test_referenced);
601 }
602
603 #[test]
604 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
605 let files: Vec<_> = (0..2)
606 .map(|id| DiscoveredFile {
607 id: FileId(id),
608 path: PathBuf::from(format!("/project/file{id}.ts")),
609 size_bytes: 1,
610 })
611 .collect();
612 let modules = vec![
613 ResolvedModule {
614 file_id: FileId(0),
615 path: files[0].path.clone(),
616 resolved_imports: vec![
617 import_with_mechanism(
618 FileId(1),
619 ImportedName::Named("esmOnly".to_string()),
620 false,
621 ),
622 import_with_mechanism(
623 FileId(1),
624 ImportedName::Named("required".to_string()),
625 true,
626 ),
627 ],
628 ..ResolvedModule::default()
629 },
630 ResolvedModule {
631 file_id: FileId(1),
632 path: files[1].path.clone(),
633 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
634 ..ResolvedModule::default()
635 },
636 ];
637 let test_entry_points = vec![EntryPoint {
638 path: files[0].path.clone(),
639 source: EntryPointSource::TestFile,
640 }];
641 let graph =
642 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
643 &modules,
644 &[ResolvedReplacedModuleTarget {
645 source_file: FileId(0),
646 target_file: FileId(1),
647 }],
648 &test_entry_points,
649 &[],
650 &test_entry_points,
651 &files,
652 ));
653
654 let exports = module_value_exports(&graph);
655 let coverage: rustc_hash::FxHashMap<_, _> = exports
656 .into_iter()
657 .map(|export| (export.name, export.test_referenced))
658 .collect();
659
660 assert_eq!(coverage.get("esmOnly"), Some(&false));
661 assert_eq!(coverage.get("required"), Some(&true));
662 }
663}