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    changed_file: FileId,
31    /// The consumer module that imports the changed contract and is NOT in the diff.
32    consumer_file: FileId,
33    /// The exported symbol names the consumer references, sorted and deduped.
34    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    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    affected_not_shown: Vec<FileId>,
47    /// Coordination gaps: changed contracts consumed by non-diff modules.
48    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 with the trace output path 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                is_type_only_star: false,
304                from_style: false,
305                span: oxc_span::Span::new(0, 10),
306                source_span: oxc_span::Span::default(),
307            },
308            target: ResolveResult::InternalModule(target),
309        }
310    }
311
312    fn named_export(name: &str) -> ExportInfo {
313        ExportInfo {
314            name: ExportName::Named(name.to_string()),
315            local_name: Some(name.to_string()),
316            is_type_only: false,
317            visibility: VisibilityTag::None,
318            expected_unused_reason: None,
319            span: oxc_span::Span::new(0, 20),
320            members: vec![],
321            is_side_effect_used: false,
322            super_class: None,
323        }
324    }
325
326    /// Plain reverse-dep chain: core (0) <- mid (1) <- app (2).
327    /// app imports mid imports core; entry is app.
328    fn build_reverse_dep_graph() -> ModuleGraph {
329        let files = vec![
330            file(0, "/p/src/core.ts"),
331            file(1, "/p/src/mid.ts"),
332            file(2, "/p/src/app.ts"),
333        ];
334        let entry_points = vec![EntryPoint {
335            path: PathBuf::from("/p/src/app.ts"),
336            source: EntryPointSource::PackageJsonMain,
337        }];
338        let resolved = vec![
339            ResolvedModule {
340                file_id: FileId(0),
341                path: PathBuf::from("/p/src/core.ts"),
342                exports: vec![named_export("compute")].into(),
343                ..Default::default()
344            },
345            ResolvedModule {
346                file_id: FileId(1),
347                path: PathBuf::from("/p/src/mid.ts"),
348                resolved_imports: vec![named_import("./core", "compute", FileId(0))],
349                exports: vec![named_export("midFn")].into(),
350                ..Default::default()
351            },
352            ResolvedModule {
353                file_id: FileId(2),
354                path: PathBuf::from("/p/src/app.ts"),
355                resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
356                ..Default::default()
357            },
358        ];
359        ModuleGraph::build(&resolved, &entry_points, &files)
360    }
361
362    /// Re-export chain: impl (0) -> barrel (1) re-exports -> consumer (2) imports
363    /// from the barrel. entry is consumer.
364    fn build_re_export_graph() -> ModuleGraph {
365        use crate::resolve::ResolvedReExport;
366        use fallow_types::extract::ReExportInfo;
367
368        let files = vec![
369            file(0, "/p/src/impl.ts"),
370            file(1, "/p/src/barrel.ts"),
371            file(2, "/p/src/consumer.ts"),
372        ];
373        let entry_points = vec![EntryPoint {
374            path: PathBuf::from("/p/src/consumer.ts"),
375            source: EntryPointSource::PackageJsonMain,
376        }];
377        let resolved = vec![
378            ResolvedModule {
379                file_id: FileId(0),
380                path: PathBuf::from("/p/src/impl.ts"),
381                exports: vec![named_export("widget")].into(),
382                ..Default::default()
383            },
384            ResolvedModule {
385                file_id: FileId(1),
386                path: PathBuf::from("/p/src/barrel.ts"),
387                re_exports: vec![ResolvedReExport {
388                    info: ReExportInfo {
389                        source: "./impl".to_string(),
390                        imported_name: "widget".to_string(),
391                        exported_name: "widget".to_string(),
392                        is_type_only: false,
393                        span: oxc_span::Span::new(0, 10),
394                        statement_span: oxc_span::Span::new(0, 0),
395                        source_span: oxc_span::Span::new(0, 0),
396                    },
397                    target: ResolveResult::InternalModule(FileId(0)),
398                }],
399                ..Default::default()
400            },
401            ResolvedModule {
402                file_id: FileId(2),
403                path: PathBuf::from("/p/src/consumer.ts"),
404                resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
405                ..Default::default()
406            },
407        ];
408        ModuleGraph::build(&resolved, &entry_points, &files)
409    }
410
411    #[test]
412    fn reverse_dep_closure_equals_hand_computed_set() {
413        let graph = build_reverse_dep_graph();
414        // Change core.ts. Hand-computed reverse-dep closure = {mid, app}.
415        let closure = graph.impact_closure(&[FileId(0)]);
416        assert_eq!(closure.in_diff, vec![FileId(0)]);
417        assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
418    }
419
420    #[test]
421    fn coordination_gap_fires_when_consumer_outside_diff() {
422        let graph = build_reverse_dep_graph();
423        // core changed, mid (consumer of core.compute) is NOT in the diff -> fires.
424        let closure = graph.impact_closure(&[FileId(0)]);
425        assert_eq!(closure.coordination_gap.len(), 1);
426        let gap = &closure.coordination_gap[0];
427        assert_eq!(gap.changed_file, FileId(0));
428        assert_eq!(gap.consumer_file, FileId(1));
429        assert_eq!(gap.consumed_symbols, vec!["compute".to_string()]);
430    }
431
432    #[test]
433    fn coordination_gap_skips_story_and_test_consumers() {
434        use fallow_types::discover::{EntryPoint, EntryPointSource};
435        // button.component (0) is changed; consumed by a co-located story (1) AND a
436        // real panel component (2), both OUTSIDE the diff. Only the real consumer is
437        // a coordination gap; the story is dev-only glue that fails in its own run.
438        let files = vec![
439            file(0, "/p/src/button.component.ts"),
440            file(1, "/p/src/button.stories.ts"),
441            file(2, "/p/src/panel.component.ts"),
442        ];
443        let entry_points = vec![EntryPoint {
444            path: PathBuf::from("/p/src/panel.component.ts"),
445            source: EntryPointSource::PackageJsonMain,
446        }];
447        let resolved = vec![
448            ResolvedModule {
449                file_id: FileId(0),
450                path: PathBuf::from("/p/src/button.component.ts"),
451                exports: vec![named_export("BzmButton")].into(),
452                ..Default::default()
453            },
454            ResolvedModule {
455                file_id: FileId(1),
456                path: PathBuf::from("/p/src/button.stories.ts"),
457                resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
458                ..Default::default()
459            },
460            ResolvedModule {
461                file_id: FileId(2),
462                path: PathBuf::from("/p/src/panel.component.ts"),
463                resolved_imports: vec![named_import("./button.component", "BzmButton", FileId(0))],
464                ..Default::default()
465            },
466        ];
467        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
468        let closure = graph.impact_closure(&[FileId(0)]);
469        // Exactly one gap, on the real consumer; the story is NOT a gap.
470        assert_eq!(closure.coordination_gap.len(), 1);
471        assert_eq!(closure.coordination_gap[0].consumer_file, FileId(2));
472        // The story is still surfaced as affected (declassified, never hidden).
473        assert!(closure.affected_not_shown.contains(&FileId(1)));
474    }
475
476    #[test]
477    fn coordination_gap_does_not_fire_when_consumer_inside_diff() {
478        let graph = build_reverse_dep_graph();
479        // core AND mid both changed. mid is the only consumer of core.compute and
480        // it IS in the diff -> no gap for the core->mid pair. (mid->app may still
481        // fire, app is outside the diff; the invariant under test is that NO gap
482        // ever names a consumer that is inside the diff.)
483        let closure = graph.impact_closure(&[FileId(0), FileId(1)]);
484        assert!(
485            closure
486                .coordination_gap
487                .iter()
488                .all(|gap| gap.consumer_file != FileId(0) && gap.consumer_file != FileId(1)),
489            "no gap may name an in-diff consumer: {:?}",
490            closure.coordination_gap
491        );
492        // Specifically, the core->mid pair (consumer mid is in the diff) must not fire.
493        assert!(
494            !closure
495                .coordination_gap
496                .iter()
497                .any(|gap| gap.changed_file == FileId(0) && gap.consumer_file == FileId(1)),
498            "core->mid must not fire when mid is in the diff"
499        );
500    }
501
502    #[test]
503    fn re_export_chain_closure_equals_hand_computed_set() {
504        let graph = build_re_export_graph();
505        // Change impl.ts. Hand-computed closure through the re-export chain =
506        // {barrel, consumer}: barrel re-exports impl (a graph edge), consumer
507        // imports from barrel.
508        let closure = graph.impact_closure(&[FileId(0)]);
509        assert_eq!(closure.in_diff, vec![FileId(0)]);
510        assert_eq!(closure.affected_not_shown, vec![FileId(1), FileId(2)]);
511    }
512
513    #[test]
514    fn re_export_chain_coordination_gap_fires_through_barrel() {
515        let graph = build_re_export_graph();
516        // impl changed; re-export chain resolution credits impl.widget's reference
517        // to the TRUE consumer (consumer.ts, FileId 2), which imports it through the
518        // barrel. consumer is outside the diff -> fires on the real consumer (the
519        // higher-signal target than the intermediate barrel).
520        let closure = graph.impact_closure(&[FileId(0)]);
521        assert_eq!(closure.coordination_gap.len(), 1);
522        let gap = &closure.coordination_gap[0];
523        assert_eq!(gap.changed_file, FileId(0));
524        assert_eq!(gap.consumer_file, FileId(2));
525        assert_eq!(gap.consumed_symbols, vec!["widget".to_string()]);
526    }
527
528    #[test]
529    fn coordination_gap_dedups_per_consumer_pair_r2() {
530        // R2: a consumer importing TWO symbols from one changed file is ONE gap
531        // entry with both symbols, never two entries.
532        let files = vec![file(0, "/p/src/core.ts"), file(1, "/p/src/app.ts")];
533        let entry_points = vec![EntryPoint {
534            path: PathBuf::from("/p/src/app.ts"),
535            source: EntryPointSource::PackageJsonMain,
536        }];
537        let resolved = vec![
538            ResolvedModule {
539                file_id: FileId(0),
540                path: PathBuf::from("/p/src/core.ts"),
541                exports: vec![named_export("alpha"), named_export("beta")].into(),
542                ..Default::default()
543            },
544            ResolvedModule {
545                file_id: FileId(1),
546                path: PathBuf::from("/p/src/app.ts"),
547                resolved_imports: vec![
548                    named_import("./core", "alpha", FileId(0)),
549                    named_import("./core", "beta", FileId(0)),
550                ],
551                ..Default::default()
552            },
553        ];
554        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
555        let closure = graph.impact_closure(&[FileId(0)]);
556        assert_eq!(
557            closure.coordination_gap.len(),
558            1,
559            "R2: one entry per consumer pair"
560        );
561        assert_eq!(
562            closure.coordination_gap[0].consumed_symbols,
563            vec!["alpha".to_string(), "beta".to_string()]
564        );
565    }
566
567    #[test]
568    fn closure_with_paths_relativizes_and_sorts() {
569        let graph = build_reverse_dep_graph();
570        let closure = graph.impact_closure(&[FileId(0)]);
571        let paths = graph.closure_with_paths(&closure, Path::new("/p"));
572        assert_eq!(paths.in_diff, vec!["src/core.ts".to_string()]);
573        assert_eq!(
574            paths.affected_not_shown,
575            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
576        );
577        assert_eq!(paths.coordination_gap.len(), 1);
578        assert_eq!(paths.coordination_gap[0].changed_file, "src/core.ts");
579        assert_eq!(paths.coordination_gap[0].consumer_file, "src/mid.ts");
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}