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