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,
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: test_coverage.covers_any_reference(export),
276 })
277 })
278 .collect()
279}
280
281#[must_use]
283pub fn impact_closure_for_changed_paths(
284 graph: &RetainedModuleGraph,
285 root: &Path,
286 changed_files: &FxHashSet<PathBuf>,
287) -> Option<ImpactClosurePaths> {
288 let graph = graph.as_graph();
289 let changed_ids = changed_file_ids(graph, changed_files);
290 if changed_ids.is_empty() {
291 return None;
292 }
293
294 let closure = graph.impact_closure(&changed_ids);
295 Some(graph.closure_with_paths(&closure, root).into())
296}
297
298#[must_use]
300pub fn partition_order_for_changed_paths(
301 graph: &RetainedModuleGraph,
302 root: &Path,
303 changed_files: &FxHashSet<PathBuf>,
304) -> Option<PartitionOrderPaths> {
305 let graph = graph.as_graph();
306 let changed_ids = changed_file_ids(graph, changed_files);
307 if changed_ids.is_empty() {
308 return None;
309 }
310
311 let partition = graph.partition_order(&changed_ids);
312 Some(graph.partition_order_with_paths(&partition, root).into())
313}
314
315#[must_use]
317pub fn focus_facts_for_changed_paths(
318 graph: &RetainedModuleGraph,
319 root: &Path,
320 changed_files: &FxHashSet<PathBuf>,
321) -> Option<Vec<FocusFileFactsPaths>> {
322 let graph = graph.as_graph();
323 let changed_ids = changed_file_ids(graph, changed_files);
324 if changed_ids.is_empty() {
325 return None;
326 }
327
328 let facts = graph.focus_file_facts(&changed_ids);
329 Some(
330 graph
331 .focus_facts_with_paths(&facts, root)
332 .into_iter()
333 .map(FocusFileFactsPaths::from)
334 .collect(),
335 )
336}
337
338#[must_use]
340pub fn export_lines_for_changed_paths(
341 graph: &RetainedModuleGraph,
342 root: &Path,
343 changed_files: &FxHashSet<PathBuf>,
344) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
345 let graph = graph.as_graph();
346 let changed_norm = normalized_changed_paths(changed_files);
347 let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
348 for module in &graph.modules {
349 let abs = normalize_path(&module.path);
350 if !changed_norm.contains(&abs) || module.exports.is_empty() {
351 continue;
352 }
353 let Ok(content) = std::fs::read_to_string(&module.path) else {
354 continue;
355 };
356 let offsets = fallow_types::extract::compute_line_offsets(&content);
357 let exports: Vec<(String, u32)> = module
358 .exports
359 .iter()
360 .map(|export| {
361 let (line, _) =
362 fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
363 (export.name.to_string(), line)
364 })
365 .collect();
366 map.insert(relative_key_path(&module.path, root), exports);
367 }
368 Some(map)
369}
370
371#[must_use]
373pub fn internal_consumers_for_changed_paths(
374 graph: &RetainedModuleGraph,
375 root: &Path,
376 changed_files: &FxHashSet<PathBuf>,
377) -> Option<FxHashMap<String, u64>> {
378 let graph = graph.as_graph();
379 let changed_norm = normalized_changed_paths(changed_files);
380 let id_to_norm: FxHashMap<FileId, String> = graph
381 .modules
382 .iter()
383 .map(|module| (module.file_id, normalize_path(&module.path)))
384 .collect();
385
386 let mut map: FxHashMap<String, u64> = FxHashMap::default();
387 for module in &graph.modules {
388 let abs = normalize_path(&module.path);
389 if !changed_norm.contains(&abs) {
390 continue;
391 }
392 let count = graph
393 .importers_of(module.file_id)
394 .iter()
395 .filter(|imp| {
396 id_to_norm
397 .get(imp)
398 .is_none_or(|p| !changed_norm.contains(p))
399 })
400 .count() as u64;
401 map.insert(relative_key_path(&module.path, root), count);
402 }
403 Some(map)
404}
405
406fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
407 let path_to_id: FxHashMap<String, FileId> = graph
408 .modules
409 .iter()
410 .map(|module| (normalize_path(&module.path), module.file_id))
411 .collect();
412
413 changed_files
414 .iter()
415 .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
416 .collect()
417}
418
419fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
420 changed_files
421 .iter()
422 .map(|path| normalize_path(path))
423 .collect()
424}
425
426fn normalize_path(path: &Path) -> String {
427 path.to_string_lossy().replace('\\', "/")
428}
429
430fn relative_key_path(path: &Path, root: &Path) -> String {
431 let simple_path = dunce::simplified(path);
432 let simple_root = dunce::simplified(root);
433 simple_path
434 .strip_prefix(simple_root)
435 .unwrap_or(simple_path)
436 .to_string_lossy()
437 .replace('\\', "/")
438}
439
440#[cfg(test)]
441mod tests {
442 use super::{RetainedModuleGraph, module_value_exports};
443 use fallow_graph::graph::ModuleGraph;
444 use fallow_graph::resolve::{
445 ResolveResult, ResolvedImport, ResolvedModule, ResolvedReplacedModuleTarget,
446 };
447 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
448 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
449 use std::path::PathBuf;
450
451 fn import(target: FileId, imported_name: ImportedName) -> ResolvedImport {
452 import_with_mechanism(target, imported_name, false)
453 }
454
455 fn import_with_mechanism(
456 target: FileId,
457 imported_name: ImportedName,
458 commonjs: bool,
459 ) -> ResolvedImport {
460 ResolvedImport {
461 info: ImportInfo {
462 source: "./target".to_string(),
463 imported_name,
464 local_name: "target".to_string(),
465 is_type_only: false,
466 from_style: false,
467 span: oxc_span::Span::new(0, 10),
468 source_span: oxc_span::Span::default(),
469 },
470 target: if commonjs {
471 ResolveResult::CommonJsInternalModule(target)
472 } else {
473 ResolveResult::InternalModule(target)
474 },
475 }
476 }
477
478 fn value_export(name: &str, span_start: u32) -> ExportInfo {
479 ExportInfo {
480 name: ExportName::Named(name.to_string()),
481 local_name: Some(name.to_string()),
482 is_type_only: false,
483 visibility: VisibilityTag::None,
484 expected_unused_reason: None,
485 span: oxc_span::Span::new(span_start, span_start + 10),
486 members: Vec::new(),
487 is_side_effect_used: false,
488 super_class: None,
489 }
490 }
491
492 fn mixed_root_graph(unmasked_root_imports_export: bool) -> RetainedModuleGraph {
493 let files: Vec<_> = (0..3)
494 .map(|id| DiscoveredFile {
495 id: FileId(id),
496 path: PathBuf::from(format!("/project/file{id}.ts")),
497 size_bytes: 1,
498 })
499 .collect();
500 let modules = vec![
501 ResolvedModule {
502 file_id: FileId(0),
503 path: files[0].path.clone(),
504 resolved_imports: vec![import(
505 FileId(2),
506 ImportedName::Named("target".to_string()),
507 )],
508 ..ResolvedModule::default()
509 },
510 ResolvedModule {
511 file_id: FileId(1),
512 path: files[1].path.clone(),
513 resolved_imports: vec![import(
514 FileId(2),
515 if unmasked_root_imports_export {
516 ImportedName::Named("target".to_string())
517 } else {
518 ImportedName::SideEffect
519 },
520 )],
521 ..ResolvedModule::default()
522 },
523 ResolvedModule {
524 file_id: FileId(2),
525 path: files[2].path.clone(),
526 exports: vec![value_export("target", 0)],
527 ..ResolvedModule::default()
528 },
529 ];
530 let test_entry_points = vec![
531 EntryPoint {
532 path: files[0].path.clone(),
533 source: EntryPointSource::TestFile,
534 },
535 EntryPoint {
536 path: files[1].path.clone(),
537 source: EntryPointSource::TestFile,
538 },
539 ];
540 let graph = ModuleGraph::build_with_reachability_roots_and_replacements(
541 &modules,
542 &[ResolvedReplacedModuleTarget {
543 source_file: FileId(0),
544 target_file: FileId(2),
545 }],
546 &test_entry_points,
547 &[],
548 &test_entry_points,
549 &files,
550 );
551 RetainedModuleGraph::from(graph)
552 }
553
554 #[test]
555 fn export_coverage_requires_one_root_to_reach_consumer_and_target() {
556 let graph = mixed_root_graph(false);
557
558 let exports = module_value_exports(&graph);
559
560 assert_eq!(exports.len(), 1);
561 assert!(!exports[0].test_referenced);
562 }
563
564 #[test]
565 fn export_coverage_accepts_an_unmasked_correlated_reference() {
566 let graph = mixed_root_graph(true);
567
568 let exports = module_value_exports(&graph);
569
570 assert_eq!(exports.len(), 1);
571 assert!(exports[0].test_referenced);
572 }
573
574 #[test]
575 fn commonjs_reference_does_not_credit_a_mocked_esm_export() {
576 let files: Vec<_> = (0..2)
577 .map(|id| DiscoveredFile {
578 id: FileId(id),
579 path: PathBuf::from(format!("/project/file{id}.ts")),
580 size_bytes: 1,
581 })
582 .collect();
583 let modules = vec![
584 ResolvedModule {
585 file_id: FileId(0),
586 path: files[0].path.clone(),
587 resolved_imports: vec![
588 import_with_mechanism(
589 FileId(1),
590 ImportedName::Named("esmOnly".to_string()),
591 false,
592 ),
593 import_with_mechanism(
594 FileId(1),
595 ImportedName::Named("required".to_string()),
596 true,
597 ),
598 ],
599 ..ResolvedModule::default()
600 },
601 ResolvedModule {
602 file_id: FileId(1),
603 path: files[1].path.clone(),
604 exports: vec![value_export("esmOnly", 0), value_export("required", 20)],
605 ..ResolvedModule::default()
606 },
607 ];
608 let test_entry_points = vec![EntryPoint {
609 path: files[0].path.clone(),
610 source: EntryPointSource::TestFile,
611 }];
612 let graph =
613 RetainedModuleGraph::from(ModuleGraph::build_with_reachability_roots_and_replacements(
614 &modules,
615 &[ResolvedReplacedModuleTarget {
616 source_file: FileId(0),
617 target_file: FileId(1),
618 }],
619 &test_entry_points,
620 &[],
621 &test_entry_points,
622 &files,
623 ));
624
625 let exports = module_value_exports(&graph);
626 let coverage: rustc_hash::FxHashMap<_, _> = exports
627 .into_iter()
628 .map(|export| (export.name, export.test_referenced))
629 .collect();
630
631 assert_eq!(coverage.get("esmOnly"), Some(&false));
632 assert_eq!(coverage.get("required"), Some(&true));
633 }
634}