1mod 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
70pub struct GraphReExportCycle {
71 pub files: Vec<PathBuf>,
75 pub file_ids: Vec<FileId>,
80 pub is_self_loop: bool,
83}
84
85struct ReExportTuple {
91 barrel: FileId,
92 source: FileId,
93 imported_name: String,
94 exported_name: String,
95 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
120enum Exposure {
121 StarSurface,
123 NamespaceObject,
126}
127
128#[derive(Default)]
136pub(in crate::graph) struct WholeModuleObservations {
137 ambient: FxHashMap<FileId, Exposure>,
139 observed: FxHashSet<FileId>,
141}
142
143impl WholeModuleObservations {
144 pub(in crate::graph) fn observe(&mut self, target: FileId) {
146 self.observed.insert(target);
147 }
148
149 pub(in crate::graph) fn observe_ambient_star(&mut self, target: FileId) {
151 self.ambient.entry(target).or_insert(Exposure::StarSurface);
152 }
153
154 pub(in crate::graph) fn observe_ambient_namespace(&mut self, target: FileId) {
156 self.ambient.insert(target, Exposure::NamespaceObject);
157 }
158
159 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
178pub(in crate::graph) struct ExposedNamespaceTargets {
185 members: FxHashMap<FileId, Exposure>,
186}
187
188impl ExposedNamespaceTargets {
189 pub(in crate::graph) fn is_empty(&self) -> bool {
191 self.members.is_empty()
192 }
193
194 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 fn files(&self) -> impl Iterator<Item = FileId> + '_ {
214 self.members.keys().copied()
215 }
216
217 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
235fn is_namespace_re_export(re: &super::types::ReExportEdge) -> bool {
238 re.imported_name == "*" && re.exported_name != "*"
239}
240
241struct NameForwarders<'a> {
247 named: FxHashMap<(FileId, &'a str), Vec<(FileId, &'a str)>>,
250 stars: FxHashMap<FileId, Vec<FileId>>,
252}
253
254#[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
293fn 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
323struct ExposedNameSearch<'a> {
333 forwarders: NameForwarders<'a>,
334 whole_object_names: FxHashMap<FileId, Vec<String>>,
339 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 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 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(¤t) {
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
488fn 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
528struct 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1123fn 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
1154fn 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
1172fn 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
1199fn 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
1219fn 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
1261fn 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
1283struct 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 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 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 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
1360fn 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}