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}