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