use std::path::{Path, PathBuf};
use fallow_types::discover::FileId;
use fixedbitset::FixedBitSet;
use rustc_hash::FxHashMap;
use super::ModuleGraph;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoordinationGap {
pub changed_file: FileId,
pub consumer_file: FileId,
pub consumed_symbols: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ImpactClosure {
pub in_diff: Vec<FileId>,
pub affected_not_shown: Vec<FileId>,
pub coordination_gap: Vec<CoordinationGap>,
}
#[derive(Debug, Clone, Default)]
pub struct ImpactClosurePaths {
pub in_diff: Vec<String>,
pub affected_not_shown: Vec<String>,
pub coordination_gap: Vec<CoordinationGapPaths>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoordinationGapPaths {
pub changed_file: String,
pub consumer_file: String,
pub consumed_symbols: Vec<String>,
}
impl ModuleGraph {
#[must_use]
pub fn impact_closure(&self, changed: &[FileId]) -> ImpactClosure {
let capacity = self.modules.len();
let mut in_diff_set = FixedBitSet::with_capacity(capacity);
for &id in changed {
let idx = id.0 as usize;
if idx < capacity {
in_diff_set.insert(idx);
}
}
let affected = self.collect_reverse_closure(&in_diff_set, capacity);
let coordination_gap = self.collect_coordination_gaps(&in_diff_set);
let mut in_diff: Vec<FileId> = in_diff_set.ones().map(|i| FileId(i as u32)).collect();
in_diff.sort_unstable_by_key(|f| f.0);
let mut affected_not_shown: Vec<FileId> =
affected.ones().map(|i| FileId(i as u32)).collect();
affected_not_shown.sort_unstable_by_key(|f| f.0);
ImpactClosure {
in_diff,
affected_not_shown,
coordination_gap,
}
}
fn collect_reverse_closure(&self, seed: &FixedBitSet, capacity: usize) -> FixedBitSet {
let mut visited = seed.clone();
let mut affected = FixedBitSet::with_capacity(capacity);
let mut queue: Vec<FileId> = seed.ones().map(|i| FileId(i as u32)).collect();
while let Some(current) = queue.pop() {
let Some(importers) = self.reverse_deps.get(current.0 as usize) else {
continue;
};
for &importer in importers {
let idx = importer.0 as usize;
if idx >= capacity || visited.contains(idx) {
continue;
}
visited.insert(idx);
if !seed.contains(idx) {
affected.insert(idx);
}
queue.push(importer);
}
}
affected
}
fn collect_coordination_gaps(&self, in_diff_set: &FixedBitSet) -> Vec<CoordinationGap> {
let mut gaps: Vec<CoordinationGap> = Vec::new();
for changed_idx in in_diff_set.ones() {
let Some(module) = self.modules.get(changed_idx) else {
continue;
};
let mut by_consumer: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
for export in &module.exports {
if export.is_type_only {
continue;
}
let symbol_name = export.name.to_string();
for reference in &export.references {
let consumer_idx = reference.from_file.0 as usize;
if in_diff_set.contains(consumer_idx) {
continue;
}
if self
.modules
.get(consumer_idx)
.is_some_and(|m| is_dev_glue_path(&m.path))
{
continue;
}
by_consumer
.entry(reference.from_file)
.or_default()
.push(symbol_name.clone());
}
}
for (consumer_file, mut symbols) in by_consumer {
symbols.sort_unstable();
symbols.dedup();
gaps.push(CoordinationGap {
changed_file: FileId(changed_idx as u32),
consumer_file,
consumed_symbols: symbols,
});
}
}
gaps.sort_unstable_by(|a, b| {
a.changed_file
.0
.cmp(&b.changed_file.0)
.then_with(|| a.consumer_file.0.cmp(&b.consumer_file.0))
});
gaps
}
#[must_use]
pub fn closure_with_paths(&self, closure: &ImpactClosure, root: &Path) -> ImpactClosurePaths {
let resolve = |id: FileId| -> Option<String> {
self.modules
.get(id.0 as usize)
.map(|m| relativize(&m.path, root))
};
let mut in_diff: Vec<String> = closure
.in_diff
.iter()
.filter_map(|&id| resolve(id))
.collect();
in_diff.sort();
let mut affected_not_shown: Vec<String> = closure
.affected_not_shown
.iter()
.filter_map(|&id| resolve(id))
.collect();
affected_not_shown.sort();
let mut coordination_gap: Vec<CoordinationGapPaths> = closure
.coordination_gap
.iter()
.filter_map(|gap| {
Some(CoordinationGapPaths {
changed_file: resolve(gap.changed_file)?,
consumer_file: resolve(gap.consumer_file)?,
consumed_symbols: gap.consumed_symbols.clone(),
})
})
.collect();
coordination_gap.sort_by(|a, b| {
a.changed_file
.cmp(&b.changed_file)
.then_with(|| a.consumer_file.cmp(&b.consumer_file))
});
ImpactClosurePaths {
in_diff,
affected_not_shown,
coordination_gap,
}
}
}
fn is_dev_glue_path(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if [".stories.", ".story.", ".spec.", ".test.", ".cy."]
.iter()
.any(|marker| name.contains(marker))
{
return true;
}
path.components().any(|component| {
matches!(
component.as_os_str().to_str(),
Some("__tests__" | "__mocks__" | "__stories__")
)
})
}
fn relativize(path: &Path, root: &Path) -> String {
let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
rel.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
use std::path::PathBuf;
fn file(id: u32, path: &str) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path: PathBuf::from(path),
size_bytes: 10,
}
}
fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: source.to_string(),
imported_name: ImportedName::Named(name.to_string()),
local_name: name.to_string(),
is_type_only: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(target),
}
}
fn named_export(name: &str) -> ExportInfo {
ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: Some(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: oxc_span::Span::new(0, 20),
members: vec![],
is_side_effect_used: false,
super_class: None,
}
}
fn build_reverse_dep_graph() -> ModuleGraph {
let files = vec![
file(0, "/p/src/core.ts"),
file(1, "/p/src/mid.ts"),
file(2, "/p/src/app.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/app.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/core.ts"),
exports: vec![named_export("compute")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/mid.ts"),
resolved_imports: vec![named_import("./core", "compute", FileId(0))],
exports: vec![named_export("midFn")],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/app.ts"),
resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
..Default::default()
},
];
ModuleGraph::build(&resolved, &entry_points, &files)
}
fn build_re_export_graph() -> ModuleGraph {
use crate::resolve::ResolvedReExport;
use fallow_types::extract::ReExportInfo;
let files = vec![
file(0, "/p/src/impl.ts"),
file(1, "/p/src/barrel.ts"),
file(2, "/p/src/consumer.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/consumer.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/impl.ts"),
exports: vec![named_export("widget")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/barrel.ts"),
re_exports: vec![ResolvedReExport {
info: ReExportInfo {
source: "./impl".to_string(),
imported_name: "widget".to_string(),
exported_name: "widget".to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
},
target: ResolveResult::InternalModule(FileId(0)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/consumer.ts"),
resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
..Default::default()
},
];
ModuleGraph::build(&resolved, &entry_points, &files)
}
#[test]
fn reverse_dep_closure_equals_hand_computed_set() {
let graph = build_reverse_dep_graph();
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(closure.in_diff, vec![FileId(0)]);
assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
}
#[test]
fn coordination_gap_fires_when_consumer_outside_diff() {
let graph = build_reverse_dep_graph();
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(closure.coordination_gap.len(), 1);
let gap = &closure.coordination_gap[0];
assert_eq!(gap.changed_file, FileId(0));
assert_eq!(gap.consumer_file, FileId(1));
assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
}
#[test]
fn coordination_gap_skips_story_and_test_consumers() {
use fallow_types::discover::{EntryPoint, EntryPointSource};
let files = vec![
file(0, "/p/src/button.component.ts"),
file(1, "/p/src/button.stories.ts"),
file(2, "/p/src/panel.component.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/panel.component.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/button.component.ts"),
exports: vec![named_export("BzmButton")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/button.stories.ts"),
resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/panel.component.ts"),
resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(closure.coordination_gap.len(), 1);
assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
assert!(closure.affected_not_shown.contains(&FileId(1)));
}
#[test]
fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
let graph = build_reverse_dep_graph();
let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
assert!(
closure
.coordination_gap
.iter()
.all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
"no gap may name an in-diff consumer: {:?}",
closure.coordination_gap
);
assert!(
!closure
.coordination_gap
.iter()
.any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
"core->mid must not fire when mid is in the diff"
);
}
#[test]
fn re_export_chain_closure_equals_hand_computed_set() {
let graph = build_re_export_graph();
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(closure.in_diff, vec![FileId(0)]);
assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
}
#[test]
fn re_export_chain_coordination_gap_fires_through_barrel() {
let graph = build_re_export_graph();
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(closure.coordination_gap.len(), 1);
let gap = &closure.coordination_gap[0];
assert_eq!(gap.changed_file, FileId(0));
assert_eq!(gap.consumer_file, FileId(2));
assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
}
#[test]
fn coordination_gap_dedups_per_consumer_pair_r2() {
let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/app.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/core.ts"),
exports: vec![named_export("alpha"), named_export("beta")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/app.ts"),
resolved_imports: vec![
named_import("./core", "alpha", FileId(0)),
named_import("./core", "beta", FileId(0)),
],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let closure = graph.impact_closure(&[FileId(0)]);
assert_eq!(
closure.coordination_gap.len(),
1,
"R2: one entry per consumer pair"
);
assert_eq!(
closure.coordination_gap[0].consumed_symbols,
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn closure_with_paths_relativizes_and_sorts() {
let graph = build_reverse_dep_graph();
let closure = graph.impact_closure(&[FileId(0)]);
let paths = graph.closure_with_paths(&closure, Path::new("/p"));
assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
assert_eq!(
paths.affected_not_shown,
vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
);
assert_eq!(paths.coordination_gap.len(), 1);
assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
}
#[test]
fn empty_changed_set_yields_empty_closure() {
let graph = build_reverse_dep_graph();
let closure = graph.impact_closure(&[]);
assert!(closure.in_diff.is_empty());
assert!(closure.affected_not_shown.is_empty());
assert!(closure.coordination_gap.is_empty());
}
}