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