1use std::path::{Path, PathBuf};
16
17use fallow_types::discover::FileId;
18use fixedbitset::FixedBitSet;
19use rustc_hash::FxHashMap;
20
21use super::ModuleGraph;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CoordinationGap {
29 pub changed_file: FileId,
31 pub consumer_file: FileId,
33 pub consumed_symbols: Vec<String>,
35}
36
37#[derive(Debug, Clone, Default)]
41pub struct ImpactClosure {
42 pub in_diff: Vec<FileId>,
44 pub affected_not_shown: Vec<FileId>,
47 pub coordination_gap: Vec<CoordinationGap>,
49}
50
51#[derive(Debug, Clone, Default)]
54pub struct ImpactClosurePaths {
55 pub in_diff: Vec<String>,
57 pub affected_not_shown: Vec<String>,
59 pub coordination_gap: Vec<CoordinationGapPaths>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct CoordinationGapPaths {
66 pub changed_file: String,
68 pub consumer_file: String,
70 pub consumed_symbols: Vec<String>,
72}
73
74impl ModuleGraph {
75 #[must_use]
88 pub fn impact_closure(&self, changed: &[FileId]) -> ImpactClosure {
89 let capacity = self.modules.len();
90 let mut in_diff_set = FixedBitSet::with_capacity(capacity);
91 for &id in changed {
92 let idx = id.0 as usize;
93 if idx < capacity {
94 in_diff_set.insert(idx);
95 }
96 }
97
98 let affected = self.collect_reverse_closure(&in_diff_set, capacity);
99 let coordination_gap = self.collect_coordination_gaps(&in_diff_set);
100
101 let mut in_diff: Vec<FileId> = in_diff_set.ones().map(|i| FileId(i as u32)).collect();
102 in_diff.sort_unstable_by_key(|f| f.0);
103 let mut affected_not_shown: Vec<FileId> =
104 affected.ones().map(|i| FileId(i as u32)).collect();
105 affected_not_shown.sort_unstable_by_key(|f| f.0);
106
107 ImpactClosure {
108 in_diff,
109 affected_not_shown,
110 coordination_gap,
111 }
112 }
113
114 fn collect_reverse_closure(&self, seed: &FixedBitSet, capacity: usize) -> FixedBitSet {
117 let mut visited = seed.clone();
118 let mut affected = FixedBitSet::with_capacity(capacity);
119 let mut queue: Vec<FileId> = seed.ones().map(|i| FileId(i as u32)).collect();
120
121 while let Some(current) = queue.pop() {
122 let Some(importers) = self.reverse_deps.get(current.0 as usize) else {
123 continue;
124 };
125 for &importer in importers {
126 let idx = importer.0 as usize;
127 if idx >= capacity || visited.contains(idx) {
128 continue;
129 }
130 visited.insert(idx);
131 if !seed.contains(idx) {
132 affected.insert(idx);
133 }
134 queue.push(importer);
135 }
136 }
137 affected
138 }
139
140 fn collect_coordination_gaps(&self, in_diff_set: &FixedBitSet) -> Vec<CoordinationGap> {
144 let mut gaps: Vec<CoordinationGap> = Vec::new();
145 for changed_idx in in_diff_set.ones() {
146 let Some(module) = self.modules.get(changed_idx) else {
147 continue;
148 };
149 let mut by_consumer: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
152 for export in &module.exports {
153 if export.is_type_only {
154 continue;
155 }
156 let symbol_name = export.name.to_string();
157 for reference in &export.references {
158 let consumer_idx = reference.from_file.0 as usize;
159 if in_diff_set.contains(consumer_idx) {
160 continue;
162 }
163 if self
169 .modules
170 .get(consumer_idx)
171 .is_some_and(|m| is_dev_glue_path(&m.path))
172 {
173 continue;
174 }
175 by_consumer
176 .entry(reference.from_file)
177 .or_default()
178 .push(symbol_name.clone());
179 }
180 }
181 for (consumer_file, mut symbols) in by_consumer {
182 symbols.sort_unstable();
183 symbols.dedup();
184 gaps.push(CoordinationGap {
185 changed_file: FileId(changed_idx as u32),
186 consumer_file,
187 consumed_symbols: symbols,
188 });
189 }
190 }
191 gaps.sort_unstable_by(|a, b| {
192 a.changed_file
193 .0
194 .cmp(&b.changed_file.0)
195 .then_with(|| a.consumer_file.0.cmp(&b.consumer_file.0))
196 });
197 gaps
198 }
199
200 #[must_use]
203 pub fn closure_with_paths(&self, closure: &ImpactClosure, root: &Path) -> ImpactClosurePaths {
204 let resolve = |id: FileId| -> Option<String> {
205 self.modules
206 .get(id.0 as usize)
207 .map(|m| relativize(&m.path, root))
208 };
209
210 let mut in_diff: Vec<String> = closure
211 .in_diff
212 .iter()
213 .filter_map(|&id| resolve(id))
214 .collect();
215 in_diff.sort();
216 let mut affected_not_shown: Vec<String> = closure
217 .affected_not_shown
218 .iter()
219 .filter_map(|&id| resolve(id))
220 .collect();
221 affected_not_shown.sort();
222
223 let mut coordination_gap: Vec<CoordinationGapPaths> = closure
224 .coordination_gap
225 .iter()
226 .filter_map(|gap| {
227 Some(CoordinationGapPaths {
228 changed_file: resolve(gap.changed_file)?,
229 consumer_file: resolve(gap.consumer_file)?,
230 consumed_symbols: gap.consumed_symbols.clone(),
231 })
232 })
233 .collect();
234 coordination_gap.sort_by(|a, b| {
235 a.changed_file
236 .cmp(&b.changed_file)
237 .then_with(|| a.consumer_file.cmp(&b.consumer_file))
238 });
239
240 ImpactClosurePaths {
241 in_diff,
242 affected_not_shown,
243 coordination_gap,
244 }
245 }
246}
247
248fn is_dev_glue_path(path: &Path) -> bool {
255 let name = path
256 .file_name()
257 .and_then(|n| n.to_str())
258 .unwrap_or_default();
259 if [".stories.", ".story.", ".spec.", ".test.", ".cy."]
260 .iter()
261 .any(|marker| name.contains(marker))
262 {
263 return true;
264 }
265 path.components().any(|component| {
266 matches!(
267 component.as_os_str().to_str(),
268 Some("__tests__" | "__mocks__" | "__stories__")
269 )
270 })
271}
272
273fn relativize(path: &Path, root: &Path) -> String {
276 let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
277 rel.to_string_lossy().replace('\\', "/")
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
284 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
285 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
286 use std::path::PathBuf;
287
288 fn file(id: u32, path: &str) -> DiscoveredFile {
289 DiscoveredFile {
290 id: FileId(id),
291 path: PathBuf::from(path),
292 size_bytes: 10,
293 }
294 }
295
296 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
297 ResolvedImport {
298 info: ImportInfo {
299 source: source.to_string(),
300 imported_name: ImportedName::Named(name.to_string()),
301 local_name: name.to_string(),
302 is_type_only: false,
303 from_style: false,
304 span: oxc_span::Span::new(0, 10),
305 source_span: oxc_span::Span::default(),
306 },
307 target: ResolveResult::InternalModule(target),
308 }
309 }
310
311 fn named_export(name: &str) -> ExportInfo {
312 ExportInfo {
313 name: ExportName::Named(name.to_string()),
314 local_name: Some(name.to_string()),
315 is_type_only: false,
316 visibility: VisibilityTag::None,
317 expected_unused_reason: None,
318 span: oxc_span::Span::new(0, 20),
319 members: vec![],
320 is_side_effect_used: false,
321 super_class: None,
322 }
323 }
324
325 fn build_reverse_dep_graph() -> ModuleGraph {
328 let files = vec![
329 file(0, "/p/src/core.ts"),
330 file(1, "/p/src/mid.ts"),
331 file(2, "/p/src/app.ts"),
332 ];
333 let entry_points = vec![EntryPoint {
334 path: PathBuf::from("/p/src/app.ts"),
335 source: EntryPointSource::PackageJsonMain,
336 }];
337 let resolved = vec![
338 ResolvedModule {
339 file_id: FileId(0),
340 path: PathBuf::from("/p/src/core.ts"),
341 exports: vec![named_export("compute")],
342 ..Default::default()
343 },
344 ResolvedModule {
345 file_id: FileId(1),
346 path: PathBuf::from("/p/src/mid.ts"),
347 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
348 exports: vec![named_export("midFn")],
349 ..Default::default()
350 },
351 ResolvedModule {
352 file_id: FileId(2),
353 path: PathBuf::from("/p/src/app.ts"),
354 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
355 ..Default::default()
356 },
357 ];
358 ModuleGraph::build(&resolved, &entry_points, &files)
359 }
360
361 fn build_re_export_graph() -> ModuleGraph {
364 use crate::resolve::ResolvedReExport;
365 use fallow_types::extract::ReExportInfo;
366
367 let files = vec![
368 file(0, "/p/src/impl.ts"),
369 file(1, "/p/src/barrel.ts"),
370 file(2, "/p/src/consumer.ts"),
371 ];
372 let entry_points = vec![EntryPoint {
373 path: PathBuf::from("/p/src/consumer.ts"),
374 source: EntryPointSource::PackageJsonMain,
375 }];
376 let resolved = vec![
377 ResolvedModule {
378 file_id: FileId(0),
379 path: PathBuf::from("/p/src/impl.ts"),
380 exports: vec![named_export("widget")],
381 ..Default::default()
382 },
383 ResolvedModule {
384 file_id: FileId(1),
385 path: PathBuf::from("/p/src/barrel.ts"),
386 re_exports: vec![ResolvedReExport {
387 info: ReExportInfo {
388 source: "./impl".to_string(),
389 imported_name: "widget".to_string(),
390 exported_name: "widget".to_string(),
391 is_type_only: false,
392 span: oxc_span::Span::new(0, 10),
393 },
394 target: ResolveResult::InternalModule(FileId(0)),
395 }],
396 ..Default::default()
397 },
398 ResolvedModule {
399 file_id: FileId(2),
400 path: PathBuf::from("/p/src/consumer.ts"),
401 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
402 ..Default::default()
403 },
404 ];
405 ModuleGraph::build(&resolved, &entry_points, &files)
406 }
407
408 #[test]
409 fn reverse_dep_closure_equals_hand_computed_set() {
410 let graph = build_reverse_dep_graph();
411 let closure = graph.impact_closure(&[FileId(0)]);
413 assert_eq!(closure.in_diff, vec![FileId(0)]);
414 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
415 }
416
417 #[test]
418 fn coordination_gap_fires_when_consumer_outside_diff() {
419 let graph = build_reverse_dep_graph();
420 let closure = graph.impact_closure(&[FileId(0)]);
422 assert_eq!(closure.coordination_gap.len(), 1);
423 let gap = &closure.coordination_gap[0];
424 assert_eq!(gap.changed_file, FileId(0));
425 assert_eq!(gap.consumer_file, FileId(1));
426 assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
427 }
428
429 #[test]
430 fn coordination_gap_skips_story_and_test_consumers() {
431 use fallow_types::discover::{EntryPoint, EntryPointSource};
432 let files = vec![
436 file(0, "/p/src/button.component.ts"),
437 file(1, "/p/src/button.stories.ts"),
438 file(2, "/p/src/panel.component.ts"),
439 ];
440 let entry_points = vec![EntryPoint {
441 path: PathBuf::from("/p/src/panel.component.ts"),
442 source: EntryPointSource::PackageJsonMain,
443 }];
444 let resolved = vec![
445 ResolvedModule {
446 file_id: FileId(0),
447 path: PathBuf::from("/p/src/button.component.ts"),
448 exports: vec![named_export("BzmButton")],
449 ..Default::default()
450 },
451 ResolvedModule {
452 file_id: FileId(1),
453 path: PathBuf::from("/p/src/button.stories.ts"),
454 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
455 ..Default::default()
456 },
457 ResolvedModule {
458 file_id: FileId(2),
459 path: PathBuf::from("/p/src/panel.component.ts"),
460 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
461 ..Default::default()
462 },
463 ];
464 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
465 let closure = graph.impact_closure(&[FileId(0)]);
466 assert_eq!(closure.coordination_gap.len(), 1);
468 assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
469 assert!(closure.affected_not_shown.contains(&FileId(1)));
471 }
472
473 #[test]
474 fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
475 let graph = build_reverse_dep_graph();
476 let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
481 assert!(
482 closure
483 .coordination_gap
484 .iter()
485 .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
486 "no gap may name an in-diff consumer: {:?}",
487 closure.coordination_gap
488 );
489 assert!(
491 !closure
492 .coordination_gap
493 .iter()
494 .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
495 "core->mid must not fire when mid is in the diff"
496 );
497 }
498
499 #[test]
500 fn re_export_chain_closure_equals_hand_computed_set() {
501 let graph = build_re_export_graph();
502 let closure = graph.impact_closure(&[FileId(0)]);
506 assert_eq!(closure.in_diff, vec![FileId(0)]);
507 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
508 }
509
510 #[test]
511 fn re_export_chain_coordination_gap_fires_through_barrel() {
512 let graph = build_re_export_graph();
513 let closure = graph.impact_closure(&[FileId(0)]);
518 assert_eq!(closure.coordination_gap.len(), 1);
519 let gap = &closure.coordination_gap[0];
520 assert_eq!(gap.changed_file, FileId(0));
521 assert_eq!(gap.consumer_file, FileId(2));
522 assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
523 }
524
525 #[test]
526 fn coordination_gap_dedups_per_consumer_pair_r2() {
527 let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
530 let entry_points = vec![EntryPoint {
531 path: PathBuf::from("/p/src/app.ts"),
532 source: EntryPointSource::PackageJsonMain,
533 }];
534 let resolved = vec![
535 ResolvedModule {
536 file_id: FileId(0),
537 path: PathBuf::from("/p/src/core.ts"),
538 exports: vec![named_export("alpha"), named_export("beta")],
539 ..Default::default()
540 },
541 ResolvedModule {
542 file_id: FileId(1),
543 path: PathBuf::from("/p/src/app.ts"),
544 resolved_imports: vec![
545 named_import("./core", "alpha", FileId(0)),
546 named_import("./core", "beta", FileId(0)),
547 ],
548 ..Default::default()
549 },
550 ];
551 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
552 let closure = graph.impact_closure(&[FileId(0)]);
553 assert_eq!(
554 closure.coordination_gap.len(),
555 1,
556 "R2: one entry per consumer pair"
557 );
558 assert_eq!(
559 closure.coordination_gap[0].consumed_symbols,
560 vec!["alpha".to_string(), "beta".to_string()]
561 );
562 }
563
564 #[test]
565 fn closure_with_paths_relativizes_and_sorts() {
566 let graph = build_reverse_dep_graph();
567 let closure = graph.impact_closure(&[FileId(0)]);
568 let paths = graph.closure_with_paths(&closure, Path::new("/p"));
569 assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
570 assert_eq!(
571 paths.affected_not_shown,
572 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
573 );
574 assert_eq!(paths.coordination_gap.len(), 1);
575 assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
576 assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
577 }
578
579 #[test]
580 fn empty_changed_set_yields_empty_closure() {
581 let graph = build_reverse_dep_graph();
582 let closure = graph.impact_closure(&[]);
583 assert!(closure.in_diff.is_empty());
584 assert!(closure.affected_not_shown.is_empty());
585 assert!(closure.coordination_gap.is_empty());
586 }
587}