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    pub file: FileId,
37    /// Count of DISTINCT files importing this file (fan-in / blast radius).
38    /// Excludes the changed file itself.
39    pub fan_in: u32,
40    /// Count of DISTINCT forward-dependency files this file imports (fan-out).
41    /// Excludes the changed file itself.
42    pub 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    pub 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    pub 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                from_style: false,
263                span: oxc_span::Span::new(0, 10),
264                source_span: oxc_span::Span::default(),
265            },
266            target: ResolveResult::InternalModule(target),
267        }
268    }
269
270    fn named_export(name: &str) -> ExportInfo {
271        ExportInfo {
272            name: ExportName::Named(name.to_string()),
273            local_name: Some(name.to_string()),
274            is_type_only: false,
275            visibility: VisibilityTag::None,
276            expected_unused_reason: None,
277            span: oxc_span::Span::new(0, 20),
278            members: vec![],
279            is_side_effect_used: false,
280            super_class: None,
281        }
282    }
283
284    /// core (0) <- mid (1) <- app (2). app imports mid imports core.
285    fn build_chain_graph() -> ModuleGraph {
286        let files = vec![
287            file(0, "/p/src/core.ts"),
288            file(1, "/p/src/mid.ts"),
289            file(2, "/p/src/app.ts"),
290        ];
291        let entry_points = vec![EntryPoint {
292            path: PathBuf::from("/p/src/app.ts"),
293            source: EntryPointSource::PackageJsonMain,
294        }];
295        let resolved = vec![
296            ResolvedModule {
297                file_id: FileId(0),
298                path: PathBuf::from("/p/src/core.ts"),
299                exports: vec![named_export("compute")],
300                ..Default::default()
301            },
302            ResolvedModule {
303                file_id: FileId(1),
304                path: PathBuf::from("/p/src/mid.ts"),
305                resolved_imports: vec![named_import("./core", "compute", FileId(0))],
306                exports: vec![named_export("midFn")],
307                ..Default::default()
308            },
309            ResolvedModule {
310                file_id: FileId(2),
311                path: PathBuf::from("/p/src/app.ts"),
312                resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
313                ..Default::default()
314            },
315        ];
316        ModuleGraph::build(&resolved, &entry_points, &files)
317    }
318
319    #[test]
320    fn fan_in_counts_importers() {
321        let graph = build_chain_graph();
322        // core is imported by mid: fan_in = 1, fan_out = 0.
323        let facts = graph.focus_file_facts(&[FileId(0)]);
324        assert_eq!(facts.len(), 1);
325        assert_eq!(facts[0].fan_in, 1);
326        assert_eq!(facts[0].fan_out, 0);
327    }
328
329    #[test]
330    fn fan_out_counts_forward_deps() {
331        let graph = build_chain_graph();
332        // app imports mid: fan_out = 1, fan_in = 0 (nothing imports app).
333        let facts = graph.focus_file_facts(&[FileId(2)]);
334        assert_eq!(facts.len(), 1);
335        assert_eq!(facts[0].fan_out, 1);
336        assert_eq!(facts[0].fan_in, 0);
337    }
338
339    #[test]
340    fn focus_facts_are_byte_identical_across_runs() {
341        let graph = build_chain_graph();
342        let changed = [FileId(0), FileId(1), FileId(2)];
343        let first = graph.focus_file_facts(&changed);
344        let second = graph.focus_file_facts(&changed);
345        assert_eq!(first, second);
346        let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
347        let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
348        assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
349    }
350
351    #[test]
352    fn re_export_barrel_flags_indirection() {
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")],
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                    },
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}