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 ImpactClosure {
104 in_diff: in_diff_set.ones().map(|i| FileId(i as u32)).collect(),
105 affected_not_shown: affected.ones().map(|i| FileId(i as u32)).collect(),
106 coordination_gap,
107 }
108 }
109
110 fn collect_reverse_closure(&self, seed: &FixedBitSet, capacity: usize) -> FixedBitSet {
113 let mut visited = seed.clone();
114 let mut stack: Vec<FileId> = seed.ones().map(|i| FileId(i as u32)).collect();
115
116 while let Some(current) = stack.pop() {
117 let Some(importers) = self.reverse_deps.get(current.0 as usize) else {
118 continue;
119 };
120 for &importer in importers {
121 let idx = importer.0 as usize;
122 if idx >= capacity || visited.contains(idx) {
123 continue;
124 }
125 visited.insert(idx);
126 stack.push(importer);
127 }
128 }
129 visited.difference_with(seed);
130 visited
131 }
132
133 fn collect_coordination_gaps(&self, in_diff_set: &FixedBitSet) -> Vec<CoordinationGap> {
137 let mut gaps: Vec<CoordinationGap> = Vec::new();
138 for changed_idx in in_diff_set.ones() {
139 let Some(module) = self.modules.get(changed_idx) else {
140 continue;
141 };
142 let mut by_consumer: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
145 for export in &module.exports {
146 if export.is_type_only {
147 continue;
148 }
149 let symbol_name = export.name.to_string();
150 for reference in &export.references {
151 let consumer_idx = reference.from_file.0 as usize;
152 if in_diff_set.contains(consumer_idx) {
153 continue;
155 }
156 if self
162 .modules
163 .get(consumer_idx)
164 .is_some_and(|m| is_dev_glue_path(&m.path))
165 {
166 continue;
167 }
168 by_consumer
169 .entry(reference.from_file)
170 .or_default()
171 .push(symbol_name.clone());
172 }
173 }
174 for (consumer_file, mut symbols) in by_consumer {
175 symbols.sort_unstable();
176 symbols.dedup();
177 gaps.push(CoordinationGap {
178 changed_file: FileId(changed_idx as u32),
179 consumer_file,
180 consumed_symbols: symbols,
181 });
182 }
183 }
184 gaps.sort_unstable_by(|a, b| {
185 a.changed_file
186 .0
187 .cmp(&b.changed_file.0)
188 .then_with(|| a.consumer_file.0.cmp(&b.consumer_file.0))
189 });
190 gaps
191 }
192
193 #[must_use]
196 pub fn closure_with_paths(&self, closure: &ImpactClosure, root: &Path) -> ImpactClosurePaths {
197 let resolve = |id: FileId| -> Option<String> {
198 self.modules
199 .get(id.0 as usize)
200 .map(|m| relativize(&m.path, root))
201 };
202
203 let mut in_diff: Vec<String> = closure
204 .in_diff
205 .iter()
206 .filter_map(|&id| resolve(id))
207 .collect();
208 in_diff.sort();
209 let mut affected_not_shown: Vec<String> = closure
210 .affected_not_shown
211 .iter()
212 .filter_map(|&id| resolve(id))
213 .collect();
214 affected_not_shown.sort();
215
216 let mut coordination_gap: Vec<CoordinationGapPaths> = closure
217 .coordination_gap
218 .iter()
219 .filter_map(|gap| {
220 Some(CoordinationGapPaths {
221 changed_file: resolve(gap.changed_file)?,
222 consumer_file: resolve(gap.consumer_file)?,
223 consumed_symbols: gap.consumed_symbols.clone(),
224 })
225 })
226 .collect();
227 coordination_gap.sort_by(|a, b| {
228 a.changed_file
229 .cmp(&b.changed_file)
230 .then_with(|| a.consumer_file.cmp(&b.consumer_file))
231 });
232
233 ImpactClosurePaths {
234 in_diff,
235 affected_not_shown,
236 coordination_gap,
237 }
238 }
239}
240
241fn is_dev_glue_path(path: &Path) -> bool {
248 let name = path
249 .file_name()
250 .and_then(|n| n.to_str())
251 .unwrap_or_default();
252 if [".stories.", ".story.", ".spec.", ".test.", ".cy."]
253 .iter()
254 .any(|marker| name.contains(marker))
255 {
256 return true;
257 }
258 path.components().any(|component| {
259 matches!(
260 component.as_os_str().to_str(),
261 Some("__tests__" | "__mocks__" | "__stories__")
262 )
263 })
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
270 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
271 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
272 use std::path::PathBuf;
273
274 fn file(id: u32, path: &str) -> DiscoveredFile {
275 DiscoveredFile {
276 id: FileId(id),
277 path: PathBuf::from(path),
278 size_bytes: 10,
279 }
280 }
281
282 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
283 ResolvedImport {
284 info: ImportInfo {
285 source: source.to_string(),
286 imported_name: ImportedName::Named(name.to_string()),
287 local_name: name.to_string(),
288 is_type_only: false,
289 is_type_only_star: false,
290 from_style: false,
291 span: oxc_span::Span::new(0, 10),
292 source_span: oxc_span::Span::default(),
293 },
294 target: ResolveResult::InternalModule(target),
295 }
296 }
297
298 fn named_export(name: &str) -> ExportInfo {
299 ExportInfo {
300 name: ExportName::Named(name.to_string()),
301 local_name: Some(name.to_string()),
302 is_type_only: false,
303 visibility: VisibilityTag::None,
304 expected_unused_reason: None,
305 span: oxc_span::Span::new(0, 20),
306 members: vec![],
307 is_side_effect_used: false,
308 super_class: None,
309 deprecated: false,
310 deprecated_reason: None,
311 }
312 }
313
314 fn build_reverse_dep_graph() -> ModuleGraph {
317 let files = vec![
318 file(0, "/p/src/core.ts"),
319 file(1, "/p/src/mid.ts"),
320 file(2, "/p/src/app.ts"),
321 ];
322 let entry_points = vec![EntryPoint {
323 path: PathBuf::from("/p/src/app.ts"),
324 source: EntryPointSource::PackageJsonMain,
325 }];
326 let resolved = vec![
327 ResolvedModule {
328 file_id: FileId(0),
329 path: PathBuf::from("/p/src/core.ts"),
330 exports: vec![named_export("compute")].into(),
331 ..Default::default()
332 },
333 ResolvedModule {
334 file_id: FileId(1),
335 path: PathBuf::from("/p/src/mid.ts"),
336 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
337 exports: vec![named_export("midFn")].into(),
338 ..Default::default()
339 },
340 ResolvedModule {
341 file_id: FileId(2),
342 path: PathBuf::from("/p/src/app.ts"),
343 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
344 ..Default::default()
345 },
346 ];
347 ModuleGraph::build(&resolved, &entry_points, &files)
348 }
349
350 fn build_re_export_graph() -> ModuleGraph {
353 use crate::resolve::ResolvedReExport;
354 use fallow_types::extract::ReExportInfo;
355
356 let files = vec![
357 file(0, "/p/src/impl.ts"),
358 file(1, "/p/src/barrel.ts"),
359 file(2, "/p/src/consumer.ts"),
360 ];
361 let entry_points = vec![EntryPoint {
362 path: PathBuf::from("/p/src/consumer.ts"),
363 source: EntryPointSource::PackageJsonMain,
364 }];
365 let resolved = vec![
366 ResolvedModule {
367 file_id: FileId(0),
368 path: PathBuf::from("/p/src/impl.ts"),
369 exports: vec![named_export("widget")].into(),
370 ..Default::default()
371 },
372 ResolvedModule {
373 file_id: FileId(1),
374 path: PathBuf::from("/p/src/barrel.ts"),
375 re_exports: vec![ResolvedReExport {
376 info: ReExportInfo {
377 source: "./impl".to_string(),
378 imported_name: "widget".to_string(),
379 exported_name: "widget".to_string(),
380 is_type_only: false,
381 span: oxc_span::Span::new(0, 10),
382 statement_span: oxc_span::Span::new(0, 0),
383 source_span: oxc_span::Span::new(0, 0),
384 },
385 target: ResolveResult::InternalModule(FileId(0)),
386 }],
387 ..Default::default()
388 },
389 ResolvedModule {
390 file_id: FileId(2),
391 path: PathBuf::from("/p/src/consumer.ts"),
392 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
393 ..Default::default()
394 },
395 ];
396 ModuleGraph::build(&resolved, &entry_points, &files)
397 }
398
399 #[test]
400 fn reverse_dep_closure_equals_hand_computed_set() {
401 let graph = build_reverse_dep_graph();
402 let closure = graph.impact_closure(&[FileId(0)]);
404 assert_eq!(closure.in_diff, vec![FileId(0)]);
405 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
406 }
407
408 #[test]
409 fn coordination_gap_fires_when_consumer_outside_diff() {
410 let graph = build_reverse_dep_graph();
411 let closure = graph.impact_closure(&[FileId(0)]);
413 assert_eq!(closure.coordination_gap.len(), 1);
414 let gap = &closure.coordination_gap[0];
415 assert_eq!(gap.changed_file, FileId(0));
416 assert_eq!(gap.consumer_file, FileId(1));
417 assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
418 }
419
420 #[test]
421 fn coordination_gap_skips_story_and_test_consumers() {
422 use fallow_types::discover::{EntryPoint, EntryPointSource};
423 let files = vec![
427 file(0, "/p/src/button.component.ts"),
428 file(1, "/p/src/button.stories.ts"),
429 file(2, "/p/src/panel.component.ts"),
430 ];
431 let entry_points = vec![EntryPoint {
432 path: PathBuf::from("/p/src/panel.component.ts"),
433 source: EntryPointSource::PackageJsonMain,
434 }];
435 let resolved = vec![
436 ResolvedModule {
437 file_id: FileId(0),
438 path: PathBuf::from("/p/src/button.component.ts"),
439 exports: vec![named_export("BzmButton")].into(),
440 ..Default::default()
441 },
442 ResolvedModule {
443 file_id: FileId(1),
444 path: PathBuf::from("/p/src/button.stories.ts"),
445 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
446 ..Default::default()
447 },
448 ResolvedModule {
449 file_id: FileId(2),
450 path: PathBuf::from("/p/src/panel.component.ts"),
451 resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
452 ..Default::default()
453 },
454 ];
455 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
456 let closure = graph.impact_closure(&[FileId(0)]);
457 assert_eq!(closure.coordination_gap.len(), 1);
459 assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
460 assert!(closure.affected_not_shown.contains(&FileId(1)));
462 }
463
464 #[test]
465 fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
466 let graph = build_reverse_dep_graph();
467 let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
472 assert!(
473 closure
474 .coordination_gap
475 .iter()
476 .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
477 "no gap may name an in-diff consumer: {:?}",
478 closure.coordination_gap
479 );
480 assert!(
482 !closure
483 .coordination_gap
484 .iter()
485 .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
486 "core->mid must not fire when mid is in the diff"
487 );
488 }
489
490 #[test]
491 fn re_export_chain_closure_equals_hand_computed_set() {
492 let graph = build_re_export_graph();
493 let closure = graph.impact_closure(&[FileId(0)]);
497 assert_eq!(closure.in_diff, vec![FileId(0)]);
498 assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
499 }
500
501 #[test]
502 fn re_export_chain_coordination_gap_fires_through_barrel() {
503 let graph = build_re_export_graph();
504 let closure = graph.impact_closure(&[FileId(0)]);
509 assert_eq!(closure.coordination_gap.len(), 1);
510 let gap = &closure.coordination_gap[0];
511 assert_eq!(gap.changed_file, FileId(0));
512 assert_eq!(gap.consumer_file, FileId(2));
513 assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
514 }
515
516 #[test]
517 fn coordination_gap_dedups_per_consumer_pair_r2() {
518 let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
521 let entry_points = vec![EntryPoint {
522 path: PathBuf::from("/p/src/app.ts"),
523 source: EntryPointSource::PackageJsonMain,
524 }];
525 let resolved = vec![
526 ResolvedModule {
527 file_id: FileId(0),
528 path: PathBuf::from("/p/src/core.ts"),
529 exports: vec![named_export("alpha"), named_export("beta")].into(),
530 ..Default::default()
531 },
532 ResolvedModule {
533 file_id: FileId(1),
534 path: PathBuf::from("/p/src/app.ts"),
535 resolved_imports: vec![
536 named_import("./core", "alpha", FileId(0)),
537 named_import("./core", "beta", FileId(0)),
538 ],
539 ..Default::default()
540 },
541 ];
542 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
543 let closure = graph.impact_closure(&[FileId(0)]);
544 assert_eq!(
545 closure.coordination_gap.len(),
546 1,
547 "R2: one entry per consumer pair"
548 );
549 assert_eq!(
550 closure.coordination_gap[0].consumed_symbols,
551 vec!["alpha".to_string(), "beta".to_string()]
552 );
553 }
554
555 #[test]
556 fn closure_with_paths_relativizes_and_sorts() {
557 let graph = build_reverse_dep_graph();
558 let closure = graph.impact_closure(&[FileId(0)]);
559 let paths = graph.closure_with_paths(&closure, Path::new("/p"));
560 assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
561 assert_eq!(
562 paths.affected_not_shown,
563 vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
564 );
565 assert_eq!(paths.coordination_gap.len(), 1);
566 assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
567 assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
568 }
569
570 #[test]
571 fn closure_partitions_cyclic_graph_with_repeated_and_invalid_seeds() {
572 let mut graph = build_reverse_dep_graph();
573 graph.reverse_deps[2].push(FileId(0));
574 graph.reverse_deps[1].push(FileId(u32::MAX));
575
576 let closure = graph.impact_closure(&[FileId(1), FileId(u32::MAX), FileId(0), FileId(1)]);
577
578 assert_eq!(closure.in_diff, vec![FileId(0), FileId(1)]);
579 assert_eq!(closure.affected_not_shown, vec![FileId(2)]);
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}