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;
16
17use super::relativize;
18
19use fallow_types::discover::FileId;
20use fixedbitset::FixedBitSet;
21use rustc_hash::FxHashMap;
22
23use super::ModuleGraph;
24
25/// A single coordination-gap entry: a changed file exports symbols consumed by a
26/// `consumer` module that is NOT in the diff. Deduped per (changed, consumer)
27/// PAIR (firing-precision rule R2): one entry per distinct consumer module, the
28/// consumed-symbol names folded in, never one entry per import statement.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CoordinationGap {
31    /// The changed file whose exported contract a non-diff module consumes.
32    changed_file: FileId,
33    /// The consumer module that imports the changed contract and is NOT in the diff.
34    consumer_file: FileId,
35    /// The exported symbol names the consumer references, sorted and deduped.
36    consumed_symbols: Vec<String>,
37}
38
39/// Result of an impact-closure computation. File partitions are `FileId` sets so
40/// the caller relativizes paths in its own path-space; [`ModuleGraph::closure_with_paths`]
41/// produces the root-relative path view for serialization.
42#[derive(Debug, Clone, Default)]
43pub struct ImpactClosure {
44    /// The seed (changed) files, the diff itself.
45    in_diff: Vec<FileId>,
46    /// Files transitively affected through `reverse_deps` (importers + re-export
47    /// chains) that do NOT appear in the diff. The differentiator set.
48    affected_not_shown: Vec<FileId>,
49    /// Coordination gaps: changed contracts consumed by non-diff modules.
50    coordination_gap: Vec<CoordinationGap>,
51}
52
53/// The same closure with `FileId`s resolved to root-relative, forward-slashed
54/// path strings, sorted for deterministic output.
55#[derive(Debug, Clone, Default)]
56pub struct ImpactClosurePaths {
57    /// Root-relative changed-file paths, sorted.
58    pub in_diff: Vec<String>,
59    /// Root-relative affected-but-not-shown paths, sorted.
60    pub affected_not_shown: Vec<String>,
61    /// Coordination gaps with paths resolved, sorted by (changed, consumer).
62    pub coordination_gap: Vec<CoordinationGapPaths>,
63}
64
65/// A [`CoordinationGap`] with `FileId`s resolved to root-relative paths.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct CoordinationGapPaths {
68    /// Root-relative path of the changed file.
69    pub changed_file: String,
70    /// Root-relative path of the non-diff consumer.
71    pub consumer_file: String,
72    /// Consumed symbol names, sorted.
73    pub consumed_symbols: Vec<String>,
74}
75
76impl ModuleGraph {
77    /// Compute the impact closure for a changed-file seed set.
78    ///
79    /// BFS over `reverse_deps` from every changed file yields the transitive
80    /// affected set; the seed partitions into `in_diff`, the rest into
81    /// `affected_not_shown`. The coordination gap walks each changed file's
82    /// exported-symbol references and reports those whose consumer is outside the
83    /// diff (rule R2: one entry per distinct consumer module).
84    ///
85    /// `changed` is a slice of `FileId`s; out-of-range or duplicate ids are
86    /// tolerated. Type-only re-export edges are skipped for the gap evidence so a
87    /// `import type`-only consumer (erased at build, no runtime contract) does not
88    /// fire.
89    #[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    /// BFS over `reverse_deps` from the seed set, returning the bitset of files
117    /// reached but NOT in the seed (the affected-not-shown partition).
118    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    /// For each changed file, collect the consumers (via exported-symbol
143    /// references) that are OUTSIDE the diff, one [`CoordinationGap`] per distinct
144    /// (changed, consumer) pair with the consumed symbol names folded in.
145    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            // (changed, consumer) -> consumed symbol name set. R2: one entry per
152            // distinct consumer module, never per import statement.
153            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                        // Consumer is inside the diff: updated alongside, no gap.
163                        continue;
164                    }
165                    // Dev-only glue (stories / specs / tests) co-located with the
166                    // changed module is not a cross-module coordination contract: if
167                    // the symbol's shape changes, the story/spec fails loudly in its
168                    // own dev/CI run rather than hiding a production coordination
169                    // risk. Skip it here; it still appears in `affected_not_shown`.
170                    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    /// Resolve a closure's `FileId`s to root-relative, forward-slashed paths,
203    /// sorted for deterministic output. Files whose module is missing are dropped.
204    #[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
250/// True when `path` is a dev-only glue file (a Storybook story, a test/spec, a
251/// Cypress spec, or a file under a `__tests__` / `__mocks__` / `__stories__`
252/// directory). Such a consumer is NOT a cross-module coordination contract: a
253/// contract change surfaces in its own dev/CI run, never as a hidden production
254/// coordination gap. Co-located stories pairing with their component were the
255/// dominant low-value noise in the coordination-gap evidence.
256fn 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    /// Plain reverse-dep chain: core (0) <- mid (1) <- app (2).
322    /// app imports mid imports core; entry is app.
323    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    /// Re-export chain: impl (0) -> barrel (1) re-exports -> consumer (2) imports
358    /// from the barrel. entry is consumer.
359    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        // Change core.ts. Hand-computed reverse-dep closure = {mid, app}.
410        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        // core changed, mid (consumer of core.compute) is NOT in the diff -> fires.
419        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        // button.component (0) is changed; consumed by a co-located story (1) AND a
431        // real panel component (2), both OUTSIDE the diff. Only the real consumer is
432        // a coordination gap; the story is dev-only glue that fails in its own run.
433        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        // Exactly one gap, on the real consumer; the story is NOT a gap.
465        assert_eq!(closure.coordination_gap.len(), 1);
466        assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
467        // The story is still surfaced as affected (declassified, never hidden).
468        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        // core AND mid both changed. mid is the only consumer of core.compute and
475        // it IS in the diff -> no gap for the core->mid pair. (mid->app may still
476        // fire, app is outside the diff; the invariant under test is that NO gap
477        // ever names a consumer that is inside the diff.)
478        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        // Specifically, the core->mid pair (consumer mid is in the diff) must not fire.
488        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        // Change impl.ts. Hand-computed closure through the re-export chain =
501        // {barrel, consumer}: barrel re-exports impl (a graph edge), consumer
502        // imports from barrel.
503        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        // impl changed; re-export chain resolution credits impl.widget's reference
512        // to the TRUE consumer (consumer.ts, FileId 2), which imports it through the
513        // barrel. consumer is outside the diff -> fires on the real consumer (the
514        // higher-signal target than the intermediate barrel).
515        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        // R2: a consumer importing TWO symbols from one changed file is ONE gap
526        // entry with both symbols, never two entries.
527        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}