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 changed_file: FileId,
31 consumer_file: FileId,
33 consumed_symbols: Vec<String>,
35}
36
37#[derive(Debug, Clone, Default)]
41pub struct ImpactClosure {
42 in_diff: Vec<FileId>,
44 affected_not_shown: Vec<FileId>,
47 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 is_type_only_star: false,
304 from_style: false,
305 span: oxc_span::Span::new(0, 10),
306 source_span: oxc_span::Span::default(),
307 },
308 target: ResolveResult::InternalModule(target),
309 }
310 }
311
312 fn named_export(name: &str) -> ExportInfo {
313 ExportInfo {
314 name: ExportName::Named(name.to_string()),
315 local_name: Some(name.to_string()),
316 is_type_only: false,
317 visibility: VisibilityTag::None,
318 expected_unused_reason: None,
319 span: oxc_span::Span::new(0, 20),
320 members: vec![],
321 is_side_effect_used: false,
322 super_class: None,
323 }
324 }
325
326 fn build_reverse_dep_graph() -> ModuleGraph {
329 let files = vec![
330 file(0, "/p/src/core.ts"),
331 file(1, "/p/src/mid.ts"),
332 file(2, "/p/src/app.ts"),
333 ];
334 let entry_points = vec![EntryPoint {
335 path: PathBuf::from("/p/src/app.ts"),
336 source: EntryPointSource::PackageJsonMain,
337 }];
338 let resolved = vec![
339 ResolvedModule {
340 file_id: FileId(0),
341 path: PathBuf::from("/p/src/core.ts"),
342 exports: vec![named_export("compute")].into(),
343 ..Default::default()
344 },
345 ResolvedModule {
346 file_id: FileId(1),
347 path: PathBuf::from("/p/src/mid.ts"),
348 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
349 exports: vec![named_export("midFn")].into(),
350 ..Default::default()
351 },
352 ResolvedModule {
353 file_id: FileId(2),
354 path: PathBuf::from("/p/src/app.ts"),
355 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
356 ..Default::default()
357 },
358 ];
359 ModuleGraph::build(&resolved, &entry_points, &files)
360 }
361
362 fn build_re_export_graph() -> ModuleGraph {
365 use crate::resolve::ResolvedReExport;
366 use fallow_types::extract::ReExportInfo;
367
368 let files = vec![
369 file(0, "/p/src/impl.ts"),
370 file(1, "/p/src/barrel.ts"),
371 file(2, "/p/src/consumer.ts"),
372 ];
373 let entry_points = vec![EntryPoint {
374 path: PathBuf::from("/p/src/consumer.ts"),
375 source: EntryPointSource::PackageJsonMain,
376 }];
377 let resolved = vec![
378 ResolvedModule {
379 file_id: FileId(0),
380 path: PathBuf::from("/p/src/impl.ts"),
381 exports: vec![named_export("widget")].into(),
382 ..Default::default()
383 },
384 ResolvedModule {
385 file_id: FileId(1),
386 path: PathBuf::from("/p/src/barrel.ts"),
387 re_exports: vec![ResolvedReExport {
388 info: ReExportInfo {
389 source: "./impl".to_string(),
390 imported_name: "widget".to_string(),
391 exported_name: "widget".to_string(),
392 is_type_only: false,
393 span: oxc_span::Span::new(0, 10),
394 statement_span: oxc_span::Span::new(0, 0),
395 source_span: oxc_span::Span::new(0, 0),
396 },
397 target: ResolveResult::InternalModule(FileId(0)),
398 }],
399 ..Default::default()
400 },
401 ResolvedModule {
402 file_id: FileId(2),
403 path: PathBuf::from("/p/src/consumer.ts"),
404 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
405 ..Default::default()
406 },
407 ];
408 ModuleGraph::build(&resolved, &entry_points, &files)
409 }
410
411 #[test]
412 fn reverse_dep_closure_equals_hand_computed_set() {
413 let graph = build_reverse_dep_graph();
414 let closure = graph.impact_closure(&[FileId(0)]);
416 assert_eq!(closure.in_diff, vec![FileId(0)]);
417 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
418 }
419
420 #[test]
421 fn coordination_gap_fires_when_consumer_outside_diff() {
422 let graph = build_reverse_dep_graph();
423 let closure = graph.impact_closure(&[FileId(0)]);
425 assert_eq!(closure.coordination_gap.len(), 1);
426 let gap = &closure.coordination_gap[0];
427 assert_eq!(gap.changed_file, FileId(0));
428 assert_eq!(gap.consumer_file, FileId(1));
429 assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
430 }
431
432 #[test]
433 fn coordination_gap_skips_story_and_test_consumers() {
434 use fallow_types::discover::{EntryPoint, EntryPointSource};
435 let files = vec![
439 file(0, "/p/src/button.component.ts"),
440 file(1, "/p/src/button.stories.ts"),
441 file(2, "/p/src/panel.component.ts"),
442 ];
443 let entry_points = vec![EntryPoint {
444 path: PathBuf::from("/p/src/panel.component.ts"),
445 source: EntryPointSource::PackageJsonMain,
446 }];
447 let resolved = vec![
448 ResolvedModule {
449 file_id: FileId(0),
450 path: PathBuf::from("/p/src/button.component.ts"),
451 exports: vec![named_export("BzmButton")].into(),
452 ..Default::default()
453 },
454 ResolvedModule {
455 file_id: FileId(1),
456 path: PathBuf::from("/p/src/button.stories.ts"),
457 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
458 ..Default::default()
459 },
460 ResolvedModule {
461 file_id: FileId(2),
462 path: PathBuf::from("/p/src/panel.component.ts"),
463 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
464 ..Default::default()
465 },
466 ];
467 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
468 let closure = graph.impact_closure(&[FileId(0)]);
469 assert_eq!(closure.coordination_gap.len(), 1);
471 assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
472 assert!(closure.affected_not_shown.contains(&FileId(1)));
474 }
475
476 #[test]
477 fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
478 let graph = build_reverse_dep_graph();
479 let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
484 assert!(
485 closure
486 .coordination_gap
487 .iter()
488 .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
489 "no gap may name an in-diff consumer: {:?}",
490 closure.coordination_gap
491 );
492 assert!(
494 !closure
495 .coordination_gap
496 .iter()
497 .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
498 "core->mid must not fire when mid is in the diff"
499 );
500 }
501
502 #[test]
503 fn re_export_chain_closure_equals_hand_computed_set() {
504 let graph = build_re_export_graph();
505 let closure = graph.impact_closure(&[FileId(0)]);
509 assert_eq!(closure.in_diff, vec![FileId(0)]);
510 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
511 }
512
513 #[test]
514 fn re_export_chain_coordination_gap_fires_through_barrel() {
515 let graph = build_re_export_graph();
516 let closure = graph.impact_closure(&[FileId(0)]);
521 assert_eq!(closure.coordination_gap.len(), 1);
522 let gap = &closure.coordination_gap[0];
523 assert_eq!(gap.changed_file, FileId(0));
524 assert_eq!(gap.consumer_file, FileId(2));
525 assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
526 }
527
528 #[test]
529 fn coordination_gap_dedups_per_consumer_pair_r2() {
530 let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
533 let entry_points = vec![EntryPoint {
534 path: PathBuf::from("/p/src/app.ts"),
535 source: EntryPointSource::PackageJsonMain,
536 }];
537 let resolved = vec![
538 ResolvedModule {
539 file_id: FileId(0),
540 path: PathBuf::from("/p/src/core.ts"),
541 exports: vec![named_export("alpha"), named_export("beta")].into(),
542 ..Default::default()
543 },
544 ResolvedModule {
545 file_id: FileId(1),
546 path: PathBuf::from("/p/src/app.ts"),
547 resolved_imports: vec![
548 named_import("./core", "alpha", FileId(0)),
549 named_import("./core", "beta", FileId(0)),
550 ],
551 ..Default::default()
552 },
553 ];
554 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
555 let closure = graph.impact_closure(&[FileId(0)]);
556 assert_eq!(
557 closure.coordination_gap.len(),
558 1,
559 "R2: one entry per consumer pair"
560 );
561 assert_eq!(
562 closure.coordination_gap[0].consumed_symbols,
563 vec!["alpha".to_string(), "beta".to_string()]
564 );
565 }
566
567 #[test]
568 fn closure_with_paths_relativizes_and_sorts() {
569 let graph = build_reverse_dep_graph();
570 let closure = graph.impact_closure(&[FileId(0)]);
571 let paths = graph.closure_with_paths(&closure, Path::new("/p"));
572 assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
573 assert_eq!(
574 paths.affected_not_shown,
575 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
576 );
577 assert_eq!(paths.coordination_gap.len(), 1);
578 assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
579 assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
580 }
581
582 #[test]
583 fn empty_changed_set_yields_empty_closure() {
584 let graph = build_reverse_dep_graph();
585 let closure = graph.impact_closure(&[]);
586 assert!(closure.in_diff.is_empty());
587 assert!(closure.affected_not_shown.is_empty());
588 assert!(closure.coordination_gap.is_empty());
589 }
590}