Skip to main content

fallow_graph/graph/re_exports/
mod.rs

1//! Phase 4: Re-export chain resolution, propagate references through barrel files.
2
3mod propagate;
4#[cfg(test)]
5mod tests;
6
7use std::path::PathBuf;
8
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use fallow_types::discover::FileId;
12
13use super::ModuleGraph;
14
15use propagate::{propagate_named_re_export, propagate_star_re_export};
16
17/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
18///
19/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
20/// the graph crate so the types crate does not need a dependency arrow back
21/// into graph for the conversion; `fallow_core::analyze::re_export_cycles`
22/// performs the `GraphReExportCycle` to `ReExportCycle` mapping by reading
23/// `is_self_loop` and routing to the matching `ReExportCycleKind` variant.
24#[derive(Debug, Clone)]
25pub struct GraphReExportCycle {
26    /// Member files participating in the cycle, sorted lexicographically by
27    /// the `Path::display()` form (matches the existing diagnostic-output
28    /// sort). For a self-loop, exactly one entry.
29    pub files: Vec<PathBuf>,
30    /// Parallel array to `files`: the FileId for each member. Kept alongside
31    /// the paths so the core-layer detector can call
32    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
33    /// without an extra path-to-FileId lookup.
34    pub file_ids: Vec<FileId>,
35    /// `true` for single-file self-re-exports (`export * from './'`), `false`
36    /// for multi-node strongly connected components.
37    pub is_self_loop: bool,
38}
39
40/// A single re-export edge collected from the module graph.
41///
42/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
43/// readable and the new `is_type_only` field carried into
44/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
45struct ReExportTuple {
46    barrel: FileId,
47    source: FileId,
48    imported_name: String,
49    exported_name: String,
50    /// `true` when the triggering re-export edge is `export type * from ...`
51    /// or `export type { foo } from ...`. Threaded into star propagation so
52    /// any synthetic stub created on the source module reflects the chain's
53    /// type-only-ness instead of defaulting to `false`.
54    is_type_only: bool,
55}
56
57impl ModuleGraph {
58    /// Resolve re-export chains: when module A re-exports from B,
59    /// any reference to A's re-exported symbol should also count as a reference
60    /// to B's original export (and transitively through the chain).
61    ///
62    /// Returns the list of re-export cycles and self-loops detected during
63    /// the upfront Tarjan SCC pass. The caller stores this on the
64    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
65    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
66    pub(super) fn resolve_re_export_chains(&mut self) -> Vec<GraphReExportCycle> {
67        let re_export_info: Vec<ReExportTuple> = self
68            .modules
69            .iter()
70            .flat_map(|m| {
71                m.re_exports.iter().map(move |re| ReExportTuple {
72                    barrel: m.file_id,
73                    source: re.source_file,
74                    imported_name: re.imported_name.clone(),
75                    exported_name: re.exported_name.clone(),
76                    is_type_only: re.is_type_only,
77                })
78            })
79            .collect();
80
81        if re_export_info.is_empty() {
82            return Vec::new();
83        }
84
85        // Surface re-export cycles up front via Tarjan SCC over the
86        // re-export subgraph (barrel -> source). A cycle is almost always a
87        // real bug in the barrel structure: silent termination via an
88        // iteration cap hid these for years. Cycles still terminate
89        // naturally via the dedup-by-`from_file` check inside each
90        // propagation helper, so this pass is purely diagnostic.
91        //
92        // The function also emits one `tracing::warn!` per cycle for
93        // operators running with `RUST_LOG=warn`; the returned vec is the
94        // structured surface consumed by the user-visible finding type.
95        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
96
97        // Precompute barrels that are transitively star-re-exported from entry points.
98        // These get entry-point-like treatment: all source exports are marked used.
99        // Entry points often expose public APIs through multiple `export *`
100        // barrels, so direct targets alone are not enough.
101        // Computing this once avoids O(modules) per call inside the hot loop.
102        let mut entry_star_targets: FxHashSet<FileId> = self
103            .modules
104            .iter()
105            .filter(|m| m.is_entry_point())
106            .flat_map(|m| {
107                m.re_exports
108                    .iter()
109                    .filter(|re| re.exported_name == "*")
110                    .map(|re| re.source_file)
111            })
112            .collect();
113        let mut entry_star_stack: Vec<FileId> = entry_star_targets.iter().copied().collect();
114        while let Some(file_id) = entry_star_stack.pop() {
115            let idx = file_id.0 as usize;
116            if idx >= self.modules.len() {
117                continue;
118            }
119
120            for re in self.modules[idx]
121                .re_exports
122                .iter()
123                .filter(|re| re.exported_name == "*")
124            {
125                if entry_star_targets.insert(re.source_file) {
126                    entry_star_stack.push(re.source_file);
127                }
128            }
129        }
130
131        // Pre-build reverse edge index: target FileId -> edge indices.
132        // This avoids O(all_edges) scans per star re-export in the hot loop.
133        // For barrel-heavy monorepos (Vue/Nuxt), star re-exports dominate the
134        // iteration cost, without this index, each call to propagate_star_re_export
135        // linearly scans all edges to find those targeting the barrel.
136        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
137        for (idx, edge) in self.edges.iter().enumerate() {
138            edges_by_target.entry(edge.target).or_default().push(idx);
139        }
140
141        // For each re-export, if the barrel's exported symbol has references,
142        // propagate those references to the source module's original export.
143        // Iterate until no new references are added (handles chains of arbitrary depth).
144        //
145        // Termination: each propagation helper adds references only after a
146        // dedup-by-`from_file` check, so the total monotone growth across
147        // iterations is bounded by `|files| * |exports|`. Once an iteration
148        // adds zero references, the fixpoint is reached and the loop exits.
149        //
150        // The `safety_cap` is a defensive backstop: a chain of length K
151        // converges in at most K iterations, and K cannot exceed the number
152        // of re-export edges. If the cap fires, something has violated
153        // monotonicity, which is a real bug and warrants a loud diagnostic.
154        let safety_cap = re_export_info.len().saturating_add(1);
155        let mut changed = true;
156        let mut iteration: usize = 0;
157        // Reuse a single HashSet across iterations to avoid repeated allocations.
158        // In barrel-heavy monorepos, this loop can run up to safety_cap * re_export_info.len()
159        // * target_exports.len() times, reusing with .clear() avoids O(n) allocations.
160        let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
161        // Track every (source, exported_name) pair we synthesise a stub for so a
162        // later value-bearing triggering edge can downgrade a type-only stub.
163        // Real `export type Foo` declarations on the source are NOT in this set
164        // and stay type-only; only synthesised bridge stubs ever flip.
165        let mut synthetic_stubs: FxHashSet<(FileId, String)> = FxHashSet::default();
166
167        while changed && iteration < safety_cap {
168            changed = false;
169            iteration += 1;
170
171            for entry in &re_export_info {
172                let barrel_idx = entry.barrel.0 as usize;
173                let source_idx = entry.source.0 as usize;
174
175                if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
176                    continue;
177                }
178
179                if entry.exported_name == "*" {
180                    changed |= propagate_star_re_export(
181                        &mut self.modules,
182                        &self.edges,
183                        &edges_by_target,
184                        entry.barrel,
185                        barrel_idx,
186                        entry.source,
187                        source_idx,
188                        &entry_star_targets,
189                        entry.is_type_only,
190                        &mut synthetic_stubs,
191                    );
192                } else {
193                    changed |= propagate_named_re_export(
194                        &mut self.modules,
195                        entry.barrel,
196                        barrel_idx,
197                        source_idx,
198                        &entry.imported_name,
199                        &entry.exported_name,
200                        &mut existing_refs,
201                    );
202                }
203            }
204        }
205
206        if iteration >= safety_cap && changed {
207            // Should never fire in practice. If it does, propagation lost its
208            // monotonicity invariant and the bug needs a loud diagnostic.
209            tracing::error!(
210                iterations = iteration,
211                safety_cap,
212                re_export_edges = re_export_info.len(),
213                "Re-export chain fixpoint exceeded safety cap; \
214                 propagation may be non-monotonic. Please file a bug at \
215                 https://github.com/fallow-rs/fallow/issues with the repro."
216            );
217        }
218
219        cycles
220    }
221}
222
223/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
224/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
225/// data for the user-visible `re-export-cycle` finding type.
226///
227/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
228/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
229/// structured surface that `fallow_core::analyze::re_export_cycles` consumes
230/// and wraps in typed `ReExportCycleFinding`s for end-user output. See
231/// issue #515.
232fn find_re_export_cycles(
233    modules: &[super::types::ModuleNode],
234    re_export_info: &[ReExportTuple],
235) -> Vec<GraphReExportCycle> {
236    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
237
238    // Collect unique nodes (FileIds appearing as either endpoint).
239    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
240    let mut nodes: Vec<FileId> = Vec::new();
241    for entry in re_export_info {
242        for &id in &[entry.barrel, entry.source] {
243            node_index.entry(id).or_insert_with(|| {
244                let idx = nodes.len();
245                nodes.push(id);
246                idx
247            });
248        }
249    }
250
251    let n = nodes.len();
252    if n == 0 {
253        return cycles;
254    }
255
256    // Build adjacency list: barrel -> source. Dedup parallel edges (same
257    // pair via multiple re-exports) so the SCC walk doesn't revisit.
258    // Self-edges (a barrel re-exporting from itself) are pathological in
259    // their own right; warn separately and exclude from the SCC pass so
260    // the cycle diagnostic stays focused on barrel-to-barrel loops.
261    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
262    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
263    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
264    for entry in re_export_info {
265        let from = node_index[&entry.barrel];
266        let to = node_index[&entry.source];
267        if from == to {
268            if seen_self_loop.insert(entry.barrel) {
269                let i = entry.barrel.0 as usize;
270                let (path_buf, path_display) = if i < modules.len() {
271                    let p = modules[i].path.clone();
272                    let d = p.display().to_string();
273                    (p, d)
274                } else {
275                    (
276                        PathBuf::from(format!("<file id {i}>")),
277                        format!("<file id {i}>"),
278                    )
279                };
280                tracing::warn!(
281                    file = path_display.as_str(),
282                    "Re-export self-loop detected: this file re-exports from \
283                     itself. Chain propagation is structurally a no-op for \
284                     these edges. Inspect the barrel for an accidental \
285                     `export * from './<this-file>'` after a rename or move."
286                );
287                cycles.push(GraphReExportCycle {
288                    files: vec![path_buf],
289                    file_ids: vec![entry.barrel],
290                    is_self_loop: true,
291                });
292            }
293            continue;
294        }
295        if seen_edge.insert((from, to)) {
296            adj[from].push(to);
297        }
298    }
299
300    // Iterative Tarjan's SCC over the re-export subgraph.
301    let sccs = tarjan_scc(n, &adj);
302
303    for scc in &sccs {
304        if scc.len() < 2 {
305            continue;
306        }
307        // Resolve each FileId to its PathBuf once. Sort by Path::display()
308        // string to match the existing diagnostic-output sort (also stable
309        // across platforms because PathBuf comparison is byte-wise).
310        let mut triples: Vec<(PathBuf, String, FileId)> = scc
311            .iter()
312            .map(|&idx| {
313                let file_id = nodes[idx];
314                let i = file_id.0 as usize;
315                if i < modules.len() {
316                    let p = modules[i].path.clone();
317                    let d = p.display().to_string();
318                    (p, d, file_id)
319                } else {
320                    let placeholder = format!("<file id {i}>");
321                    (PathBuf::from(&placeholder), placeholder, file_id)
322                }
323            })
324            .collect();
325        triples.sort_by(|a, b| a.1.cmp(&b.1));
326        let members = triples
327            .iter()
328            .map(|(_, d, _)| d.as_str())
329            .collect::<Vec<_>>()
330            .join(" <-> ");
331        tracing::warn!(
332            cycle_size = scc.len(),
333            members = members.as_str(),
334            "Re-export cycle detected: chain propagation may be incomplete \
335             for symbols on this barrel loop. Break the cycle to restore \
336             full reachability analysis."
337        );
338        let (files, file_ids) = triples.into_iter().fold(
339            (Vec::new(), Vec::new()),
340            |(mut paths, mut ids), (p, _, id)| {
341                paths.push(p);
342                ids.push(id);
343                (paths, ids)
344            },
345        );
346        cycles.push(GraphReExportCycle {
347            files,
348            file_ids,
349            is_self_loop: false,
350        });
351    }
352
353    cycles
354}
355
356/// Iterative Tarjan's strongly connected components, returns SCCs that
357/// contain at least one node. The graph is given as adjacency-by-index;
358/// the caller maps node indices back to FileIds.
359fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
360    use fixedbitset::FixedBitSet;
361
362    let mut index_counter: u32 = 0;
363    let mut indices: Vec<u32> = vec![u32::MAX; n];
364    let mut lowlinks: Vec<u32> = vec![0; n];
365    let mut on_stack = FixedBitSet::with_capacity(n);
366    let mut stack: Vec<usize> = Vec::new();
367    let mut sccs: Vec<Vec<usize>> = Vec::new();
368
369    struct Frame {
370        node: usize,
371        next_succ: usize,
372    }
373
374    for start in 0..n {
375        if indices[start] != u32::MAX {
376            continue;
377        }
378        let mut dfs: Vec<Frame> = vec![Frame {
379            node: start,
380            next_succ: 0,
381        }];
382        indices[start] = index_counter;
383        lowlinks[start] = index_counter;
384        index_counter = index_counter.saturating_add(1);
385        stack.push(start);
386        on_stack.insert(start);
387
388        while let Some(frame) = dfs.last_mut() {
389            let v = frame.node;
390            if frame.next_succ < adj[v].len() {
391                let w = adj[v][frame.next_succ];
392                frame.next_succ = frame.next_succ.saturating_add(1);
393                if indices[w] == u32::MAX {
394                    indices[w] = index_counter;
395                    lowlinks[w] = index_counter;
396                    index_counter = index_counter.saturating_add(1);
397                    stack.push(w);
398                    on_stack.insert(w);
399                    dfs.push(Frame {
400                        node: w,
401                        next_succ: 0,
402                    });
403                } else if on_stack.contains(w) {
404                    lowlinks[v] = lowlinks[v].min(indices[w]);
405                }
406            } else {
407                if lowlinks[v] == indices[v] {
408                    let mut scc = Vec::new();
409                    while let Some(w) = stack.pop() {
410                        on_stack.remove(w);
411                        scc.push(w);
412                        if w == v {
413                            break;
414                        }
415                    }
416                    sccs.push(scc);
417                }
418                dfs.pop();
419                if let Some(parent) = dfs.last_mut() {
420                    let pv = parent.node;
421                    lowlinks[pv] = lowlinks[pv].min(lowlinks[v]);
422                }
423            }
424        }
425    }
426
427    sccs
428}