Skip to main content

fallow_graph/graph/
impact_closure.rs

1//! Impact-closure engine: from a changed-file set, compute the transitive
2//! affected-but-NOT-in-diff set plus a coordination-gap detector.
3//!
4//! The differentiator a diff tool cannot do: a diff is changed lines, but the
5//! real risk is the transitive set of code those lines affect, most of which is
6//! NOT in the diff. This walks [`ModuleGraph::reverse_deps`] (which already folds
7//! re-export chains in, because a `export {x} from './changed'` is a real graph
8//! edge barrel->changed) and partitions the reached files into
9//! `{ in_diff, affected_not_shown }`, then reports the coordination gap: a changed
10//! EXPORTED symbol whose consumer modules are absent from the diff.
11//!
12//! Honest scope (ADR-001, syntactic): the coordination gap is an attention
13//! pointer at the exact inter-module failure mode, NOT a correctness proof.
14
15use std::path::{Path, PathBuf};
16
17use fallow_types::discover::FileId;
18use fixedbitset::FixedBitSet;
19use rustc_hash::FxHashMap;
20
21use super::ModuleGraph;
22
23/// A single coordination-gap entry: a changed file exports symbols consumed by a
24/// `consumer` module that is NOT in the diff. Deduped per (changed, consumer)
25/// PAIR (firing-precision rule R2): one entry per distinct consumer module, the
26/// consumed-symbol names folded in, never one entry per import statement.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CoordinationGap {
29    /// The changed file whose exported contract a non-diff module consumes.
30    pub changed_file: FileId,
31    /// The consumer module that imports the changed contract and is NOT in the diff.
32    pub consumer_file: FileId,
33    /// The exported symbol names the consumer references, sorted and deduped.
34    pub consumed_symbols: Vec<String>,
35}
36
37/// Result of an impact-closure computation. File partitions are `FileId` sets so
38/// the caller relativizes paths in its own path-space; [`ModuleGraph::closure_with_paths`]
39/// produces the root-relative path view for serialization.
40#[derive(Debug, Clone, Default)]
41pub struct ImpactClosure {
42    /// The seed (changed) files, the diff itself.
43    pub in_diff: Vec<FileId>,
44    /// Files transitively affected through `reverse_deps` (importers + re-export
45    /// chains) that do NOT appear in the diff. The differentiator set.
46    pub affected_not_shown: Vec<FileId>,
47    /// Coordination gaps: changed contracts consumed by non-diff modules.
48    pub coordination_gap: Vec<CoordinationGap>,
49}
50
51/// The same closure with `FileId`s resolved to root-relative, forward-slashed
52/// path strings, sorted for deterministic output.
53#[derive(Debug, Clone, Default)]
54pub struct ImpactClosurePaths {
55    /// Root-relative changed-file paths, sorted.
56    pub in_diff: Vec<String>,
57    /// Root-relative affected-but-not-shown paths, sorted.
58    pub affected_not_shown: Vec<String>,
59    /// Coordination gaps with paths resolved, sorted by (changed, consumer).
60    pub coordination_gap: Vec<CoordinationGapPaths>,
61}
62
63/// A [`CoordinationGap`] with `FileId`s resolved to root-relative paths.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct CoordinationGapPaths {
66    /// Root-relative path of the changed file.
67    pub changed_file: String,
68    /// Root-relative path of the non-diff consumer.
69    pub consumer_file: String,
70    /// Consumed symbol names, sorted.
71    pub consumed_symbols: Vec<String>,
72}
73
74impl ModuleGraph {
75    /// Compute the impact closure for a changed-file seed set.
76    ///
77    /// BFS over `reverse_deps` from every changed file yields the transitive
78    /// affected set; the seed partitions into `in_diff`, the rest into
79    /// `affected_not_shown`. The coordination gap walks each changed file's
80    /// exported-symbol references and reports those whose consumer is outside the
81    /// diff (rule R2: one entry per distinct consumer module).
82    ///
83    /// `changed` is a slice of `FileId`s; out-of-range or duplicate ids are
84    /// tolerated. Type-only re-export edges are skipped for the gap evidence so a
85    /// `import type`-only consumer (erased at build, no runtime contract) does not
86    /// fire.
87    #[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    /// BFS over `reverse_deps` from the seed set, returning the bitset of files
115    /// reached but NOT in the seed (the affected-not-shown partition).
116    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    /// For each changed file, collect the consumers (via exported-symbol
141    /// references) that are OUTSIDE the diff, one [`CoordinationGap`] per distinct
142    /// (changed, consumer) pair with the consumed symbol names folded in.
143    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            // (changed, consumer) -> consumed symbol name set. R2: one entry per
150            // distinct consumer module, never per import statement.
151            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                        // Consumer is inside the diff: updated alongside, no gap.
161                        continue;
162                    }
163                    // Dev-only glue (stories / specs / tests) co-located with the
164                    // changed module is not a cross-module coordination contract: if
165                    // the symbol's shape changes, the story/spec fails loudly in its
166                    // own dev/CI run rather than hiding a production coordination
167                    // risk. Skip it here; it still appears in `affected_not_shown`.
168                    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    /// Resolve a closure's `FileId`s to root-relative, forward-slashed paths,
201    /// sorted for deterministic output. Files whose module is missing are dropped.
202    #[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
248/// True when `path` is a dev-only glue file (a Storybook story, a test/spec, a
249/// Cypress spec, or a file under a `__tests__` / `__mocks__` / `__stories__`
250/// directory). Such a consumer is NOT a cross-module coordination contract: a
251/// contract change surfaces in its own dev/CI run, never as a hidden production
252/// coordination gap. Co-located stories pairing with their component were the
253/// dominant low-value noise in the coordination-gap evidence.
254fn 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
273/// Strip `root` and forward-slash-normalize a module path for cross-platform
274/// JSON parity (mirrors `fallow_core::trace`'s relativization).
275fn 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    /// Plain reverse-dep chain: core (0) <- mid (1) <- app (2).
326    /// app imports mid imports core; entry is app.
327    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    /// Re-export chain: impl (0) -> barrel (1) re-exports -> consumer (2) imports
362    /// from the barrel. entry is consumer.
363    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        // Change core.ts. Hand-computed reverse-dep closure = {mid, app}.
412        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        // core changed, mid (consumer of core.compute) is NOT in the diff -> fires.
421        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        // button.component (0) is changed; consumed by a co-located story (1) AND a
433        // real panel component (2), both OUTSIDE the diff. Only the real consumer is
434        // a coordination gap; the story is dev-only glue that fails in its own run.
435        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        // Exactly one gap, on the real consumer; the story is NOT a gap.
467        assert_eq!(closure.coordination_gap.len(), 1);
468        assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
469        // The story is still surfaced as affected (declassified, never hidden).
470        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        // core AND mid both changed. mid is the only consumer of core.compute and
477        // it IS in the diff -> no gap for the core->mid pair. (mid->app may still
478        // fire, app is outside the diff; the invariant under test is that NO gap
479        // ever names a consumer that is inside the diff.)
480        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        // Specifically, the core->mid pair (consumer mid is in the diff) must not fire.
490        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        // Change impl.ts. Hand-computed closure through the re-export chain =
503        // {barrel, consumer}: barrel re-exports impl (a graph edge), consumer
504        // imports from barrel.
505        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        // impl changed; re-export chain resolution credits impl.widget's reference
514        // to the TRUE consumer (consumer.ts, FileId 2), which imports it through the
515        // barrel. consumer is outside the diff -> fires on the real consumer (the
516        // higher-signal target than the intermediate barrel).
517        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        // R2: a consumer importing TWO symbols from one changed file is ONE gap
528        // entry with both symbols, never two entries.
529        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}