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 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")].into(),
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")].into(),
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")].into(),
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 statement_span: oxc_span::Span::new(0, 0),
394 source_span: oxc_span::Span::new(0, 0),
395 },
396 target: ResolveResult::InternalModule(FileId(0)),
397 }],
398 ..Default::default()
399 },
400 ResolvedModule {
401 file_id: FileId(2),
402 path: PathBuf::from("/p/src/consumer.ts"),
403 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
404 ..Default::default()
405 },
406 ];
407 ModuleGraph::build(&resolved, &entry_points, &files)
408 }
409
410 #[test]
411 fn reverse_dep_closure_equals_hand_computed_set() {
412 let graph = build_reverse_dep_graph();
413 let closure = graph.impact_closure(&[FileId(0)]);
415 assert_eq!(closure.in_diff, vec![FileId(0)]);
416 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
417 }
418
419 #[test]
420 fn coordination_gap_fires_when_consumer_outside_diff() {
421 let graph = build_reverse_dep_graph();
422 let closure = graph.impact_closure(&[FileId(0)]);
424 assert_eq!(closure.coordination_gap.len(), 1);
425 let gap = &closure.coordination_gap[0];
426 assert_eq!(gap.changed_file, FileId(0));
427 assert_eq!(gap.consumer_file, FileId(1));
428 assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
429 }
430
431 #[test]
432 fn coordination_gap_skips_story_and_test_consumers() {
433 use fallow_types::discover::{EntryPoint, EntryPointSource};
434 let files = vec![
438 file(0, "/p/src/button.component.ts"),
439 file(1, "/p/src/button.stories.ts"),
440 file(2, "/p/src/panel.component.ts"),
441 ];
442 let entry_points = vec![EntryPoint {
443 path: PathBuf::from("/p/src/panel.component.ts"),
444 source: EntryPointSource::PackageJsonMain,
445 }];
446 let resolved = vec![
447 ResolvedModule {
448 file_id: FileId(0),
449 path: PathBuf::from("/p/src/button.component.ts"),
450 exports: vec![named_export("BzmButton")].into(),
451 ..Default::default()
452 },
453 ResolvedModule {
454 file_id: FileId(1),
455 path: PathBuf::from("/p/src/button.stories.ts"),
456 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
457 ..Default::default()
458 },
459 ResolvedModule {
460 file_id: FileId(2),
461 path: PathBuf::from("/p/src/panel.component.ts"),
462 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
463 ..Default::default()
464 },
465 ];
466 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
467 let closure = graph.impact_closure(&[FileId(0)]);
468 assert_eq!(closure.coordination_gap.len(), 1);
470 assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
471 assert!(closure.affected_not_shown.contains(&FileId(1)));
473 }
474
475 #[test]
476 fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
477 let graph = build_reverse_dep_graph();
478 let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
483 assert!(
484 closure
485 .coordination_gap
486 .iter()
487 .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
488 "no gap may name an in-diff consumer: {:?}",
489 closure.coordination_gap
490 );
491 assert!(
493 !closure
494 .coordination_gap
495 .iter()
496 .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
497 "core->mid must not fire when mid is in the diff"
498 );
499 }
500
501 #[test]
502 fn re_export_chain_closure_equals_hand_computed_set() {
503 let graph = build_re_export_graph();
504 let closure = graph.impact_closure(&[FileId(0)]);
508 assert_eq!(closure.in_diff, vec![FileId(0)]);
509 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
510 }
511
512 #[test]
513 fn re_export_chain_coordination_gap_fires_through_barrel() {
514 let graph = build_re_export_graph();
515 let closure = graph.impact_closure(&[FileId(0)]);
520 assert_eq!(closure.coordination_gap.len(), 1);
521 let gap = &closure.coordination_gap[0];
522 assert_eq!(gap.changed_file, FileId(0));
523 assert_eq!(gap.consumer_file, FileId(2));
524 assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
525 }
526
527 #[test]
528 fn coordination_gap_dedups_per_consumer_pair_r2() {
529 let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
532 let entry_points = vec![EntryPoint {
533 path: PathBuf::from("/p/src/app.ts"),
534 source: EntryPointSource::PackageJsonMain,
535 }];
536 let resolved = vec![
537 ResolvedModule {
538 file_id: FileId(0),
539 path: PathBuf::from("/p/src/core.ts"),
540 exports: vec![named_export("alpha"), named_export("beta")].into(),
541 ..Default::default()
542 },
543 ResolvedModule {
544 file_id: FileId(1),
545 path: PathBuf::from("/p/src/app.ts"),
546 resolved_imports: vec![
547 named_import("./core", "alpha", FileId(0)),
548 named_import("./core", "beta", FileId(0)),
549 ],
550 ..Default::default()
551 },
552 ];
553 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
554 let closure = graph.impact_closure(&[FileId(0)]);
555 assert_eq!(
556 closure.coordination_gap.len(),
557 1,
558 "R2: one entry per consumer pair"
559 );
560 assert_eq!(
561 closure.coordination_gap[0].consumed_symbols,
562 vec!["alpha".to_string(), "beta".to_string()]
563 );
564 }
565
566 #[test]
567 fn closure_with_paths_relativizes_and_sorts() {
568 let graph = build_reverse_dep_graph();
569 let closure = graph.impact_closure(&[FileId(0)]);
570 let paths = graph.closure_with_paths(&closure, Path::new("/p"));
571 assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
572 assert_eq!(
573 paths.affected_not_shown,
574 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
575 );
576 assert_eq!(paths.coordination_gap.len(), 1);
577 assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
578 assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
579 }
580
581 #[test]
582 fn empty_changed_set_yields_empty_closure() {
583 let graph = build_reverse_dep_graph();
584 let closure = graph.impact_closure(&[]);
585 assert!(closure.in_diff.is_empty());
586 assert!(closure.affected_not_shown.is_empty());
587 assert!(closure.coordination_gap.is_empty());
588 }
589}