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.
247///
248/// This predicate stays separate from `fallow_engine::test_paths`. The engine
249/// crate depends on this crate, so the graph cannot call the engine. The
250/// semantics also differ: stories are dev glue here, but they are not test
251/// paths for the engine.
252fn is_dev_glue_path(path: &Path) -> bool {
253    let name = path
254        .file_name()
255        .and_then(|n| n.to_str())
256        .unwrap_or_default();
257    if [".stories.", ".story.", ".spec.", ".test.", ".cy."]
258        .iter()
259        .any(|marker| name.contains(marker))
260    {
261        return true;
262    }
263    path.components().any(|component| {
264        matches!(
265            component.as_os_str().to_str(),
266            Some("__tests__" | "__mocks__" | "__stories__")
267        )
268    })
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
275    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
276    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
277    use std::path::PathBuf;
278
279    fn file(id: u32, path: &str) -> DiscoveredFile {
280        DiscoveredFile {
281            id: FileId(id),
282            path: PathBuf::from(path),
283            size_bytes: 10,
284        }
285    }
286
287    fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
288        ResolvedImport {
289            info: ImportInfo {
290                source: source.to_string(),
291                imported_name: ImportedName::Named(name.to_string()),
292                local_name: name.to_string(),
293                is_type_only: false,
294                is_type_only_star: false,
295                from_style: false,
296                span: oxc_span::Span::new(0, 10),
297                source_span: oxc_span::Span::default(),
298            },
299            target: ResolveResult::InternalModule(target),
300        }
301    }
302
303    fn named_export(name: &str) -> ExportInfo {
304        ExportInfo {
305            name: ExportName::Named(name.to_string()),
306            local_name: Some(name.to_string()),
307            is_type_only: false,
308            visibility: VisibilityTag::None,
309            expected_unused_reason: None,
310            span: oxc_span::Span::new(0, 20),
311            members: vec![],
312            is_side_effect_used: false,
313            super_class: None,
314            deprecated: false,
315            deprecated_reason: None,
316        }
317    }
318
319    /// Plain reverse-dep chain: core (0) <- mid (1) <- app (2).
320    /// app imports mid imports core; entry is app.
321    fn build_reverse_dep_graph() -> ModuleGraph {
322        let files = vec![
323            file(0, "/p/src/core.ts"),
324            file(1, "/p/src/mid.ts"),
325            file(2, "/p/src/app.ts"),
326        ];
327        let entry_points = vec![EntryPoint {
328            path: PathBuf::from("/p/src/app.ts"),
329            source: EntryPointSource::PackageJsonMain,
330        }];
331        let resolved = vec![
332            ResolvedModule {
333                file_id: FileId(0),
334                path: PathBuf::from("/p/src/core.ts"),
335                exports: vec![named_export("compute")].into(),
336                ..Default::default()
337            },
338            ResolvedModule {
339                file_id: FileId(1),
340                path: PathBuf::from("/p/src/mid.ts"),
341                resolved_imports: vec![named_import("./core", "compute", FileId(0))],
342                exports: vec![named_export("midFn")].into(),
343                ..Default::default()
344            },
345            ResolvedModule {
346                file_id: FileId(2),
347                path: PathBuf::from("/p/src/app.ts"),
348                resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
349                ..Default::default()
350            },
351        ];
352        ModuleGraph::build(&resolved, &entry_points, &files)
353    }
354
355    /// Re-export chain: impl (0) -> barrel (1) re-exports -> consumer (2) imports
356    /// from the barrel. entry is consumer.
357    fn build_re_export_graph() -> ModuleGraph {
358        use crate::resolve::ResolvedReExport;
359        use fallow_types::extract::ReExportInfo;
360
361        let files = vec![
362            file(0, "/p/src/impl.ts"),
363            file(1, "/p/src/barrel.ts"),
364            file(2, "/p/src/consumer.ts"),
365        ];
366        let entry_points = vec![EntryPoint {
367            path: PathBuf::from("/p/src/consumer.ts"),
368            source: EntryPointSource::PackageJsonMain,
369        }];
370        let resolved = vec![
371            ResolvedModule {
372                file_id: FileId(0),
373                path: PathBuf::from("/p/src/impl.ts"),
374                exports: vec![named_export("widget")].into(),
375                ..Default::default()
376            },
377            ResolvedModule {
378                file_id: FileId(1),
379                path: PathBuf::from("/p/src/barrel.ts"),
380                re_exports: vec![ResolvedReExport {
381                    info: ReExportInfo {
382                        source: "./impl".to_string(),
383                        imported_name: "widget".to_string(),
384                        exported_name: "widget".to_string(),
385                        is_type_only: false,
386                        span: oxc_span::Span::new(0, 10),
387                        statement_span: oxc_span::Span::new(0, 0),
388                        source_span: oxc_span::Span::new(0, 0),
389                    },
390                    target: ResolveResult::InternalModule(FileId(0)),
391                }],
392                ..Default::default()
393            },
394            ResolvedModule {
395                file_id: FileId(2),
396                path: PathBuf::from("/p/src/consumer.ts"),
397                resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
398                ..Default::default()
399            },
400        ];
401        ModuleGraph::build(&resolved, &entry_points, &files)
402    }
403
404    #[test]
405    fn reverse_dep_closure_equals_hand_computed_set() {
406        let graph = build_reverse_dep_graph();
407        // Change core.ts. Hand-computed reverse-dep closure = {mid, app}.
408        let closure = graph.impact_closure(&[FileId(0)]);
409        assert_eq!(closure.in_diff, vec![FileId(0)]);
410        assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
411    }
412
413    #[test]
414    fn coordination_gap_fires_when_consumer_outside_diff() {
415        let graph = build_reverse_dep_graph();
416        // core changed, mid (consumer of core.compute) is NOT in the diff -> fires.
417        let closure = graph.impact_closure(&[FileId(0)]);
418        assert_eq!(closure.coordination_gap.len(), 1);
419        let gap = &closure.coordination_gap[0];
420        assert_eq!(gap.changed_file, FileId(0));
421        assert_eq!(gap.consumer_file, FileId(1));
422        assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
423    }
424
425    #[test]
426    fn coordination_gap_skips_story_and_test_consumers() {
427        use fallow_types::discover::{EntryPoint, EntryPointSource};
428        // button.component (0) is changed; consumed by a co-located story (1) AND a
429        // real panel component (2), both OUTSIDE the diff. Only the real consumer is
430        // a coordination gap; the story is dev-only glue that fails in its own run.
431        let files = vec![
432            file(0, "/p/src/button.component.ts"),
433            file(1, "/p/src/button.stories.ts"),
434            file(2, "/p/src/panel.component.ts"),
435        ];
436        let entry_points = vec![EntryPoint {
437            path: PathBuf::from("/p/src/panel.component.ts"),
438            source: EntryPointSource::PackageJsonMain,
439        }];
440        let resolved = vec![
441            ResolvedModule {
442                file_id: FileId(0),
443                path: PathBuf::from("/p/src/button.component.ts"),
444                exports: vec![named_export("BzmButton")].into(),
445                ..Default::default()
446            },
447            ResolvedModule {
448                file_id: FileId(1),
449                path: PathBuf::from("/p/src/button.stories.ts"),
450                resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
451                ..Default::default()
452            },
453            ResolvedModule {
454                file_id: FileId(2),
455                path: PathBuf::from("/p/src/panel.component.ts"),
456                resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
457                ..Default::default()
458            },
459        ];
460        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
461        let closure = graph.impact_closure(&[FileId(0)]);
462        // Exactly one gap, on the real consumer; the story is NOT a gap.
463        assert_eq!(closure.coordination_gap.len(), 1);
464        assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
465        // The story is still surfaced as affected (declassified, never hidden).
466        assert!(closure.affected_not_shown.contains(&FileId(1)));
467    }
468
469    #[test]
470    fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
471        let graph = build_reverse_dep_graph();
472        // core AND mid both changed. mid is the only consumer of core.compute and
473        // it IS in the diff -> no gap for the core->mid pair. (mid->app may still
474        // fire, app is outside the diff; the invariant under test is that NO gap
475        // ever names a consumer that is inside the diff.)
476        let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
477        assert!(
478            closure
479                .coordination_gap
480                .iter()
481                .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
482            "no gap may name an in-diff consumer: {:?}",
483            closure.coordination_gap
484        );
485        // Specifically, the core->mid pair (consumer mid is in the diff) must not fire.
486        assert!(
487            !closure
488                .coordination_gap
489                .iter()
490                .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
491            "core->mid must not fire when mid is in the diff"
492        );
493    }
494
495    #[test]
496    fn re_export_chain_closure_equals_hand_computed_set() {
497        let graph = build_re_export_graph();
498        // Change impl.ts. Hand-computed closure through the re-export chain =
499        // {barrel, consumer}: barrel re-exports impl (a graph edge), consumer
500        // imports from barrel.
501        let closure = graph.impact_closure(&[FileId(0)]);
502        assert_eq!(closure.in_diff, vec![FileId(0)]);
503        assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
504    }
505
506    #[test]
507    fn re_export_chain_coordination_gap_fires_through_barrel() {
508        let graph = build_re_export_graph();
509        // impl changed; re-export chain resolution credits impl.widget's reference
510        // to the TRUE consumer (consumer.ts, FileId 2), which imports it through the
511        // barrel. consumer is outside the diff -> fires on the real consumer (the
512        // higher-signal target than the intermediate barrel).
513        let closure = graph.impact_closure(&[FileId(0)]);
514        assert_eq!(closure.coordination_gap.len(), 1);
515        let gap = &closure.coordination_gap[0];
516        assert_eq!(gap.changed_file, FileId(0));
517        assert_eq!(gap.consumer_file, FileId(2));
518        assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
519    }
520
521    #[test]
522    fn coordination_gap_dedups_per_consumer_pair_r2() {
523        // R2: a consumer importing TWO symbols from one changed file is ONE gap
524        // entry with both symbols, never two entries.
525        let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
526        let entry_points = vec![EntryPoint {
527            path: PathBuf::from("/p/src/app.ts"),
528            source: EntryPointSource::PackageJsonMain,
529        }];
530        let resolved = vec![
531            ResolvedModule {
532                file_id: FileId(0),
533                path: PathBuf::from("/p/src/core.ts"),
534                exports: vec![named_export("alpha"), named_export("beta")].into(),
535                ..Default::default()
536            },
537            ResolvedModule {
538                file_id: FileId(1),
539                path: PathBuf::from("/p/src/app.ts"),
540                resolved_imports: vec![
541                    named_import("./core", "alpha", FileId(0)),
542                    named_import("./core", "beta", FileId(0)),
543                ],
544                ..Default::default()
545            },
546        ];
547        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
548        let closure = graph.impact_closure(&[FileId(0)]);
549        assert_eq!(
550            closure.coordination_gap.len(),
551            1,
552            "R2: one entry per consumer pair"
553        );
554        assert_eq!(
555            closure.coordination_gap[0].consumed_symbols,
556            vec!["alpha".to_string(), "beta".to_string()]
557        );
558    }
559
560    #[test]
561    fn closure_with_paths_relativizes_and_sorts() {
562        let graph = build_reverse_dep_graph();
563        let closure = graph.impact_closure(&[FileId(0)]);
564        let paths = graph.closure_with_paths(&closure, Path::new("/p"));
565        assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
566        assert_eq!(
567            paths.affected_not_shown,
568            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
569        );
570        assert_eq!(paths.coordination_gap.len(), 1);
571        assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
572        assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
573    }
574
575    #[test]
576    fn closure_partitions_cyclic_graph_with_repeated_and_invalid_seeds() {
577        let mut graph = build_reverse_dep_graph();
578        graph.reverse_deps[2].push(FileId(0));
579        graph.reverse_deps[1].push(FileId(u32::MAX));
580
581        let closure = graph.impact_closure(&[FileId(1), FileId(u32::MAX), FileId(0), FileId(1)]);
582
583        assert_eq!(closure.in_diff, vec![FileId(0), FileId(1)]);
584        assert_eq!(closure.affected_not_shown, vec![FileId(2)]);
585    }
586
587    #[test]
588    fn empty_changed_set_yields_empty_closure() {
589        let graph = build_reverse_dep_graph();
590        let closure = graph.impact_closure(&[]);
591        assert!(closure.in_diff.is_empty());
592        assert!(closure.affected_not_shown.is_empty());
593        assert!(closure.coordination_gap.is_empty());
594    }
595}