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_reference(self, reference: &fallow_graph::graph::SymbolReference) -> bool {
110 self.graph.is_test_reference_covered(reference)
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,
124 pub symbols: Vec<ImportedSymbolSummary>,
125}
126
127impl From<GraphDirectImporterSummary> for DirectImporterSummary {
128 fn from(summary: GraphDirectImporterSummary) -> Self {
129 Self {
130 source: summary.source,
131 symbols: summary.symbols.into_iter().map(Into::into).collect(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ImportedSymbolSummary {
139 pub imported: String,
140 pub local: String,
141 pub type_only: bool,
142}
143
144impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
145 fn from(symbol: GraphImportedSymbolSummary) -> Self {
146 Self {
147 imported: symbol.imported,
148 local: symbol.local,
149 type_only: symbol.type_only,
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ModuleValueExport {
157 pub file_id: FileId,
158 pub name: String,
159 pub span_start: u32,
160 pub test_referenced: bool,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct ImpactClosurePaths {
166 pub in_diff: Vec<String>,
167 pub affected_not_shown: Vec<String>,
168 pub coordination_gap: Vec<CoordinationGapPaths>,
169}
170
171impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
172 fn from(paths: GraphImpactClosurePaths) -> Self {
173 Self {
174 in_diff: paths.in_diff,
175 affected_not_shown: paths.affected_not_shown,
176 coordination_gap: paths
177 .coordination_gap
178 .into_iter()
179 .map(CoordinationGapPaths::from)
180 .collect(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct CoordinationGapPaths {
188 pub changed_file: String,
189 pub consumer_file: String,
190 pub consumed_symbols: Vec<String>,
191}
192
193impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
194 fn from(paths: GraphCoordinationGapPaths) -> Self {
195 Self {
196 changed_file: paths.changed_file,
197 consumer_file: paths.consumer_file,
198 consumed_symbols: paths.consumed_symbols,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub struct PartitionOrderPaths {
206 pub units: Vec<ReviewUnitPaths>,
207 pub order: Vec<String>,
208}
209
210impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
211 fn from(paths: GraphPartitionOrderPaths) -> Self {
212 Self {
213 units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
214 order: paths.order,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ReviewUnitPaths {
222 pub module_dir: String,
223 pub files: Vec<String>,
224}
225
226impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
227 fn from(paths: GraphReviewUnitPaths) -> Self {
228 Self {
229 module_dir: paths.module_dir,
230 files: paths.files,
231 }
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct FocusFileFactsPaths {
238 pub file: String,
239 pub fan_in: u32,
240 pub fan_out: u32,
241 pub dynamic_dispatch: bool,
242 pub re_export_indirection: bool,
243}
244
245impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
246 fn from(paths: GraphFocusFileFactsPaths) -> Self {
247 Self {
248 file: paths.file,
249 fan_in: paths.fan_in,
250 fan_out: paths.fan_out,
251 dynamic_dispatch: paths.dynamic_dispatch,
252 re_export_indirection: paths.re_export_indirection,
253 }
254 }
255}
256
257#[must_use]
260pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
261 let test_coverage = graph.static_test_coverage();
262 let graph = graph.as_graph();
263
264 graph
265 .modules
266 .iter()
267 .flat_map(|node| {
268 node.exports
269 .iter()
270 .filter(|export| !export.is_type_only)
271 .map(|export| ModuleValueExport {
272 file_id: node.file_id,
273 name: export.name.to_string(),
274 span_start: export.span.start,
275 test_referenced: export
276 .references
277 .iter()
278 .any(|reference| test_coverage.covers_reference(reference)),
279 })
280 })
281 .collect()
282}
283
284#[must_use]
286pub fn impact_closure_for_changed_paths(
287 graph: &RetainedModuleGraph,
288 root: &Path,
289 changed_files: &FxHashSet<PathBuf>,
290) -> Option<ImpactClosurePaths> {
291 let graph = graph.as_graph();
292 let changed_ids = changed_file_ids(graph, changed_files);
293 if changed_ids.is_empty() {
294 return None;
295 }
296
297 let closure = graph.impact_closure(&changed_ids);
298 Some(graph.closure_with_paths(&closure, root).into())
299}
300
301#[must_use]
303pub fn partition_order_for_changed_paths(
304 graph: &RetainedModuleGraph,
305 root: &Path,
306 changed_files: &FxHashSet<PathBuf>,
307) -> Option<PartitionOrderPaths> {
308 let graph = graph.as_graph();
309 let changed_ids = changed_file_ids(graph, changed_files);
310 if changed_ids.is_empty() {
311 return None;
312 }
313
314 let partition = graph.partition_order(&changed_ids);
315 Some(graph.partition_order_with_paths(&partition, root).into())
316}
317
318#[must_use]
320pub fn focus_facts_for_changed_paths(
321 graph: &RetainedModuleGraph,
322 root: &Path,
323 changed_files: &FxHashSet<PathBuf>,
324) -> Option<Vec<FocusFileFactsPaths>> {
325 let graph = graph.as_graph();
326 let changed_ids = changed_file_ids(graph, changed_files);
327 if changed_ids.is_empty() {
328 return None;
329 }
330
331 let facts = graph.focus_file_facts(&changed_ids);
332 Some(
333 graph
334 .focus_facts_with_paths(&facts, root)
335 .into_iter()
336 .map(FocusFileFactsPaths::from)
337 .collect(),
338 )
339}
340
341#[must_use]
343pub fn export_lines_for_changed_paths(
344 graph: &RetainedModuleGraph,
345 root: &Path,
346 changed_files: &FxHashSet<PathBuf>,
347) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
348 let graph = graph.as_graph();
349 let changed_norm = normalized_changed_paths(changed_files);
350 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
351 for module in &graph.modules {
352 let abs = normalize_path(&module.path);
353 if !changed_norm.contains(&abs) || module.exports.is_empty() {
354 continue;
355 }
356 let Ok(content) = std::fs::read_to_string(&module.path) else {
357 continue;
358 };
359 let offsets = fallow_types::extract::compute_line_offsets(&content);
360 let exports: Vec<(String, u32)> = module
361 .exports
362 .iter()
363 .map(|export| {
364 let (line, _) =
365 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
366 (export.name.to_string(), line)
367 })
368 .collect();
369 map.insert(relative_key_path(&module.path, root), exports);
370 }
371 Some(map)
372}
373
374#[must_use]
376pub fn internal_consumers_for_changed_paths(
377 graph: &RetainedModuleGraph,
378 root: &Path,
379 changed_files: &FxHashSet<PathBuf>,
380) -> Option<FxHashMap<String, u64>> {
381 let graph = graph.as_graph();
382 let changed_norm = normalized_changed_paths(changed_files);
383 let id_to_norm: FxHashMap<FileId, String> = graph
384 .modules
385 .iter()
386 .map(|module| (module.file_id, normalize_path(&module.path)))
387 .collect();
388
389 let mut map: FxHashMap<String, u64> = FxHashMap::default();
390 for module in &graph.modules {
391 let abs = normalize_path(&module.path);
392 if !changed_norm.contains(&abs) {
393 continue;
394 }
395 let count = graph
396 .importers_of(module.file_id)
397 .iter()
398 .filter(|imp| {
399 id_to_norm
400 .get(imp)
401 .is_none_or(|p| !changed_norm.contains(p))
402 })
403 .count() as u64;
404 map.insert(relative_key_path(&module.path, root), count);
405 }
406 Some(map)
407}
408
409fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
410 let path_to_id: FxHashMap<String, FileId> = graph
411 .modules
412 .iter()
413 .map(|module| (normalize_path(&module.path), module.file_id))
414 .collect();
415
416 changed_files
417 .iter()
418 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
419 .collect()
420}
421
422fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
423 changed_files
424 .iter()
425 .map(|path| normalize_path(path))
426 .collect()
427}
428
429fn normalize_path(path: &Path) -> String {
430 path.to_string_lossy().replace('\\', "/")
431}
432
433fn relative_key_path(path: &Path, root: &Path) -> String {
434 let simple_path = dunce::simplified(path);
435 let simple_root = dunce::simplified(root);
436 simple_path
437 .strip_prefix(simple_root)
438 .unwrap_or(simple_path)
439 .to_string_lossy()
440 .replace('\\', "/")
441}
442
443#[cfg(test)]
444mod tests {
445 use super::{RetainedModuleGraph, module_value_exports};
446 use fallow_graph::graph::ModuleGraph;
447 use fallow_graph::resolve::{
448 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
449 };
450 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
451 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
452 use std::path::PathBuf;
453
454 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
455 import_with_mechanism(target, imported_name, false)
456 }
457
458 fn import_with_mechanism(
459 target: FileId,
460 imported_name: ImportedName,
461 commonjs: bool,
462 ) -> ResolvedImport {
463 ResolvedImport {
464 info: ImportInfo {
465 source: "./target".to_string(),
466 imported_name,
467 local_name: "target".to_string(),
468 is_type_only: false,
469 from_style: false,
470 span: oxc_span::Span::new(0, 10),
471 source_span: oxc_span::Span::default(),
472 },
473 target: if commonjs {
474 ResolveResult::CommonJsInternalModule(target)
475 } else {
476 ResolveResult::InternalModule(target)
477 },
478 }
479 }
480
481 fn value_export(name: &str, span_start: u32) -> ExportInfo {
482 ExportInfo {
483 name: ExportName::Named(name.to_string()),
484 local_name: Some(name.to_string()),
485 is_type_only: false,
486 visibility: VisibilityTag::None,
487 expected_unused_reason: None,
488 span: oxc_span::Span::new(span_start, span_start + 10),
489 members: Vec::new(),
490 is_side_effect_used: false,
491 super_class: None,
492 }
493 }
494
495 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
496 let files: Vec<_> = (0..3)
497 .map(|id| DiscoveredFile {
498 id: FileId(id),
499 path: PathBuf::from(format!("/project/file{id}.ts")),
500 size_bytes: 1,
501 })
502 .collect();
503 let modules = vec![
504 ResolvedModule {
505 file_id: FileId(0),
506 path: files[0].path.clone(),
507 resolved_imports: vec![import(
508 FileId(2),
509 ImportedName::Named("target".to_string()),
510 )],
511 ..ResolvedModule::default()
512 },
513 ResolvedModule {
514 file_id: FileId(1),
515 path: files[1].path.clone(),
516 resolved_imports: vec![import(
517 FileId(2),
518 if unmasked_root_imports_export {
519 ImportedName::Named("target".to_string())
520 } else {
521 ImportedName::SideEffect
522 },
523 )],
524 ..ResolvedModule::default()
525 },
526 ResolvedModule {
527 file_id: FileId(2),
528 path: files[2].path.clone(),
529 exports: vec![value_export("target", 0)],
530 ..ResolvedModule::default()
531 },
532 ];
533 let test_entry_points = vec![
534 EntryPoint {
535 path: files[0].path.clone(),
536 source: EntryPointSource::TestFile,
537 },
538 EntryPoint {
539 path: files[1].path.clone(),
540 source: EntryPointSource::TestFile,
541 },
542 ];
543 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
544 &modules,
545 &[ResolvedReplacedModuleTarget {
546 source_file: FileId(0),
547 target_file: FileId(2),
548 }],
549 &test_entry_points,
550 &[],
551 &test_entry_points,
552 &files,
553 );
554 RetainedModuleGraph::from(graph)
555 }
556
557 #[test]
558 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
559 let graph = mixed_root_graph(false);
560
561 let exports = module_value_exports(&graph);
562
563 assert_eq!(exports.len(), 1);
564 assert!(!exports[0].test_referenced);
565 }
566
567 #[test]
568 fn export_coverage_accepts_an_unmasked_correlated_reference() {
569 let graph = mixed_root_graph(true);
570
571 let exports = module_value_exports(&graph);
572
573 assert_eq!(exports.len(), 1);
574 assert!(exports[0].test_referenced);
575 }
576
577 #[test]
578 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
579 let files: Vec<_> = (0..2)
580 .map(|id| DiscoveredFile {
581 id: FileId(id),
582 path: PathBuf::from(format!("/project/file{id}.ts")),
583 size_bytes: 1,
584 })
585 .collect();
586 let modules = vec![
587 ResolvedModule {
588 file_id: FileId(0),
589 path: files[0].path.clone(),
590 resolved_imports: vec![
591 import_with_mechanism(
592 FileId(1),
593 ImportedName::Named("esmOnly".to_string()),
594 false,
595 ),
596 import_with_mechanism(
597 FileId(1),
598 ImportedName::Named("required".to_string()),
599 true,
600 ),
601 ],
602 ..ResolvedModule::default()
603 },
604 ResolvedModule {
605 file_id: FileId(1),
606 path: files[1].path.clone(),
607 exports: vec![value_export("esmOnly", 0), value_export("required", 20)],
608 ..ResolvedModule::default()
609 },
610 ];
611 let test_entry_points = vec![EntryPoint {
612 path: files[0].path.clone(),
613 source: EntryPointSource::TestFile,
614 }];
615 let graph =
616 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
617 &modules,
618 &[ResolvedReplacedModuleTarget {
619 source_file: FileId(0),
620 target_file: FileId(1),
621 }],
622 &test_entry_points,
623 &[],
624 &test_entry_points,
625 &files,
626 ));
627
628 let exports = module_value_exports(&graph);
629 let coverage: rustc_hash::FxHashMap<_, _> = exports
630 .into_iter()
631 .map(|export| (export.name, export.test_referenced))
632 .collect();
633
634 assert_eq!(coverage.get("esmOnly"), Some(&false));
635 assert_eq!(coverage.get("required"), Some(&true));
636 }
637}