Skip to main content

fallow_graph/graph/
public_exports.rs

1//! Exports-aware public-export-key computation (the hard 80% of 6.A).
2//!
3//! Given the set of *public-API entry points* (the `package.json` `exports`-mapped
4//! modules plus the no-`exports` source-index fallback; computed in core's
5//! `public_api_package_entry_points`, which already encodes rule R4), this
6//! resolves the set of export symbols reachable through that public surface and
7//! returns one stable `"<rel_path>::<name>"` key per public export.
8//!
9//! An export `(file, name)` is PUBLIC when its DECLARING module is part of the
10//! public surface, which is:
11//! - a public-API entry point module itself (its own exports, INCLUDING the
12//!   synthetic re-export stubs the graph materializes on a barrel for every
13//!   `export { x } from './impl'` and `export * from './impl'` it forwards), OR
14//! - a module in the `export *` closure rooted at public-API entries (a target
15//!   whose names are flattened straight into the public surface by `export *`).
16//!
17//! Keying on the surface AS EXPOSED (the entry's own name, e.g. `index.js::pub`),
18//! not the origin's internal name (`src/impl.ts::pub`), is what makes the delta
19//! exports-aware and avoids double-counting one symbol on both the barrel and
20//! the origin. A symbol re-exported only through an INTERNAL barrel that is not
21//! in `exports` never lands on a public entry or a star-target, so it produces
22//! ZERO public-API delta (the Aisha repro); one re-exported through the
23//! `exports`-mapped entry lands on that entry once (exactly one). This mirrors
24//! the exports-aware reachability the `unprovided-inject` and
25//! `unrendered-component` detectors use, kept in the graph crate so the review
26//! brief (cli) can call it directly off the retained graph.
27
28use std::path::{Path, PathBuf};
29
30use fallow_types::discover::FileId;
31use rustc_hash::FxHashSet;
32
33use super::ModuleGraph;
34
35impl ModuleGraph {
36    /// Compute the set of public-export keys reachable through the given
37    /// `public_api_entry_points` (an exports-aware set; see module docs).
38    ///
39    /// Keys are `"<root-relative forward-slashed path>::<export name>"`.
40    /// Type-only exports are skipped: a type erased at build carries no runtime
41    /// contract, so it never widens the public *value* surface that 6.A tracks.
42    #[must_use]
43    pub fn public_export_keys(
44        &self,
45        public_api_entry_points: &FxHashSet<FileId>,
46        root: &Path,
47    ) -> FxHashSet<String> {
48        let star_targets = self.public_star_re_export_targets(public_api_entry_points);
49        let mut keys: FxHashSet<String> = FxHashSet::default();
50
51        for module in &self.modules {
52            // The public surface is the exports DECLARED ON a public entry (its
53            // own + the synthetic re-export stubs the graph put there) plus the
54            // exports of any `export *` target reached from a public entry. The
55            // origin module of a NAMED re-export is internal, so its own copy of
56            // the symbol is intentionally NOT keyed (avoids double-counting and
57            // keeps an internal-barrel-only symbol out of the surface).
58            let module_is_public = public_api_entry_points.contains(&module.file_id)
59                || star_targets.contains(&module.file_id);
60            if !module_is_public {
61                continue;
62            }
63            let rel = relativize(&module.path, root);
64            for export in &module.exports {
65                if export.is_type_only {
66                    continue;
67                }
68                keys.insert(format!("{rel}::{}", export.name));
69            }
70        }
71        keys
72    }
73
74    /// The `export *` closure rooted at the public-API entry points: every module
75    /// reachable through a chain of `export * from './x'` edges starting from a
76    /// public entry. Such modules' exports are part of the public surface even
77    /// though the entry never names them.
78    fn public_star_re_export_targets(
79        &self,
80        public_api_entry_points: &FxHashSet<FileId>,
81    ) -> FxHashSet<FileId> {
82        let mut targets: FxHashSet<FileId> = public_api_entry_points
83            .iter()
84            .filter_map(|id| self.modules.get(id.0 as usize))
85            .flat_map(|module| {
86                module
87                    .re_exports
88                    .iter()
89                    .filter(|re| re.exported_name == "*")
90                    .map(|re| re.source_file)
91            })
92            .collect();
93
94        let mut stack: Vec<FileId> = targets.iter().copied().collect();
95        while let Some(id) = stack.pop() {
96            let Some(module) = self.modules.get(id.0 as usize) else {
97                continue;
98            };
99            for re in module
100                .re_exports
101                .iter()
102                .filter(|re| re.exported_name == "*")
103            {
104                if targets.insert(re.source_file) {
105                    stack.push(re.source_file);
106                }
107            }
108        }
109        targets
110    }
111}
112
113/// Strip `root` and forward-slash-normalize a module path (mirrors
114/// `impact_closure::relativize` for cross-platform key parity).
115fn relativize(path: &Path, root: &Path) -> String {
116    let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
117    rel.to_string_lossy().replace('\\', "/")
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
124    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
125    use fallow_types::extract::{
126        ExportInfo, ExportName, ImportInfo, ImportedName, ReExportInfo, VisibilityTag,
127    };
128    use std::path::PathBuf;
129
130    fn file(id: u32, path: &str) -> DiscoveredFile {
131        DiscoveredFile {
132            id: FileId(id),
133            path: PathBuf::from(path),
134            size_bytes: 10,
135        }
136    }
137
138    fn named_export(name: &str) -> ExportInfo {
139        ExportInfo {
140            name: ExportName::Named(name.to_string()),
141            local_name: Some(name.to_string()),
142            is_type_only: false,
143            visibility: VisibilityTag::None,
144            expected_unused_reason: None,
145            span: oxc_span::Span::new(0, 20),
146            members: vec![],
147            is_side_effect_used: false,
148            super_class: None,
149        }
150    }
151
152    fn re_export(imported: &str, exported: &str, target: FileId) -> ResolvedReExport {
153        ResolvedReExport {
154            info: ReExportInfo {
155                source: "./impl".to_string(),
156                imported_name: imported.to_string(),
157                exported_name: exported.to_string(),
158                is_type_only: false,
159                span: oxc_span::Span::new(0, 10),
160                statement_span: oxc_span::Span::new(0, 0),
161                source_span: oxc_span::Span::new(0, 0),
162            },
163            target: ResolveResult::InternalModule(target),
164        }
165    }
166
167    fn named_import(name: &str, target: FileId) -> ResolvedImport {
168        ResolvedImport {
169            info: ImportInfo {
170                source: "./x".to_string(),
171                imported_name: ImportedName::Named(name.to_string()),
172                local_name: name.to_string(),
173                is_type_only: false,
174                from_style: false,
175                span: oxc_span::Span::new(0, 10),
176                source_span: oxc_span::Span::default(),
177            },
178            target: ResolveResult::InternalModule(target),
179        }
180    }
181
182    /// index (0, the exports entry) re-exports `pub` from impl (1, NOT public);
183    /// internal-barrel (2) re-exports `priv` from impl. consumer (3) imports both.
184    fn build_graph() -> (ModuleGraph, FxHashSet<FileId>) {
185        let files = vec![
186            file(0, "/p/index.js"),
187            file(1, "/p/src/impl.ts"),
188            file(2, "/p/src/internal.ts"),
189            file(3, "/p/src/consumer.ts"),
190        ];
191        let entry_points = vec![EntryPoint {
192            path: PathBuf::from("/p/index.js"),
193            source: EntryPointSource::PackageJsonExports,
194        }];
195        let resolved = vec![
196            ResolvedModule {
197                file_id: FileId(0),
198                path: PathBuf::from("/p/index.js"),
199                re_exports: vec![re_export("pub", "pub", FileId(1))],
200                ..Default::default()
201            },
202            ResolvedModule {
203                file_id: FileId(1),
204                path: PathBuf::from("/p/src/impl.ts"),
205                exports: vec![named_export("pub"), named_export("priv")].into(),
206                ..Default::default()
207            },
208            ResolvedModule {
209                file_id: FileId(2),
210                path: PathBuf::from("/p/src/internal.ts"),
211                re_exports: vec![re_export("priv", "priv", FileId(1))],
212                ..Default::default()
213            },
214            ResolvedModule {
215                file_id: FileId(3),
216                path: PathBuf::from("/p/src/consumer.ts"),
217                resolved_imports: vec![
218                    named_import("pub", FileId(0)),
219                    named_import("priv", FileId(2)),
220                ],
221                ..Default::default()
222            },
223        ];
224        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
225        // The exports-mapped entry set: only index.js (PackageJsonExports).
226        let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
227        (graph, public_entries)
228    }
229
230    #[test]
231    fn export_reexported_through_exports_path_is_public() {
232        let (graph, public_entries) = build_graph();
233        let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
234        // `pub` is re-exported through the exports-mapped index.js, so it appears
235        // on the public surface keyed at the entry (the exposed name), not the
236        // internal origin.
237        assert!(
238            keys.contains("index.js::pub"),
239            "exports-reachable symbol must be public: {keys:?}"
240        );
241    }
242
243    #[test]
244    fn export_reexported_only_through_internal_barrel_is_not_public() {
245        let (graph, public_entries) = build_graph();
246        let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
247        // `priv` reaches a consumer ONLY through the internal (non-exports)
248        // barrel, so it is on no public-surface key (neither the entry nor a
249        // star-target).
250        assert!(
251            !keys.iter().any(|k| k.ends_with("::priv")),
252            "internal-barrel-only symbol must NOT be public: {keys:?}"
253        );
254    }
255
256    /// Build the Aisha-repro graph parameterized by which impl symbols exist and
257    /// which is re-exported through the exports-mapped `index.js`. `internal`
258    /// (if present) is re-exported only through the non-exports internal barrel.
259    fn build_aisha_graph(
260        impl_exports: &[&str],
261        exports_reexported: &[&str],
262        internal_reexported: &[&str],
263    ) -> (ModuleGraph, FxHashSet<FileId>) {
264        let files = vec![
265            file(0, "/p/index.js"),
266            file(1, "/p/src/impl.ts"),
267            file(2, "/p/src/internal.ts"),
268        ];
269        let entry_points = vec![EntryPoint {
270            path: PathBuf::from("/p/index.js"),
271            source: EntryPointSource::PackageJsonExports,
272        }];
273        let resolved = vec![
274            ResolvedModule {
275                file_id: FileId(0),
276                path: PathBuf::from("/p/index.js"),
277                re_exports: exports_reexported
278                    .iter()
279                    .map(|n| re_export(n, n, FileId(1)))
280                    .collect(),
281                ..Default::default()
282            },
283            ResolvedModule {
284                file_id: FileId(1),
285                path: PathBuf::from("/p/src/impl.ts"),
286                exports: impl_exports.iter().map(|n| named_export(n)).collect(),
287                ..Default::default()
288            },
289            ResolvedModule {
290                file_id: FileId(2),
291                path: PathBuf::from("/p/src/internal.ts"),
292                re_exports: internal_reexported
293                    .iter()
294                    .map(|n| re_export(n, n, FileId(1)))
295                    .collect(),
296                ..Default::default()
297            },
298        ];
299        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
300        let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
301        (graph, public_entries)
302    }
303
304    #[test]
305    fn done_condition_internal_zero_exports_one() {
306        let root = Path::new("/p");
307        // Base: impl exports `pub`, re-exported through the exports-mapped index.
308        let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
309        let base = base_graph.public_export_keys(&base_entries, root);
310
311        // Head A: add an internal-barrel symbol NOT in exports -> 0 public deltas.
312        let (head_a_graph, head_a_entries) =
313            build_aisha_graph(&["pub", "internalOnly"], &["pub"], &["internalOnly"]);
314        let head_a = head_a_graph.public_export_keys(&head_a_entries, root);
315        let internal_delta: Vec<_> = head_a.difference(&base).collect();
316        assert!(
317            internal_delta.is_empty(),
318            "internal-barrel symbol must yield ZERO public-API delta: {internal_delta:?}"
319        );
320
321        // Head B: add a symbol reachable through the exports path -> exactly 1.
322        let (head_b_graph, head_b_entries) =
323            build_aisha_graph(&["pub", "widget"], &["pub", "widget"], &[]);
324        let head_b = head_b_graph.public_export_keys(&head_b_entries, root);
325        let exports_delta: Vec<_> = head_b.difference(&base).collect();
326        assert_eq!(
327            exports_delta.len(),
328            1,
329            "exports-reachable symbol must yield EXACTLY ONE public-API delta: {exports_delta:?}"
330        );
331        assert_eq!(exports_delta[0], "index.js::widget");
332    }
333
334    #[test]
335    fn type_only_exports_are_skipped() {
336        let files = vec![file(0, "/p/index.ts")];
337        let entry_points = vec![EntryPoint {
338            path: PathBuf::from("/p/index.ts"),
339            source: EntryPointSource::PackageJsonExports,
340        }];
341        let mut type_export = named_export("T");
342        type_export.is_type_only = true;
343        let resolved = vec![ResolvedModule {
344            file_id: FileId(0),
345            path: PathBuf::from("/p/index.ts"),
346            exports: vec![type_export, named_export("v")].into(),
347            ..Default::default()
348        }];
349        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
350        let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
351        let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
352        assert!(keys.contains("index.ts::v"));
353        assert!(!keys.contains("index.ts::T"), "type-only export skipped");
354    }
355}