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::collections::VecDeque;
8use std::path::PathBuf;
9
10use fixedbitset::FixedBitSet;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13#[cfg(test)]
14use std::cell::{Cell, RefCell};
15
16use crate::resolve::ResolvedModule;
17use fallow_types::discover::FileId;
18
19use super::types::{ReferencePathInterner, RoutedReferenceKey};
20use super::{Edge, ModuleGraph};
21
22use propagate::{
23    EffectiveDeclarationRouteCache, ImportBindingUsageIndex, NamedPropagationScratch,
24    NamedReExportPropagation, StarReExportPropagation, propagate_named_re_export,
25    propagate_star_re_export,
26};
27
28#[cfg(test)]
29thread_local! {
30    static PROPAGATION_VISITS: RefCell<Option<Vec<(FileId, FileId)>>> =
31        const { RefCell::new(None) };
32    static DIFFERENTIAL_CHECK_ENABLED: Cell<bool> = const { Cell::new(false) };
33}
34
35#[cfg(test)]
36fn record_propagation_visit(entry: &ReExportTuple) {
37    PROPAGATION_VISITS.with(|visits| {
38        if let Some(visits) = visits.borrow_mut().as_mut() {
39            visits.push((entry.barrel, entry.source));
40        }
41    });
42}
43
44#[cfg(test)]
45fn capture_propagation_visits<T>(run: impl FnOnce() -> T) -> (T, Vec<(FileId, FileId)>) {
46    PROPAGATION_VISITS.with(|visits| *visits.borrow_mut() = Some(Vec::new()));
47    let result = run();
48    let visits = PROPAGATION_VISITS.with(|visits| visits.borrow_mut().take().unwrap_or_default());
49    (result, visits)
50}
51
52#[cfg(test)]
53fn with_re_export_differential_check<T>(run: impl FnOnce() -> T) -> T {
54    DIFFERENTIAL_CHECK_ENABLED.with(|enabled| {
55        let previous = enabled.replace(true);
56        let result = run();
57        enabled.set(previous);
58        result
59    })
60}
61
62/// A re-export cycle or self-loop detected during Phase 4 chain resolution.
63///
64/// The graph-layer mirror of `fallow_types::results::ReExportCycle`. Kept in
65/// the graph crate so the types crate does not need a dependency arrow back
66/// into graph for the conversion. The analysis backend performs the
67/// `GraphReExportCycle` to `ReExportCycle` mapping by reading `is_self_loop`
68/// and routing to the matching `ReExportCycleKind` variant.
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct GraphReExportCycle {
71    /// Member files participating in the cycle, sorted lexicographically by
72    /// the `Path::display()` form (matches the existing diagnostic-output
73    /// sort). For a self-loop, exactly one entry.
74    pub files: Vec<PathBuf>,
75    /// Parallel array to `files`: the FileId for each member. Kept alongside
76    /// the paths so the core-layer detector can call
77    /// `suppressions.is_file_suppressed(id, IssueKind::ReExportCycle)`
78    /// without an extra path-to-FileId lookup.
79    pub file_ids: Vec<FileId>,
80    /// `true` for single-file self-re-exports (`export * from './'`), `false`
81    /// for multi-node strongly connected components.
82    pub is_self_loop: bool,
83}
84
85/// A single re-export edge collected from the module graph.
86///
87/// Replaces an earlier ad-hoc 5-tuple so the propagation loop is more
88/// readable and the new `is_type_only` field carried into
89/// [`propagate_star_re_export`] does not get lost in tuple-index plumbing.
90struct ReExportTuple {
91    barrel: FileId,
92    source: FileId,
93    imported_name: String,
94    exported_name: String,
95    /// `true` when the triggering re-export edge is `export type * from ...`
96    /// or `export type { foo } from ...`. Threaded into star propagation so
97    /// any synthetic stub created on the source module reflects the chain's
98    /// type-only-ness instead of defaulting to `false`.
99    is_type_only: bool,
100}
101
102struct ReExportContext<'a> {
103    entry_star_targets: &'a FxHashSet<FileId>,
104    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
105    binding_usage: &'a ImportBindingUsageIndex,
106    effective_exports: &'a super::effective_exports::EffectiveExportIndex,
107    existing_refs: &'a mut FxHashSet<RoutedReferenceKey>,
108    synthetic_stubs: &'a mut FxHashSet<(FileId, String, bool)>,
109    declaration_routes: &'a mut EffectiveDeclarationRouteCache,
110    scratch: &'a mut NamedPropagationScratch,
111    reference_paths: &'a mut ReferencePathInterner,
112}
113
114/// How much of a closure member the consumers that cannot be enumerated see.
115///
116/// The two differ on `default` alone, because a plain `export *` forwards
117/// every named export of its source and never the source's `default`.
118#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119enum Exposure {
120    /// Reached through a plain `export *`: every export except `default`.
121    StarSurface,
122    /// The whole namespace object is observed: every export, `default`
123    /// included.
124    NamespaceObject,
125}
126
127/// The targets whose module surface Phase 2 saw observed, split by whether
128/// the observation survives the target being unreachable.
129///
130/// Ambient module declarations expose an external module id, so their
131/// observation stands at any reachability. Every other observer is a real
132/// consumer in this graph and is retained only while an entry point reaches
133/// its target.
134#[derive(Default)]
135pub(in crate::graph) struct WholeModuleObservations {
136    /// Targets an ambient module body re-exports from, at any reachability.
137    ambient: FxHashMap<FileId, Exposure>,
138    /// Targets a consumer in this graph observes as a whole object.
139    observed: FxHashSet<FileId>,
140}
141
142impl WholeModuleObservations {
143    /// Record a whole-object observation made by a consumer in this graph.
144    pub(in crate::graph) fn observe(&mut self, target: FileId) {
145        self.observed.insert(target);
146    }
147
148    /// Record the target of an ambient plain `export *`.
149    pub(in crate::graph) fn observe_ambient_star(&mut self, target: FileId) {
150        self.ambient.entry(target).or_insert(Exposure::StarSurface);
151    }
152
153    /// Record the target of an ambient `export * as ns`.
154    pub(in crate::graph) fn observe_ambient_namespace(&mut self, target: FileId) {
155        self.ambient.insert(target, Exposure::NamespaceObject);
156    }
157
158    /// The closure seeds: every ambient target, plus the observed targets an
159    /// entry point reaches.
160    fn seeds<'a>(
161        &'a self,
162        entry_reachable: &'a FixedBitSet,
163    ) -> impl Iterator<Item = (FileId, Exposure)> + 'a {
164        self.ambient
165            .iter()
166            .map(|(&target, &exposure)| (target, exposure))
167            .chain(
168                self.observed
169                    .iter()
170                    .copied()
171                    .filter(|target| entry_reachable.contains(target.0 as usize))
172                    .map(|target| (target, Exposure::NamespaceObject)),
173            )
174    }
175}
176
177/// The exposed namespace closure: every module whose names reach consumers
178/// the graph cannot enumerate, with the part of its export surface they see.
179///
180/// Built by [`ModuleGraph::collect_exposed_namespace_targets`], computed once
181/// per graph build and read by Phase 2c (namespace re-export propagation) and
182/// Phase 4 (the entry-star seed).
183pub(in crate::graph) struct ExposedNamespaceTargets {
184    members: FxHashMap<FileId, Exposure>,
185}
186
187impl ExposedNamespaceTargets {
188    /// Whether the closure has no members at all.
189    pub(in crate::graph) fn is_empty(&self) -> bool {
190        self.members.is_empty()
191    }
192
193    /// Whether the member exposes `exported_name`.
194    ///
195    /// A member reached through a plain `export *` does not expose `default`:
196    /// the star that carried its names onward never forwards it, so an
197    /// `export * as default` declared on such a member hands its target's
198    /// namespace object to nobody.
199    pub(in crate::graph) fn exposes_name(&self, file_id: FileId, exported_name: &str) -> bool {
200        match self.members.get(&file_id) {
201            Some(Exposure::NamespaceObject) => true,
202            Some(Exposure::StarSurface) => exported_name != "default",
203            None => false,
204        }
205    }
206
207    /// Every member, at either exposure.
208    ///
209    /// Phase 4 star propagation credits the named exports of a member's
210    /// `export *` sources and never their `default`, which both exposures
211    /// forward alike, so it reads the membership alone.
212    fn files(&self) -> impl Iterator<Item = FileId> + '_ {
213        self.members.keys().copied()
214    }
215
216    /// Record a member, re-walking it when a wider exposure than a previous
217    /// visit arrives. Each member is walked at most twice.
218    fn record(&mut self, stack: &mut Vec<(FileId, Exposure)>, file_id: FileId, exposure: Exposure) {
219        match self.members.entry(file_id) {
220            std::collections::hash_map::Entry::Occupied(mut slot) => {
221                if *slot.get() == Exposure::StarSurface && exposure == Exposure::NamespaceObject {
222                    slot.insert(exposure);
223                    stack.push((file_id, exposure));
224                }
225            }
226            std::collections::hash_map::Entry::Vacant(slot) => {
227                slot.insert(exposure);
228                stack.push((file_id, exposure));
229            }
230        }
231    }
232}
233
234/// `export * as ns from './x'`: the barrel exposes x's namespace object under
235/// a single name instead of forwarding x's names.
236fn is_namespace_re_export(re: &super::types::ReExportEdge) -> bool {
237    re.imported_name == "*" && re.exported_name != "*"
238}
239
240/// Reverse index of the re-export edges that carry one exported name outward,
241/// from the module that declares it toward the barrels that forward it.
242///
243/// A namespace re-export (`export * as ns`) is not a forwarder: it bundles the
244/// source's names into one object instead of passing them along.
245struct NameForwarders<'a> {
246    /// `(source, imported name)` to the barrels re-exporting it, each with the
247    /// name it exports it under, so renames are followed exactly.
248    named: FxHashMap<(FileId, &'a str), Vec<(FileId, &'a str)>>,
249    /// Source file to the barrels that re-export all of its names.
250    stars: FxHashMap<FileId, Vec<FileId>>,
251}
252
253/// Reusable state for [`ExposedNameSearch::reaches_exposure`].
254///
255/// `failed` outlives a single search within one round: an exhausted search
256/// proves that every state it visited fails too, so a forwarding chain shared
257/// by many namespace edges is walked once instead of once per edge. A round
258/// that widens the closure clears it, because a state that failed against the
259/// smaller closure can succeed against the wider one.
260#[derive(Default)]
261struct NameSearchScratch<'a> {
262    visited: FxHashSet<(FileId, &'a str)>,
263    frontier: Vec<(FileId, &'a str)>,
264    failed: FxHashSet<(FileId, &'a str)>,
265}
266
267impl<'a> NameForwarders<'a> {
268    fn build(modules: &'a [super::types::ModuleNode]) -> Self {
269        let mut named: FxHashMap<(FileId, &'a str), Vec<(FileId, &'a str)>> = FxHashMap::default();
270        let mut stars: FxHashMap<FileId, Vec<FileId>> = FxHashMap::default();
271        for module in modules {
272            for re in &module.re_exports {
273                if re.imported_name == "*" {
274                    if re.exported_name == "*" {
275                        stars
276                            .entry(re.source_file)
277                            .or_default()
278                            .push(module.file_id);
279                    }
280                } else {
281                    named
282                        .entry((re.source_file, re.imported_name.as_str()))
283                        .or_default()
284                        .push((module.file_id, re.exported_name.as_str()));
285                }
286            }
287        }
288        Self { named, stars }
289    }
290}
291
292/// Extend `may_reach` with every module `seeds` re-exports from, transitively,
293/// following named and plain-star edges and ignoring names.
294///
295/// `export * as ns` is left out: it bundles its source's names into one object
296/// instead of forwarding them, so it never carries a name outward.
297///
298/// A seed already in `may_reach` is skipped with its whole subtree, which is
299/// what lets the closure fixpoint widen the prune round by round instead of
300/// rebuilding it from scratch.
301fn extend_forwarding_sources(
302    modules: &[super::types::ModuleNode],
303    may_reach: &mut FxHashSet<FileId>,
304    seeds: impl IntoIterator<Item = FileId>,
305) {
306    let mut stack: Vec<FileId> = seeds
307        .into_iter()
308        .filter(|seed| may_reach.insert(*seed))
309        .collect();
310    while let Some(barrel) = stack.pop() {
311        let Some(module) = modules.get(barrel.0 as usize) else {
312            continue;
313        };
314        for re in &module.re_exports {
315            if !is_namespace_re_export(re) && may_reach.insert(re.source_file) {
316                stack.push(re.source_file);
317            }
318        }
319    }
320}
321
322/// The outward search that decides whether one `export * as ns` edge hands its
323/// target's namespace object to consumers the graph cannot enumerate.
324///
325/// A name reaches such a consumer when it arrives, through named and plain-star
326/// re-exports, at an entry point's own export surface, at a module already in
327/// the exposed namespace closure, or at a name some importer uses as a whole
328/// object. The three are the same three decisions Phase 2c makes for the
329/// namespace edges it credits, so seeding from them keeps the closure and
330/// Phase 2c from disagreeing about which objects are observed.
331struct ExposedNameSearch<'a> {
332    forwarders: NameForwarders<'a>,
333    /// Per target file, the imported names whose local binding some importer
334    /// uses as a whole object (`Object.values(ns)`, a spread, a
335    /// destructure-with-rest). Named imports only: a namespace import is
336    /// already a `whole_module_targets` seed.
337    whole_object_names: FxHashMap<FileId, Vec<String>>,
338    /// Every module some acceptance point re-exports from, transitively, names
339    /// ignored.
340    ///
341    /// A name can only travel from a module to an acceptance point along
342    /// forwarding edges, so a module outside this set answers the search in
343    /// constant time however deep its own chains run. Recomputed per round,
344    /// because a round that widens the closure adds acceptance points.
345    may_reach: FxHashSet<FileId>,
346    scratch: NameSearchScratch<'a>,
347}
348
349impl<'a> ExposedNameSearch<'a> {
350    fn build(
351        modules: &'a [super::types::ModuleNode],
352        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
353    ) -> Self {
354        let mut whole_object_names: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
355        for consumer in module_by_id.values() {
356            if consumer.whole_object_uses.is_empty() {
357                continue;
358            }
359            for import in &consumer.resolved_imports {
360                let Some(target) = import.target.internal_file_id() else {
361                    continue;
362                };
363                let imported_name = match &import.info.imported_name {
364                    fallow_types::extract::ImportedName::Named(name) => name.as_str(),
365                    fallow_types::extract::ImportedName::Default => "default",
366                    _ => continue,
367                };
368                let local_name = import.info.local_name.as_str();
369                if local_name.is_empty()
370                    || !consumer
371                        .whole_object_uses
372                        .iter()
373                        .any(|used| used == local_name)
374                {
375                    continue;
376                }
377                let names = whole_object_names.entry(target).or_default();
378                if !names.iter().any(|name| name == imported_name) {
379                    names.push(imported_name.to_string());
380                }
381            }
382        }
383
384        let mut may_reach = FxHashSet::default();
385        extend_forwarding_sources(
386            modules,
387            &mut may_reach,
388            modules
389                .iter()
390                .filter(|m| m.is_entry_point())
391                .map(|m| m.file_id)
392                .chain(whole_object_names.keys().copied()),
393        );
394
395        Self {
396            forwarders: NameForwarders::build(modules),
397            whole_object_names,
398            may_reach,
399            scratch: NameSearchScratch::default(),
400        }
401    }
402
403    /// Widen the reachability prune with the members the last round added and
404    /// drop the memoised failures for a round against the wider closure.
405    ///
406    /// The prune only ever grows, so the members already walked keep their
407    /// subtree and each re-export edge is visited at most once across every
408    /// round instead of once per round.
409    fn refresh(
410        &mut self,
411        modules: &'a [super::types::ModuleNode],
412        closure: &ExposedNamespaceTargets,
413    ) {
414        extend_forwarding_sources(
415            modules,
416            &mut self.may_reach,
417            closure.members.keys().copied(),
418        );
419        self.scratch.failed.clear();
420    }
421
422    /// Whether `name`, as exported by `file`, reaches an acceptance point
423    /// through named and plain-star re-exports.
424    ///
425    /// Each hop must really forward the binding: a barrel that declares its
426    /// own `name`, or that receives it from two stars at once, exports a
427    /// different binding under that name and the chain stops there. Being on
428    /// an entry point's plain-`export *` closure is not on its own proof that
429    /// the name survives to the entry, so no hop is skipped for it. A plain
430    /// `export *` also never carries `default`, so a `default`-named state
431    /// takes named hops only.
432    fn reaches_exposure(
433        &mut self,
434        graph: &ModuleGraph,
435        closure: &ExposedNamespaceTargets,
436        file: FileId,
437        name: &'a str,
438    ) -> bool {
439        if !self.may_reach.contains(&file) || self.scratch.failed.contains(&(file, name)) {
440            return false;
441        }
442        let Self {
443            forwarders,
444            whole_object_names,
445            may_reach,
446            scratch,
447        } = self;
448        scratch.visited.clear();
449        scratch.frontier.clear();
450        scratch.visited.insert((file, name));
451        scratch.frontier.push((file, name));
452        while let Some((current, current_name)) = scratch.frontier.pop() {
453            if exposes_here(graph, closure, whole_object_names, current, current_name) {
454                return true;
455            }
456            if let Some(barrels) = forwarders.named.get(&(current, current_name)) {
457                for &(barrel, exported_name) in barrels {
458                    if may_reach.contains(&barrel)
459                        && graph.forwards_binding(current, current_name, barrel, exported_name)
460                        && !scratch.failed.contains(&(barrel, exported_name))
461                        && scratch.visited.insert((barrel, exported_name))
462                    {
463                        scratch.frontier.push((barrel, exported_name));
464                    }
465                }
466            }
467            if current_name == "default" {
468                continue;
469            }
470            if let Some(barrels) = forwarders.stars.get(&current) {
471                for &barrel in barrels {
472                    if may_reach.contains(&barrel)
473                        && graph.forwards_binding(current, current_name, barrel, current_name)
474                        && !scratch.failed.contains(&(barrel, current_name))
475                        && scratch.visited.insert((barrel, current_name))
476                    {
477                        scratch.frontier.push((barrel, current_name));
478                    }
479                }
480            }
481        }
482        scratch.failed.extend(scratch.visited.iter().copied());
483        false
484    }
485}
486
487/// Whether the module that exports `name` already hands it to a consumer the
488/// graph cannot enumerate, with no further re-export hop needed.
489///
490/// An entry point exposes every name it exports, `default` included: it is the
491/// public API. A closure member exposes what its own exposure allows. A name
492/// some importer uses as a whole object is observed in full by that importer.
493fn exposes_here(
494    graph: &ModuleGraph,
495    closure: &ExposedNamespaceTargets,
496    whole_object_names: &FxHashMap<FileId, Vec<String>>,
497    file: FileId,
498    name: &str,
499) -> bool {
500    graph.is_entry_point_file(file)
501        || closure.exposes_name(file, name)
502        || whole_object_names
503            .get(&file)
504            .is_some_and(|names| names.iter().any(|candidate| candidate == name))
505}
506
507struct ReExportFixpointInput<'a> {
508    re_export_info: &'a [ReExportTuple],
509    entry_star_targets: &'a FxHashSet<FileId>,
510    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
511    module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
512    reference_paths: &'a mut ReferencePathInterner,
513}
514
515#[cfg(test)]
516struct LegacyReExportFullScan<'a> {
517    modules: &'a mut [super::types::ModuleNode],
518    edges: &'a [Edge],
519    re_export_info: &'a [ReExportTuple],
520    entry_star_targets: &'a FxHashSet<FileId>,
521    edges_by_target: &'a FxHashMap<FileId, Vec<usize>>,
522    module_by_id: &'a FxHashMap<FileId, &'a ResolvedModule>,
523    effective_exports: &'a super::effective_exports::EffectiveExportIndex,
524    reference_paths: &'a mut ReferencePathInterner,
525}
526
527/// Deterministic scheduler for monotone re-export propagation.
528///
529/// Each tuple reads export state from `barrel` and may add references or
530/// synthetic exports to `source`. When `source` changes, only tuples whose
531/// `barrel` is that module can observe the new state, so those tuple indices
532/// are re-enqueued in their original stable order.
533struct ReExportPropagationPlan {
534    observers_by_module: FxHashMap<FileId, Vec<usize>>,
535    queue: VecDeque<usize>,
536    enqueued: Vec<bool>,
537}
538
539impl ReExportPropagationPlan {
540    fn new(re_export_info: &[ReExportTuple]) -> Self {
541        let mut observers_by_module: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
542        for (idx, entry) in re_export_info.iter().enumerate() {
543            observers_by_module
544                .entry(entry.barrel)
545                .or_default()
546                .push(idx);
547        }
548
549        Self {
550            observers_by_module,
551            queue: (0..re_export_info.len()).collect(),
552            enqueued: vec![true; re_export_info.len()],
553        }
554    }
555
556    fn pop_front(&mut self) -> Option<usize> {
557        let idx = self.queue.pop_front()?;
558        self.enqueued[idx] = false;
559        Some(idx)
560    }
561
562    fn enqueue_observers(&mut self, changed_module: FileId) {
563        let Some(observers) = self.observers_by_module.get(&changed_module) else {
564            return;
565        };
566        for &idx in observers {
567            if !self.enqueued[idx] {
568                self.enqueued[idx] = true;
569                self.queue.push_back(idx);
570            }
571        }
572    }
573}
574
575impl ModuleGraph {
576    /// Resolve re-export chains: when module A re-exports from B,
577    /// any reference to A's re-exported symbol should also count as a reference
578    /// to B's original export (and transitively through the chain).
579    ///
580    /// Returns the list of re-export cycles and self-loops detected during
581    /// the upfront Tarjan SCC pass. The caller stores this on the
582    /// `ModuleGraph` so the `re-export-cycle` finding type can surface them
583    /// to users instead of relying on `RUST_LOG=warn` (see issue #515).
584    pub(super) fn resolve_re_export_chains(
585        &mut self,
586        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
587        exposed_namespace_targets: &ExposedNamespaceTargets,
588        reference_paths: &mut ReferencePathInterner,
589    ) -> Vec<GraphReExportCycle> {
590        let re_export_info = self.collect_re_export_tuples();
591
592        if re_export_info.is_empty() {
593            return Vec::new();
594        }
595
596        let cycles = find_re_export_cycles(&self.modules, &re_export_info);
597
598        let entry_star_targets = self.collect_entry_star_targets(exposed_namespace_targets);
599        let edges_by_target = self.build_edges_by_target();
600
601        self.run_re_export_fixpoint(ReExportFixpointInput {
602            re_export_info: &re_export_info,
603            entry_star_targets: &entry_star_targets,
604            edges_by_target: &edges_by_target,
605            module_by_id,
606            reference_paths,
607        });
608
609        cycles
610    }
611
612    /// Flatten every module's re-export edges into a single tuple list.
613    fn collect_re_export_tuples(&self) -> Vec<ReExportTuple> {
614        self.modules
615            .iter()
616            .flat_map(|m| {
617                m.re_exports.iter().map(move |re| ReExportTuple {
618                    barrel: m.file_id,
619                    source: re.source_file,
620                    imported_name: re.imported_name.clone(),
621                    exported_name: re.exported_name.clone(),
622                    is_type_only: re.is_type_only,
623                })
624            })
625            .collect()
626    }
627
628    /// Compute the transitive closure of `export *` source files whose every
629    /// named export is credited: star sources of entry-point barrels, closed
630    /// over plain `export *` chains, plus every member of the exposed
631    /// namespace closure (`collect_exposed_namespace_targets`, computed once
632    /// per build and threaded in).
633    fn collect_entry_star_targets(
634        &self,
635        exposed_namespace_targets: &ExposedNamespaceTargets,
636    ) -> FxHashSet<FileId> {
637        let mut entry_star_targets: FxHashSet<FileId> = exposed_namespace_targets.files().collect();
638        entry_star_targets.extend(self.modules.iter().filter(|m| m.is_entry_point()).flat_map(
639            |m| {
640                m.re_exports
641                    .iter()
642                    .filter(|re| re.exported_name == "*")
643                    .map(|re| re.source_file)
644            },
645        ));
646        self.extend_plain_star_closure(&mut entry_star_targets);
647        entry_star_targets
648    }
649
650    /// Every module whose full namespace object is handed to consumers the
651    /// graph cannot enumerate per name (issues #2357, #2372, #2373).
652    ///
653    /// The seeds are the targets whose whole namespace object Phase 2
654    /// observed (`whole_module_targets`: an ambient-module star, a
655    /// dynamic-import pattern match, a bindingless side-effect `require()`, or
656    /// a namespace import the graph could not narrow because it is used as a
657    /// whole object, handed on without member access, or re-exported from a
658    /// non-entry module) plus every `export * as ns` source whose name reaches
659    /// an entry point's own export surface, an existing closure member, or an
660    /// importer that uses the binding as a whole object. Every such consumer
661    /// sees every name on the namespace object, including the names that only
662    /// arrive through the target's own `export *` and `export * as ns` chains,
663    /// and per-name propagation cannot credit those because no name is ever
664    /// imported. The closure therefore follows both chain forms: star
665    /// propagation treats each member like an entry barrel for its `export *`
666    /// sources (named exports, never `default`), and namespace re-export
667    /// propagation credits every export of each member's `export * as ns`
668    /// sources (`default` included, because the namespace object exposes it).
669    ///
670    /// A member reached through a plain `export *` carries the weaker
671    /// [`Exposure::StarSurface`]: the star forwarded its named exports and not
672    /// its `default`, so an `export * as default` declared on it exposes
673    /// nothing and stops the walk.
674    ///
675    /// The namespace-edge seeds and the closure walk run to a fixpoint against
676    /// each other: a target that joins the closure can itself expose a name a
677    /// further `export * as ns` edge forwards to it, and that edge only
678    /// qualifies once the target is a member. The rounds are the same
679    /// exposure decision Phase 2c makes per namespace edge, so the closure
680    /// Phase 2c reads already contains every target Phase 2c would credit in
681    /// full, instead of stopping one namespace level short of it.
682    ///
683    /// `entry_reachable` is the entry-point reachability bitset. It gates the
684    /// two seed kinds issues #2372 and #2373 add (an observed whole-object
685    /// target and an `export * as ns` source), and nothing else: withholding
686    /// those can only withhold credit the pre-existing closure never gave.
687    /// The ambient seeds and the walk stay ungated, because a chain that
688    /// starts at an unreachable shim routinely re-enters a module an entry
689    /// point imports directly, and gating it would report exports on files
690    /// the report calls reachable.
691    ///
692    /// Computed once per graph build and threaded into both phases that read
693    /// it; it depends only on `re_exports`, the entry-point flags, the
694    /// consumers' whole-object uses, and reachability, none of which any later
695    /// phase mutates.
696    pub(in crate::graph) fn collect_exposed_namespace_targets(
697        &self,
698        whole_module_targets: &WholeModuleObservations,
699        entry_reachable: &FixedBitSet,
700        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
701    ) -> ExposedNamespaceTargets {
702        let mut closure = ExposedNamespaceTargets {
703            members: FxHashMap::default(),
704        };
705        let mut stack: Vec<(FileId, Exposure)> = Vec::new();
706        for (seed, exposure) in whole_module_targets.seeds(entry_reachable) {
707            closure.record(&mut stack, seed, exposure);
708        }
709
710        let mut pending: Vec<(FileId, &str, FileId)> = self
711            .modules
712            .iter()
713            .flat_map(|m| {
714                m.re_exports
715                    .iter()
716                    .filter(|re| is_namespace_re_export(re))
717                    .map(move |re| (m.file_id, re.exported_name.as_str(), re.source_file))
718            })
719            .filter(|(_, _, source)| entry_reachable.contains(source.0 as usize))
720            .collect();
721        let mut search =
722            (!pending.is_empty()).then(|| ExposedNameSearch::build(&self.modules, module_by_id));
723
724        loop {
725            self.extend_exposure_walk(&mut closure, &mut stack);
726            let Some(search) = search.as_mut() else { break };
727            if pending.is_empty() {
728                break;
729            }
730            search.refresh(&self.modules, &closure);
731            let mut widened = false;
732            let mut still_pending = Vec::with_capacity(pending.len());
733            for (barrel, exported_name, source) in std::mem::take(&mut pending) {
734                if closure.members.get(&source) == Some(&Exposure::NamespaceObject) {
735                    continue;
736                }
737                if search.reaches_exposure(self, &closure, barrel, exported_name) {
738                    closure.record(&mut stack, source, Exposure::NamespaceObject);
739                    widened = true;
740                } else {
741                    still_pending.push((barrel, exported_name, source));
742                }
743            }
744            pending = still_pending;
745            if !widened {
746                break;
747            }
748        }
749        closure
750    }
751
752    /// Drain the closure's work stack, carrying each member's exposure along
753    /// its own `export *` and `export * as ns` edges.
754    ///
755    /// No hop is dropped for reachability. A re-export edge makes its source
756    /// reachable whenever the barrel is, so only the ambient seeds can ever
757    /// walk from an unreachable member, and their chains routinely re-enter
758    /// modules an entry point imports directly.
759    fn extend_exposure_walk(
760        &self,
761        closure: &mut ExposedNamespaceTargets,
762        stack: &mut Vec<(FileId, Exposure)>,
763    ) {
764        while let Some((file_id, exposure)) = stack.pop() {
765            let Some(module) = self.modules.get(file_id.0 as usize) else {
766                continue;
767            };
768            for re in &module.re_exports {
769                if re.imported_name != "*" {
770                    continue;
771                }
772                let next = if re.exported_name == "*" {
773                    Exposure::StarSurface
774                } else if exposure == Exposure::NamespaceObject || re.exported_name != "default" {
775                    Exposure::NamespaceObject
776                } else {
777                    continue;
778                };
779                closure.record(stack, re.source_file, next);
780            }
781        }
782    }
783
784    /// Whether `barrel` re-exports under `barrel_name` the very binding
785    /// `source` exports under `source_name`.
786    ///
787    /// Only the namespace choice lives here: the value namespace decides
788    /// whenever the source exports the name there (a namespace object is a
789    /// value binding), and a type-only surface falls back to the type
790    /// namespace. The per-namespace comparison itself is Phase 2c's own
791    /// `uniquely_forwards_binding`, so the closure's outward search and the
792    /// phase it pre-computes for cannot drift apart on what a hop forwards.
793    fn forwards_binding(
794        &self,
795        source: FileId,
796        source_name: &str,
797        barrel: FileId,
798        barrel_name: &str,
799    ) -> bool {
800        for namespace in [super::ExportNamespace::Value, super::ExportNamespace::Type] {
801            if !matches!(
802                self.resolve_export(source, source_name, namespace),
803                super::EffectiveExportResolution::Unique(_)
804            ) {
805                continue;
806            }
807            return super::namespace_indexes::uniquely_forwards_binding(
808                self,
809                source,
810                source_name,
811                barrel,
812                barrel_name,
813                namespace,
814            );
815        }
816        false
817    }
818
819    /// Whether the file is an entry point of this graph.
820    fn is_entry_point_file(&self, file_id: FileId) -> bool {
821        self.modules
822            .get(file_id.0 as usize)
823            .is_some_and(super::types::ModuleNode::is_entry_point)
824    }
825
826    /// Extend `targets` with every module its members reach through plain
827    /// `export *` chains, transitively.
828    fn extend_plain_star_closure(&self, targets: &mut FxHashSet<FileId>) {
829        let mut stack: Vec<FileId> = targets.iter().copied().collect();
830        while let Some(file_id) = stack.pop() {
831            let Some(module) = self.modules.get(file_id.0 as usize) else {
832                continue;
833            };
834            for re in module
835                .re_exports
836                .iter()
837                .filter(|re| re.imported_name == "*" && re.exported_name == "*")
838            {
839                if targets.insert(re.source_file) {
840                    stack.push(re.source_file);
841                }
842            }
843        }
844    }
845
846    /// Index every edge by its target file for fast star-propagation lookups.
847    fn build_edges_by_target(&self) -> FxHashMap<FileId, Vec<usize>> {
848        let mut edges_by_target: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
849        for (idx, edge) in self.edges.iter().enumerate() {
850            edges_by_target.entry(edge.target).or_default().push(idx);
851        }
852        edges_by_target
853    }
854
855    /// Run monotone propagation, revisiting only tuples affected by new state.
856    fn run_re_export_fixpoint(&mut self, input: ReExportFixpointInput<'_>) {
857        let ReExportFixpointInput {
858            re_export_info,
859            entry_star_targets,
860            edges_by_target,
861            module_by_id,
862            reference_paths,
863        } = input;
864        #[cfg(test)]
865        let mut legacy_modules: Option<Vec<super::types::ModuleNode>> = DIFFERENTIAL_CHECK_ENABLED
866            .with(|enabled| {
867                enabled.get().then(|| {
868                    serde_json::from_value(
869                        serde_json::to_value(&self.modules)
870                            .expect("module graph should serialize for differential testing"),
871                    )
872                    .expect("module graph should deserialize for differential testing")
873                })
874            });
875
876        let safety_cap = self.re_export_transition_safety_cap(re_export_info);
877        let mut processed = 0usize;
878        let mut plan = ReExportPropagationPlan::new(re_export_info);
879        let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
880        let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
881        let binding_usage = ImportBindingUsageIndex::build(module_by_id);
882        let mut declaration_routes = EffectiveDeclarationRouteCache::default();
883        let mut scratch = NamedPropagationScratch::default();
884
885        while let Some(entry_idx) = plan.pop_front() {
886            if processed >= safety_cap {
887                tracing::error!(
888                    processed,
889                    safety_cap,
890                    re_export_edges = re_export_info.len(),
891                    "Re-export propagation exceeded its finite-state safety cap; \
892                     propagation may be non-monotonic. Please file a bug at \
893                     https://github.com/fallow-rs/fallow/issues with the repro."
894                );
895                break;
896            }
897            processed += 1;
898
899            let mut context = ReExportContext {
900                entry_star_targets,
901                edges_by_target,
902                binding_usage: &binding_usage,
903                effective_exports: &self.effective_exports,
904                existing_refs: &mut existing_refs,
905                synthetic_stubs: &mut synthetic_stubs,
906                declaration_routes: &mut declaration_routes,
907                scratch: &mut scratch,
908                reference_paths,
909            };
910
911            let entry = &re_export_info[entry_idx];
912            #[cfg(test)]
913            record_propagation_visit(entry);
914            if Self::propagate_re_export_entry(&mut self.modules, &self.edges, entry, &mut context)
915            {
916                plan.enqueue_observers(entry.source);
917            }
918        }
919
920        #[cfg(test)]
921        if let Some(legacy_modules) = legacy_modules.as_mut() {
922            Self::run_re_export_full_scan(LegacyReExportFullScan {
923                modules: legacy_modules,
924                edges: &self.edges,
925                re_export_info,
926                entry_star_targets,
927                edges_by_target,
928                module_by_id,
929                effective_exports: &self.effective_exports,
930                reference_paths,
931            });
932            assert_eq!(
933                serde_json::to_value(legacy_modules)
934                    .expect("legacy module graph should serialize for comparison"),
935                serde_json::to_value(&self.modules)
936                    .expect("queue module graph should serialize for comparison"),
937                "work-queue propagation must match the legacy full-scan fixpoint"
938            );
939        }
940    }
941
942    /// Bound scheduler work by the finite set of exports, synthetic names, and
943    /// interned reference paths that monotone propagation can add.
944    fn re_export_transition_safety_cap(&self, re_export_info: &[ReExportTuple]) -> usize {
945        let initial_exports = self
946            .modules
947            .iter()
948            .map(|module| module.exports.len())
949            .sum::<usize>();
950        let named_inputs = self
951            .edges
952            .iter()
953            .flat_map(|edge| &edge.symbols)
954            .filter(|symbol| {
955                matches!(
956                    &symbol.imported_name,
957                    fallow_types::extract::ImportedName::Named(_)
958                )
959            })
960            .count()
961            .saturating_add(initial_exports)
962            .saturating_add(re_export_info.len());
963
964        let module_count = self.modules.len();
965        let synthetic_export_hosts = self
966            .modules
967            .iter()
968            .filter(|module| {
969                module
970                    .re_exports
971                    .iter()
972                    .any(|re_export| re_export.exported_name == "*")
973            })
974            .count();
975        let synthetic_exports = synthetic_export_hosts
976            .saturating_mul(named_inputs)
977            .saturating_mul(2);
978        let max_exports = initial_exports.saturating_add(synthetic_exports);
979        let reference_additions = max_exports.saturating_mul(module_count).saturating_mul(2);
980        let state_changes = synthetic_exports.saturating_add(reference_additions);
981
982        re_export_info
983            .len()
984            .saturating_add(state_changes.saturating_mul(re_export_info.len()))
985            .max(re_export_info.len())
986    }
987
988    /// Propagate references for one re-export edge, dispatching star vs named.
989    fn propagate_re_export_entry(
990        modules: &mut [super::types::ModuleNode],
991        edges: &[Edge],
992        entry: &ReExportTuple,
993        context: &mut ReExportContext<'_>,
994    ) -> bool {
995        let barrel_idx = entry.barrel.0 as usize;
996        let source_idx = entry.source.0 as usize;
997
998        if barrel_idx >= modules.len() || source_idx >= modules.len() {
999            return false;
1000        }
1001
1002        if entry.exported_name == "*" {
1003            propagate_star_re_export(StarReExportPropagation {
1004                modules,
1005                edges,
1006                edges_by_target: context.edges_by_target,
1007                binding_usage: context.binding_usage,
1008                effective_exports: context.effective_exports,
1009                barrel_id: entry.barrel,
1010                barrel_idx,
1011                source_id: entry.source,
1012                source_idx,
1013                entry_star_targets: context.entry_star_targets,
1014                triggering_is_type_only: entry.is_type_only,
1015                synthetic_stubs: context.synthetic_stubs,
1016                reference_paths: context.reference_paths,
1017            })
1018        } else {
1019            propagate_named_re_export(NamedReExportPropagation {
1020                modules,
1021                effective_exports: context.effective_exports,
1022                barrel_id: entry.barrel,
1023                barrel_idx,
1024                source_id: entry.source,
1025                source_idx,
1026                imported_name: &entry.imported_name,
1027                exported_name: &entry.exported_name,
1028                is_type_only: entry.is_type_only,
1029                existing_refs: context.existing_refs,
1030                declaration_routes: context.declaration_routes,
1031                scratch: context.scratch,
1032                reference_paths: context.reference_paths,
1033            })
1034        }
1035    }
1036
1037    #[cfg(test)]
1038    fn run_re_export_full_scan(input: LegacyReExportFullScan<'_>) {
1039        let LegacyReExportFullScan {
1040            modules,
1041            edges,
1042            re_export_info,
1043            entry_star_targets,
1044            edges_by_target,
1045            module_by_id,
1046            effective_exports,
1047            reference_paths,
1048        } = input;
1049        let max_iterations = re_export_info.len().saturating_add(1);
1050        let mut existing_refs: FxHashSet<RoutedReferenceKey> = FxHashSet::default();
1051        let mut synthetic_stubs: FxHashSet<(FileId, String, bool)> = FxHashSet::default();
1052        let binding_usage = ImportBindingUsageIndex::build(module_by_id);
1053        let mut declaration_routes = EffectiveDeclarationRouteCache::default();
1054        let mut scratch = NamedPropagationScratch::default();
1055
1056        for _ in 0..max_iterations {
1057            let mut changed = false;
1058            for entry in re_export_info {
1059                let mut context = ReExportContext {
1060                    entry_star_targets,
1061                    edges_by_target,
1062                    binding_usage: &binding_usage,
1063                    effective_exports,
1064                    existing_refs: &mut existing_refs,
1065                    synthetic_stubs: &mut synthetic_stubs,
1066                    declaration_routes: &mut declaration_routes,
1067                    scratch: &mut scratch,
1068                    reference_paths,
1069                };
1070                changed |= Self::propagate_re_export_entry(modules, edges, entry, &mut context);
1071            }
1072            if !changed {
1073                break;
1074            }
1075        }
1076    }
1077}
1078
1079/// Find SCCs of size >= 2 in the re-export subgraph and self-re-export
1080/// edges, emit one `tracing::warn!` per cycle, AND return structured cycle
1081/// data for the user-visible `re-export-cycle` finding type.
1082///
1083/// The `tracing::warn!` emissions remain unchanged from #442 (RUST_LOG=warn
1084/// operators still see them). The returned `Vec<GraphReExportCycle>` is the
1085/// structured surface that the analysis backend consumes and wraps in typed
1086/// `ReExportCycleFinding`s for end-user output. See issue #515.
1087fn find_re_export_cycles(
1088    modules: &[super::types::ModuleNode],
1089    re_export_info: &[ReExportTuple],
1090) -> Vec<GraphReExportCycle> {
1091    let mut cycles: Vec<GraphReExportCycle> = Vec::new();
1092
1093    let (node_index, nodes) = build_re_export_node_index(re_export_info);
1094    let n = nodes.len();
1095    if n == 0 {
1096        return cycles;
1097    }
1098
1099    let adj = build_re_export_adjacency(re_export_info, &node_index, modules, &mut cycles);
1100
1101    let sccs = tarjan_scc(n, &adj);
1102
1103    for scc in &sccs {
1104        if scc.len() < 2 {
1105            continue;
1106        }
1107        cycles.push(build_multi_node_cycle(scc, &nodes, modules));
1108    }
1109
1110    cycles
1111}
1112
1113/// Assign a dense node index to every distinct barrel / source file id.
1114fn build_re_export_node_index(
1115    re_export_info: &[ReExportTuple],
1116) -> (FxHashMap<FileId, usize>, Vec<FileId>) {
1117    let mut node_index: FxHashMap<FileId, usize> = FxHashMap::default();
1118    let mut nodes: Vec<FileId> = Vec::new();
1119    for entry in re_export_info {
1120        for &id in &[entry.barrel, entry.source] {
1121            node_index.entry(id).or_insert_with(|| {
1122                let idx = nodes.len();
1123                nodes.push(id);
1124                idx
1125            });
1126        }
1127    }
1128    (node_index, nodes)
1129}
1130
1131/// Build the adjacency list for the re-export subgraph, emitting a self-loop
1132/// `GraphReExportCycle` for any barrel that re-exports from itself.
1133fn build_re_export_adjacency(
1134    re_export_info: &[ReExportTuple],
1135    node_index: &FxHashMap<FileId, usize>,
1136    modules: &[super::types::ModuleNode],
1137    cycles: &mut Vec<GraphReExportCycle>,
1138) -> Vec<Vec<usize>> {
1139    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); node_index.len()];
1140    let mut seen_edge: FxHashSet<(usize, usize)> = FxHashSet::default();
1141    let mut seen_self_loop: FxHashSet<FileId> = FxHashSet::default();
1142    for entry in re_export_info {
1143        let from = node_index[&entry.barrel];
1144        let to = node_index[&entry.source];
1145        if from == to {
1146            if seen_self_loop.insert(entry.barrel) {
1147                cycles.push(build_self_loop_cycle(entry.barrel, modules));
1148            }
1149            continue;
1150        }
1151        if seen_edge.insert((from, to)) {
1152            adj[from].push(to);
1153        }
1154    }
1155    adj
1156}
1157
1158/// Emit the `tracing::warn!` and structured cycle for a self-re-export edge.
1159fn build_self_loop_cycle(
1160    barrel: FileId,
1161    modules: &[super::types::ModuleNode],
1162) -> GraphReExportCycle {
1163    let (path_buf, path_display) = module_path_and_display(barrel, modules);
1164    tracing::warn!(
1165        file = path_display.as_str(),
1166        "Re-export self-loop detected: this file re-exports from \
1167         itself. Chain propagation is structurally a no-op for \
1168         these edges. Inspect the barrel for an accidental \
1169         `export * from './<this-file>'` after a rename or move."
1170    );
1171    GraphReExportCycle {
1172        files: vec![path_buf],
1173        file_ids: vec![barrel],
1174        is_self_loop: true,
1175    }
1176}
1177
1178/// Emit the `tracing::warn!` and structured cycle for a multi-node SCC.
1179fn build_multi_node_cycle(
1180    scc: &[usize],
1181    nodes: &[FileId],
1182    modules: &[super::types::ModuleNode],
1183) -> GraphReExportCycle {
1184    let mut triples: Vec<(PathBuf, String, FileId)> = scc
1185        .iter()
1186        .map(|&idx| {
1187            let file_id = nodes[idx];
1188            let (path, display) = module_path_and_display(file_id, modules);
1189            (path, display, file_id)
1190        })
1191        .collect();
1192    triples.sort_by(|a, b| a.1.cmp(&b.1));
1193    let members = triples
1194        .iter()
1195        .map(|(_, d, _)| d.as_str())
1196        .collect::<Vec<_>>()
1197        .join(" <-> ");
1198    tracing::warn!(
1199        cycle_size = scc.len(),
1200        members = members.as_str(),
1201        "Re-export cycle detected: chain propagation may be incomplete \
1202         for symbols on this barrel loop. Break the cycle to restore \
1203         full reachability analysis."
1204    );
1205    let (files, file_ids) = triples.into_iter().fold(
1206        (Vec::new(), Vec::new()),
1207        |(mut paths, mut ids), (p, _, id)| {
1208            paths.push(p);
1209            ids.push(id);
1210            (paths, ids)
1211        },
1212    );
1213    GraphReExportCycle {
1214        files,
1215        file_ids,
1216        is_self_loop: false,
1217    }
1218}
1219
1220/// Resolve a `FileId` to its `(PathBuf, display string)`, falling back to a
1221/// placeholder when the id is outside the module list.
1222fn module_path_and_display(
1223    file_id: FileId,
1224    modules: &[super::types::ModuleNode],
1225) -> (PathBuf, String) {
1226    let i = file_id.0 as usize;
1227    if i < modules.len() {
1228        let p = modules[i].path.clone();
1229        let d = p.display().to_string();
1230        (p, d)
1231    } else {
1232        let placeholder = format!("<file id {i}>");
1233        (PathBuf::from(&placeholder), placeholder)
1234    }
1235}
1236
1237struct TarjanFrame {
1238    node: usize,
1239    next_succ: usize,
1240}
1241
1242/// Mutable Tarjan SCC state shared across the iterative DFS.
1243struct TarjanState {
1244    index_counter: u32,
1245    indices: Vec<u32>,
1246    lowlinks: Vec<u32>,
1247    on_stack: fixedbitset::FixedBitSet,
1248    stack: Vec<usize>,
1249    sccs: Vec<Vec<usize>>,
1250}
1251
1252impl TarjanState {
1253    fn new(n: usize) -> Self {
1254        Self {
1255            index_counter: 0,
1256            indices: vec![u32::MAX; n],
1257            lowlinks: vec![0; n],
1258            on_stack: fixedbitset::FixedBitSet::with_capacity(n),
1259            stack: Vec::new(),
1260            sccs: Vec::new(),
1261        }
1262    }
1263
1264    /// Assign the next DFS index to `node` and push it onto the SCC stack.
1265    fn discover(&mut self, node: usize) {
1266        self.indices[node] = self.index_counter;
1267        self.lowlinks[node] = self.index_counter;
1268        self.index_counter = self.index_counter.saturating_add(1);
1269        self.stack.push(node);
1270        self.on_stack.insert(node);
1271    }
1272
1273    /// Advance one successor of the current frame, pushing a child frame when a
1274    /// new node is discovered. Returns the child node to descend into, if any.
1275    fn step_successor(&mut self, frame: &mut TarjanFrame, adj: &[Vec<usize>]) -> Option<usize> {
1276        let v = frame.node;
1277        let w = adj[v][frame.next_succ];
1278        frame.next_succ = frame.next_succ.saturating_add(1);
1279        if self.indices[w] == u32::MAX {
1280            self.discover(w);
1281            Some(w)
1282        } else {
1283            if self.on_stack.contains(w) {
1284                self.lowlinks[v] = self.lowlinks[v].min(self.indices[w]);
1285            }
1286            None
1287        }
1288    }
1289
1290    /// Finish the current frame: emit its SCC if it is a root, then propagate
1291    /// its lowlink to the parent frame.
1292    fn finish_frame(&mut self, v: usize, parent: Option<usize>) {
1293        if self.lowlinks[v] == self.indices[v] {
1294            let mut scc = Vec::new();
1295            while let Some(w) = self.stack.pop() {
1296                self.on_stack.remove(w);
1297                scc.push(w);
1298                if w == v {
1299                    break;
1300                }
1301            }
1302            self.sccs.push(scc);
1303        }
1304        if let Some(pv) = parent {
1305            self.lowlinks[pv] = self.lowlinks[pv].min(self.lowlinks[v]);
1306        }
1307    }
1308}
1309
1310/// Iterative Tarjan's strongly connected components, returns SCCs that
1311/// contain at least one node. The graph is given as adjacency-by-index;
1312/// the caller maps node indices back to FileIds.
1313fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
1314    let mut state = TarjanState::new(n);
1315
1316    for start in 0..n {
1317        if state.indices[start] != u32::MAX {
1318            continue;
1319        }
1320        state.discover(start);
1321        let mut dfs: Vec<TarjanFrame> = vec![TarjanFrame {
1322            node: start,
1323            next_succ: 0,
1324        }];
1325
1326        while let Some(frame) = dfs.last_mut() {
1327            let v = frame.node;
1328            if frame.next_succ < adj[v].len() {
1329                if let Some(child) = state.step_successor(frame, adj) {
1330                    dfs.push(TarjanFrame {
1331                        node: child,
1332                        next_succ: 0,
1333                    });
1334                }
1335            } else {
1336                dfs.pop();
1337                state.finish_frame(v, dfs.last().map(|parent| parent.node));
1338            }
1339        }
1340    }
1341
1342    state.sccs
1343}