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    /// Traversing `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        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    /// Traverse `reverse_deps` from the seed set, returning the bitset of files
111    /// reached but NOT in the seed (the affected-not-shown partition).
112    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    /// For each changed file, collect the consumers (via exported-symbol
134    /// references) that are OUTSIDE the diff, one [`CoordinationGap`] per distinct
135    /// (changed, consumer) pair with the consumed symbol names folded in.
136    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            // (changed, consumer) -> consumed symbol name set. R2: one entry per
143            // distinct consumer module, never per import statement.
144            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                        // Consumer is inside the diff: updated alongside, no gap.
154                        continue;
155                    }
156                    // Dev-only glue (stories / specs / tests) co-located with the
157                    // changed module is not a cross-module coordination contract: if
158                    // the symbol's shape changes, the story/spec fails loudly in its
159                    // own dev/CI run rather than hiding a production coordination
160                    // risk. Skip it here; it still appears in `affected_not_shown`.
161                    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    /// Resolve a closure's `FileId`s to root-relative, forward-slashed paths,
194    /// sorted for deterministic output. Files whose module is missing are dropped.
195    #[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
241/// True when `path` is a dev-only glue file (a Storybook story, a test/spec, a
242/// Cypress spec, or a file under a `__tests__` / `__mocks__` / `__stories__`
243/// directory). Such a consumer is NOT a cross-module coordination contract: a
244/// contract change surfaces in its own dev/CI run, never as a hidden production
245/// coordination gap. Co-located stories pairing with their component were the
246/// dominant low-value noise in the coordination-gap evidence.
247fn 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    /// Plain reverse-dep chain: core (0) <- mid (1) <- app (2).
315    /// app imports mid imports core; entry is app.
316    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    /// Re-export chain: impl (0) -> barrel (1) re-exports -> consumer (2) imports
351    /// from the barrel. entry is consumer.
352    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        // Change core.ts. Hand-computed reverse-dep closure = {mid, app}.
403        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        // core changed, mid (consumer of core.compute) is NOT in the diff -> fires.
412        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        // button.component (0) is changed; consumed by a co-located story (1) AND a
424        // real panel component (2), both OUTSIDE the diff. Only the real consumer is
425        // a coordination gap; the story is dev-only glue that fails in its own run.
426        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        // Exactly one gap, on the real consumer; the story is NOT a gap.
458        assert_eq!(closure.coordination_gap.len(), 1);
459        assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
460        // The story is still surfaced as affected (declassified, never hidden).
461        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        // core AND mid both changed. mid is the only consumer of core.compute and
468        // it IS in the diff -> no gap for the core->mid pair. (mid->app may still
469        // fire, app is outside the diff; the invariant under test is that NO gap
470        // ever names a consumer that is inside the diff.)
471        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        // Specifically, the core->mid pair (consumer mid is in the diff) must not fire.
481        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        // Change impl.ts. Hand-computed closure through the re-export chain =
494        // {barrel, consumer}: barrel re-exports impl (a graph edge), consumer
495        // imports from barrel.
496        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        // impl changed; re-export chain resolution credits impl.widget's reference
505        // to the TRUE consumer (consumer.ts, FileId 2), which imports it through the
506        // barrel. consumer is outside the diff -> fires on the real consumer (the
507        // higher-signal target than the intermediate barrel).
508        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        // R2: a consumer importing TWO symbols from one changed file is ONE gap
519        // entry with both symbols, never two entries.
520        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}