Skip to main content

fallow_graph/graph/
fan_io.rs

1//! Fan-in / fan-out + focus graph facts: from a changed-file set, compute
2//! the per-file graph blast-radius signals (fan-IN = importers, fan-OUT = forward
3//! deps) and the two confidence-flag signals (dynamic dispatch, re-export
4//! indirection) that the weighted focus map (`audit_focus.rs`) consumes.
5//!
6//! This is the graph-crate half of the focus map: all `ModuleGraph` access
7//! lives here (mirroring `impact_closure` / `partition_order`), so the CLI focus
8//! extractor stays a pure function of these resolved facts. The fan-in/out is the
9//! roadmap stage-4 "fan-in / fan-out (graph): reverse-deps + forward-deps; high
10//! fan-in = high blast radius" signal; the confidence flags are the
11//! "dynamically-wired / re-export-heavy code is not silently de-prioritized"
12//! guard.
13//!
14//! Determinism (matching the partition + order engine): the engine is a pure function of
15//! `(graph, changed_file_ids)`. No timestamps, no randomness, no float scoring.
16//! No `FxHashMap` iteration order reaches output: every collection is sorted
17//! before serialization in the path-resolved view.
18
19use std::path::{Path, PathBuf};
20
21use fallow_types::discover::FileId;
22use rustc_hash::FxHashSet;
23
24use super::{ModuleGraph, ReferenceKind};
25
26/// Per-file graph facts for one changed file, used by the focus map.
27///
28/// `fan_in` / `fan_out` are the blast-radius signals; `dynamic_dispatch` and
29/// `re_export_indirection` are the confidence-flag signals (a file that MAY be
30/// reached through dynamic dispatch or re-export indirection carries the flag so
31/// its static-reachability signal is not trusted as complete). `FileId`-keyed;
32/// the caller path-resolves via [`ModuleGraph::focus_facts_with_paths`].
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct FocusFileFacts {
35    /// The changed file these facts describe.
36    file: FileId,
37    /// Count of DISTINCT files importing this file (fan-in / blast radius).
38    /// Excludes the changed file itself.
39    fan_in: u32,
40    /// Count of DISTINCT forward-dependency files this file imports (fan-out).
41    /// Excludes the changed file itself.
42    fan_out: u32,
43    /// Whether this file is wired through dynamic dispatch: it has any outgoing
44    /// dynamic-import edge OR is referenced by another file via a `DynamicImport`
45    /// reference (DI / decorators / plugin-loader / `React.lazy` patterns the
46    /// static graph cannot fully resolve). Drives the `low: dynamic dispatch
47    /// detected` confidence flag. Conservative (over-flags): a file that MAY be
48    /// dynamically wired carries the flag.
49    dynamic_dispatch: bool,
50    /// Whether this file's reachability runs through re-export indirection: it is
51    /// a re-export barrel (has its own `re_exports`), is a re-export SOURCE of a
52    /// barrel, or is referenced via a `ReExport` reference. Drives the `low:
53    /// re-export indirection` confidence flag.
54    re_export_indirection: bool,
55}
56
57/// The same per-file facts with the `FileId` resolved to a root-relative,
58/// forward-slashed path string, sorted for deterministic output.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FocusFileFactsPaths {
61    /// Root-relative, forward-slashed path of the changed file.
62    pub file: String,
63    /// Fan-in count (importers).
64    pub fan_in: u32,
65    /// Fan-out count (forward deps).
66    pub fan_out: u32,
67    /// Dynamic-dispatch confidence signal.
68    pub dynamic_dispatch: bool,
69    /// Re-export-indirection confidence signal.
70    pub re_export_indirection: bool,
71}
72
73/// Signal sets for `focus_file_facts`, built in one pass over every module.
74struct ReferenceSignalSets {
75    /// Files referenced via a `DynamicImport` reference (target direction).
76    dynamic_targets: FxHashSet<FileId>,
77    /// Files referenced via a `ReExport` reference (target direction).
78    re_export_ref_targets: FxHashSet<FileId>,
79    /// Files that originate a `DynamicImport` reference (source direction).
80    dynamic_sources: FxHashSet<FileId>,
81    /// Files some barrel re-exports from (re-export source direction).
82    re_export_sources: FxHashSet<FileId>,
83}
84
85impl ModuleGraph {
86    /// Compute the per-file focus graph facts (fan-in/out + the two
87    /// confidence-flag signals) for a changed-file seed set.
88    ///
89    /// Out-of-range or duplicate ids in `changed` are tolerated (dropped /
90    /// deduped). Each fact is keyed by the changed file's `FileId`; the caller
91    /// relativizes via [`ModuleGraph::focus_facts_with_paths`] for serialization.
92    #[must_use]
93    pub fn focus_file_facts(&self, changed: &[FileId]) -> Vec<FocusFileFacts> {
94        // Dedup + drop out-of-range ids into a stable working set.
95        let mut seen = FxHashSet::default();
96        let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
97        for &id in changed {
98            if (id.0 as usize) < self.modules.len() && seen.insert(id) {
99                changed_ids.push(id);
100            }
101        }
102        changed_ids.sort_unstable_by_key(|f| f.0);
103
104        // A file participates in DynamicImport / ReExport when ANY export on
105        // ANY module carries such a reference TO or FROM it. Build the signal
106        // sets once, so the per-changed-file lookups are O(1).
107        let reference_signals = self.collect_reference_signal_sets();
108
109        changed_ids
110            .iter()
111            .map(|&id| {
112                let fan_in = self.fan_in_count(id);
113                let fan_out = self.fan_out_count(id);
114                let dynamic_dispatch = reference_signals.dynamic_targets.contains(&id)
115                    || reference_signals.dynamic_sources.contains(&id);
116                let re_export_indirection = self.is_re_export_participant(id, &reference_signals);
117                FocusFileFacts {
118                    file: id,
119                    fan_in,
120                    fan_out,
121                    dynamic_dispatch,
122                    re_export_indirection,
123                }
124            })
125            .collect()
126    }
127
128    /// Distinct count of files importing `file` (fan-in), excluding `file`.
129    fn fan_in_count(&self, file: FileId) -> u32 {
130        let Some(importers) = self.reverse_deps.get(file.0 as usize) else {
131            return 0;
132        };
133        let mut distinct: FxHashSet<FileId> = FxHashSet::default();
134        for &importer in importers {
135            if importer != file {
136                distinct.insert(importer);
137            }
138        }
139        u32::try_from(distinct.len()).unwrap_or(u32::MAX)
140    }
141
142    /// Distinct count of forward-dependency files `file` imports (fan-out),
143    /// excluding self-edges.
144    fn fan_out_count(&self, file: FileId) -> u32 {
145        let mut distinct: FxHashSet<FileId> = FxHashSet::default();
146        for target in self.edges_for(file) {
147            if target != file {
148                distinct.insert(target);
149            }
150        }
151        u32::try_from(distinct.len()).unwrap_or(u32::MAX)
152    }
153
154    /// Build reference signal sets in one pass over every module.
155    fn collect_reference_signal_sets(&self) -> ReferenceSignalSets {
156        let mut dynamic_targets: FxHashSet<FileId> = FxHashSet::default();
157        let mut re_export_ref_targets: FxHashSet<FileId> = FxHashSet::default();
158        let mut dynamic_sources: FxHashSet<FileId> = FxHashSet::default();
159        let mut re_export_sources: FxHashSet<FileId> = FxHashSet::default();
160        for node in &self.modules {
161            for edge in &node.re_exports {
162                re_export_sources.insert(edge.source_file);
163            }
164            for export in &node.exports {
165                for reference in &export.references {
166                    match reference.kind {
167                        ReferenceKind::DynamicImport => {
168                            dynamic_targets.insert(node.file_id);
169                            dynamic_sources.insert(reference.from_file);
170                        }
171                        ReferenceKind::ReExport => {
172                            re_export_ref_targets.insert(node.file_id);
173                        }
174                        _ => {}
175                    }
176                }
177            }
178        }
179        ReferenceSignalSets {
180            dynamic_targets,
181            re_export_ref_targets,
182            dynamic_sources,
183            re_export_sources,
184        }
185    }
186
187    /// Whether `file` participates in re-export indirection: it is a re-export
188    /// barrel (declares its own `re_exports`), it is a re-export SOURCE of some
189    /// barrel, or it is referenced via a `ReExport` reference (the
190    /// `re_export_ref_targets` membership).
191    fn is_re_export_participant(&self, file: FileId, sets: &ReferenceSignalSets) -> bool {
192        if sets.re_export_ref_targets.contains(&file) {
193            return true;
194        }
195        // Barrel: declares its own re-exports.
196        if let Some(node) = self.modules.get(file.0 as usize)
197            && !node.re_exports.is_empty()
198        {
199            return true;
200        }
201        // Re-export SOURCE: some barrel re-exports FROM this file.
202        sets.re_export_sources.contains(&file)
203    }
204
205    /// Resolve a `FocusFileFacts` set's `FileId`s to root-relative, forward-
206    /// slashed paths, sorted for deterministic output. Files whose module is
207    /// missing are dropped.
208    #[must_use]
209    pub fn focus_facts_with_paths(
210        &self,
211        facts: &[FocusFileFacts],
212        root: &Path,
213    ) -> Vec<FocusFileFactsPaths> {
214        let mut resolved: Vec<FocusFileFactsPaths> = facts
215            .iter()
216            .filter_map(|f| {
217                let path = self.modules.get(f.file.0 as usize)?;
218                Some(FocusFileFactsPaths {
219                    file: relativize(&path.path, root),
220                    fan_in: f.fan_in,
221                    fan_out: f.fan_out,
222                    dynamic_dispatch: f.dynamic_dispatch,
223                    re_export_indirection: f.re_export_indirection,
224                })
225            })
226            .collect();
227        resolved.sort_by(|a, b| a.file.cmp(&b.file));
228        resolved
229    }
230}
231
232/// Strip `root` and forward-slash-normalize a module path (mirrors
233/// `impact_closure::relativize` / `partition_order::relativize`).
234fn relativize(path: &Path, root: &Path) -> String {
235    let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
236    rel.to_string_lossy().replace('\\', "/")
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
243    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
244    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
245    use std::path::PathBuf;
246
247    fn file(id: u32, path: &str) -> DiscoveredFile {
248        DiscoveredFile {
249            id: FileId(id),
250            path: PathBuf::from(path),
251            size_bytes: 10,
252        }
253    }
254
255    fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
256        ResolvedImport {
257            info: ImportInfo {
258                source: source.to_string(),
259                imported_name: ImportedName::Named(name.to_string()),
260                local_name: name.to_string(),
261                is_type_only: false,
262                is_type_only_star: false,
263                from_style: false,
264                span: oxc_span::Span::new(0, 10),
265                source_span: oxc_span::Span::default(),
266            },
267            target: ResolveResult::InternalModule(target),
268        }
269    }
270
271    fn named_export(name: &str) -> ExportInfo {
272        ExportInfo {
273            name: ExportName::Named(name.to_string()),
274            local_name: Some(name.to_string()),
275            is_type_only: false,
276            visibility: VisibilityTag::None,
277            expected_unused_reason: None,
278            span: oxc_span::Span::new(0, 20),
279            members: vec![],
280            is_side_effect_used: false,
281            super_class: None,
282        }
283    }
284
285    /// core (0) <- mid (1) <- app (2). app imports mid imports core.
286    fn build_chain_graph() -> ModuleGraph {
287        let files = vec![
288            file(0, "/p/src/core.ts"),
289            file(1, "/p/src/mid.ts"),
290            file(2, "/p/src/app.ts"),
291        ];
292        let entry_points = vec![EntryPoint {
293            path: PathBuf::from("/p/src/app.ts"),
294            source: EntryPointSource::PackageJsonMain,
295        }];
296        let resolved = vec![
297            ResolvedModule {
298                file_id: FileId(0),
299                path: PathBuf::from("/p/src/core.ts"),
300                exports: vec![named_export("compute")].into(),
301                ..Default::default()
302            },
303            ResolvedModule {
304                file_id: FileId(1),
305                path: PathBuf::from("/p/src/mid.ts"),
306                resolved_imports: vec![named_import("./core", "compute", FileId(0))],
307                exports: vec![named_export("midFn")].into(),
308                ..Default::default()
309            },
310            ResolvedModule {
311                file_id: FileId(2),
312                path: PathBuf::from("/p/src/app.ts"),
313                resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
314                ..Default::default()
315            },
316        ];
317        ModuleGraph::build(&resolved, &entry_points, &files)
318    }
319
320    #[test]
321    fn fan_in_counts_importers() {
322        let graph = build_chain_graph();
323        // core is imported by mid: fan_in = 1, fan_out = 0.
324        let facts = graph.focus_file_facts(&[FileId(0)]);
325        assert_eq!(facts.len(), 1);
326        assert_eq!(facts[0].fan_in, 1);
327        assert_eq!(facts[0].fan_out, 0);
328    }
329
330    #[test]
331    fn fan_out_counts_forward_deps() {
332        let graph = build_chain_graph();
333        // app imports mid: fan_out = 1, fan_in = 0 (nothing imports app).
334        let facts = graph.focus_file_facts(&[FileId(2)]);
335        assert_eq!(facts.len(), 1);
336        assert_eq!(facts[0].fan_out, 1);
337        assert_eq!(facts[0].fan_in, 0);
338    }
339
340    #[test]
341    fn focus_facts_are_byte_identical_across_runs() {
342        let graph = build_chain_graph();
343        let changed = [FileId(0), FileId(1), FileId(2)];
344        let first = graph.focus_file_facts(&changed);
345        let second = graph.focus_file_facts(&changed);
346        assert_eq!(first, second);
347        let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
348        let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
349        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
350    }
351
352    #[test]
353    fn re_export_barrel_flags_indirection() {
354        use crate::resolve::ResolvedReExport;
355        use fallow_types::extract::ReExportInfo;
356
357        let files = vec![
358            file(0, "/p/src/impl.ts"),
359            file(1, "/p/src/barrel.ts"),
360            file(2, "/p/src/consumer.ts"),
361        ];
362        let entry_points = vec![EntryPoint {
363            path: PathBuf::from("/p/src/consumer.ts"),
364            source: EntryPointSource::PackageJsonMain,
365        }];
366        let resolved = vec![
367            ResolvedModule {
368                file_id: FileId(0),
369                path: PathBuf::from("/p/src/impl.ts"),
370                exports: vec![named_export("widget")].into(),
371                ..Default::default()
372            },
373            ResolvedModule {
374                file_id: FileId(1),
375                path: PathBuf::from("/p/src/barrel.ts"),
376                re_exports: vec![ResolvedReExport {
377                    info: ReExportInfo {
378                        source: "./impl".to_string(),
379                        imported_name: "widget".to_string(),
380                        exported_name: "widget".to_string(),
381                        is_type_only: false,
382                        span: oxc_span::Span::new(0, 10),
383                        statement_span: oxc_span::Span::new(0, 0),
384                        source_span: oxc_span::Span::new(0, 0),
385                    },
386                    target: ResolveResult::InternalModule(FileId(0)),
387                }],
388                ..Default::default()
389            },
390            ResolvedModule {
391                file_id: FileId(2),
392                path: PathBuf::from("/p/src/consumer.ts"),
393                resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
394                ..Default::default()
395            },
396        ];
397        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
398        // barrel.ts declares re-exports -> flagged. impl.ts is a re-export source
399        // -> flagged.
400        let barrel = graph.focus_file_facts(&[FileId(1)]);
401        assert!(barrel[0].re_export_indirection, "barrel flags indirection");
402        let impl_facts = graph.focus_file_facts(&[FileId(0)]);
403        assert!(
404            impl_facts[0].re_export_indirection,
405            "re-export source flags indirection"
406        );
407    }
408
409    #[test]
410    fn empty_changed_set_yields_no_facts() {
411        let graph = build_chain_graph();
412        assert!(graph.focus_file_facts(&[]).is_empty());
413    }
414
415    #[test]
416    fn out_of_range_ids_are_dropped() {
417        let graph = build_chain_graph();
418        let facts = graph.focus_file_facts(&[FileId(999)]);
419        assert!(facts.is_empty());
420    }
421}