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 from_style: false,
495 span: oxc_span::Span::new(0, 10),
496 source_span: oxc_span::Span::default(),
497 },
498 target: if commonjs {
499 ResolveResult::CommonJsInternalModule(target)
500 } else {
501 ResolveResult::InternalModule(target)
502 },
503 }
504 }
505
506 fn value_export(name: &str, span_start: u32) -> ExportInfo {
507 ExportInfo {
508 name: ExportName::Named(name.to_string()),
509 local_name: Some(name.to_string()),
510 is_type_only: false,
511 visibility: VisibilityTag::None,
512 expected_unused_reason: None,
513 span: oxc_span::Span::new(span_start, span_start + 10),
514 members: Vec::new(),
515 is_side_effect_used: false,
516 super_class: None,
517 }
518 }
519
520 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
521 let files: Vec<_> = (0..3)
522 .map(|id| DiscoveredFile {
523 id: FileId(id),
524 path: PathBuf::from(format!("/project/file{id}.ts")),
525 size_bytes: 1,
526 })
527 .collect();
528 let modules = vec![
529 ResolvedModule {
530 file_id: FileId(0),
531 path: files[0].path.clone(),
532 resolved_imports: vec![import(
533 FileId(2),
534 ImportedName::Named("target".to_string()),
535 )],
536 ..ResolvedModule::default()
537 },
538 ResolvedModule {
539 file_id: FileId(1),
540 path: files[1].path.clone(),
541 resolved_imports: vec![import(
542 FileId(2),
543 if unmasked_root_imports_export {
544 ImportedName::Named("target".to_string())
545 } else {
546 ImportedName::SideEffect
547 },
548 )],
549 ..ResolvedModule::default()
550 },
551 ResolvedModule {
552 file_id: FileId(2),
553 path: files[2].path.clone(),
554 exports: vec![value_export("target", 0)].into(),
555 ..ResolvedModule::default()
556 },
557 ];
558 let test_entry_points = vec![
559 EntryPoint {
560 path: files[0].path.clone(),
561 source: EntryPointSource::TestFile,
562 },
563 EntryPoint {
564 path: files[1].path.clone(),
565 source: EntryPointSource::TestFile,
566 },
567 ];
568 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
569 &modules,
570 &[ResolvedReplacedModuleTarget {
571 source_file: FileId(0),
572 target_file: FileId(2),
573 }],
574 &test_entry_points,
575 &[],
576 &test_entry_points,
577 &files,
578 );
579 RetainedModuleGraph::from(graph)
580 }
581
582 #[test]
583 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
584 let graph = mixed_root_graph(false);
585
586 let exports = module_value_exports(&graph);
587
588 assert_eq!(exports.len(), 1);
589 assert!(!exports[0].test_referenced);
590 }
591
592 #[test]
593 fn export_coverage_accepts_an_unmasked_correlated_reference() {
594 let graph = mixed_root_graph(true);
595
596 let exports = module_value_exports(&graph);
597
598 assert_eq!(exports.len(), 1);
599 assert!(exports[0].test_referenced);
600 }
601
602 #[test]
603 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
604 let files: Vec<_> = (0..2)
605 .map(|id| DiscoveredFile {
606 id: FileId(id),
607 path: PathBuf::from(format!("/project/file{id}.ts")),
608 size_bytes: 1,
609 })
610 .collect();
611 let modules = vec![
612 ResolvedModule {
613 file_id: FileId(0),
614 path: files[0].path.clone(),
615 resolved_imports: vec![
616 import_with_mechanism(
617 FileId(1),
618 ImportedName::Named("esmOnly".to_string()),
619 false,
620 ),
621 import_with_mechanism(
622 FileId(1),
623 ImportedName::Named("required".to_string()),
624 true,
625 ),
626 ],
627 ..ResolvedModule::default()
628 },
629 ResolvedModule {
630 file_id: FileId(1),
631 path: files[1].path.clone(),
632 exports: vec![value_export("esmOnly", 0), value_export("required", 20)].into(),
633 ..ResolvedModule::default()
634 },
635 ];
636 let test_entry_points = vec![EntryPoint {
637 path: files[0].path.clone(),
638 source: EntryPointSource::TestFile,
639 }];
640 let graph =
641 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
642 &modules,
643 &[ResolvedReplacedModuleTarget {
644 source_file: FileId(0),
645 target_file: FileId(1),
646 }],
647 &test_entry_points,
648 &[],
649 &test_entry_points,
650 &files,
651 ));
652
653 let exports = module_value_exports(&graph);
654 let coverage: rustc_hash::FxHashMap<_, _> = exports
655 .into_iter()
656 .map(|export| (export.name, export.test_referenced))
657 .collect();
658
659 assert_eq!(coverage.get("esmOnly"), Some(&false));
660 assert_eq!(coverage.get("required"), Some(&true));
661 }
662}