1use std::path::{Path, PathBuf};
29
30use fallow_types::discover::FileId;
31use rustc_hash::FxHashSet;
32
33use super::ModuleGraph;
34
35impl ModuleGraph {
36 #[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 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 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
113fn 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 },
161 target: ResolveResult::InternalModule(target),
162 }
163 }
164
165 fn named_import(name: &str, target: FileId) -> ResolvedImport {
166 ResolvedImport {
167 info: ImportInfo {
168 source: "./x".to_string(),
169 imported_name: ImportedName::Named(name.to_string()),
170 local_name: name.to_string(),
171 is_type_only: false,
172 from_style: false,
173 span: oxc_span::Span::new(0, 10),
174 source_span: oxc_span::Span::default(),
175 },
176 target: ResolveResult::InternalModule(target),
177 }
178 }
179
180 fn build_graph() -> (ModuleGraph, FxHashSet<FileId>) {
183 let files = vec![
184 file(0, "/p/index.js"),
185 file(1, "/p/src/impl.ts"),
186 file(2, "/p/src/internal.ts"),
187 file(3, "/p/src/consumer.ts"),
188 ];
189 let entry_points = vec![EntryPoint {
190 path: PathBuf::from("/p/index.js"),
191 source: EntryPointSource::PackageJsonExports,
192 }];
193 let resolved = vec![
194 ResolvedModule {
195 file_id: FileId(0),
196 path: PathBuf::from("/p/index.js"),
197 re_exports: vec![re_export("pub", "pub", FileId(1))],
198 ..Default::default()
199 },
200 ResolvedModule {
201 file_id: FileId(1),
202 path: PathBuf::from("/p/src/impl.ts"),
203 exports: vec![named_export("pub"), named_export("priv")],
204 ..Default::default()
205 },
206 ResolvedModule {
207 file_id: FileId(2),
208 path: PathBuf::from("/p/src/internal.ts"),
209 re_exports: vec![re_export("priv", "priv", FileId(1))],
210 ..Default::default()
211 },
212 ResolvedModule {
213 file_id: FileId(3),
214 path: PathBuf::from("/p/src/consumer.ts"),
215 resolved_imports: vec![
216 named_import("pub", FileId(0)),
217 named_import("priv", FileId(2)),
218 ],
219 ..Default::default()
220 },
221 ];
222 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
223 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
225 (graph, public_entries)
226 }
227
228 #[test]
229 fn export_reexported_through_exports_path_is_public() {
230 let (graph, public_entries) = build_graph();
231 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
232 assert!(
236 keys.contains("index.js::pub"),
237 "exports-reachable symbol must be public: {keys:?}"
238 );
239 }
240
241 #[test]
242 fn export_reexported_only_through_internal_barrel_is_not_public() {
243 let (graph, public_entries) = build_graph();
244 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
245 assert!(
249 !keys.iter().any(|k| k.ends_with("::priv")),
250 "internal-barrel-only symbol must NOT be public: {keys:?}"
251 );
252 }
253
254 fn build_aisha_graph(
258 impl_exports: &[&str],
259 exports_reexported: &[&str],
260 internal_reexported: &[&str],
261 ) -> (ModuleGraph, FxHashSet<FileId>) {
262 let files = vec![
263 file(0, "/p/index.js"),
264 file(1, "/p/src/impl.ts"),
265 file(2, "/p/src/internal.ts"),
266 ];
267 let entry_points = vec![EntryPoint {
268 path: PathBuf::from("/p/index.js"),
269 source: EntryPointSource::PackageJsonExports,
270 }];
271 let resolved = vec![
272 ResolvedModule {
273 file_id: FileId(0),
274 path: PathBuf::from("/p/index.js"),
275 re_exports: exports_reexported
276 .iter()
277 .map(|n| re_export(n, n, FileId(1)))
278 .collect(),
279 ..Default::default()
280 },
281 ResolvedModule {
282 file_id: FileId(1),
283 path: PathBuf::from("/p/src/impl.ts"),
284 exports: impl_exports.iter().map(|n| named_export(n)).collect(),
285 ..Default::default()
286 },
287 ResolvedModule {
288 file_id: FileId(2),
289 path: PathBuf::from("/p/src/internal.ts"),
290 re_exports: internal_reexported
291 .iter()
292 .map(|n| re_export(n, n, FileId(1)))
293 .collect(),
294 ..Default::default()
295 },
296 ];
297 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
298 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
299 (graph, public_entries)
300 }
301
302 #[test]
303 fn done_condition_internal_zero_exports_one() {
304 let root = Path::new("/p");
305 let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
307 let base = base_graph.public_export_keys(&base_entries, root);
308
309 let (head_a_graph, head_a_entries) =
311 build_aisha_graph(&["pub", "internalOnly"], &["pub"], &["internalOnly"]);
312 let head_a = head_a_graph.public_export_keys(&head_a_entries, root);
313 let internal_delta: Vec<_> = head_a.difference(&base).collect();
314 assert!(
315 internal_delta.is_empty(),
316 "internal-barrel symbol must yield ZERO public-API delta: {internal_delta:?}"
317 );
318
319 let (head_b_graph, head_b_entries) =
321 build_aisha_graph(&["pub", "widget"], &["pub", "widget"], &[]);
322 let head_b = head_b_graph.public_export_keys(&head_b_entries, root);
323 let exports_delta: Vec<_> = head_b.difference(&base).collect();
324 assert_eq!(
325 exports_delta.len(),
326 1,
327 "exports-reachable symbol must yield EXACTLY ONE public-API delta: {exports_delta:?}"
328 );
329 assert_eq!(exports_delta[0], "index.js::widget");
330 }
331
332 #[test]
333 fn type_only_exports_are_skipped() {
334 let files = vec![file(0, "/p/index.ts")];
335 let entry_points = vec![EntryPoint {
336 path: PathBuf::from("/p/index.ts"),
337 source: EntryPointSource::PackageJsonExports,
338 }];
339 let mut type_export = named_export("T");
340 type_export.is_type_only = true;
341 let resolved = vec![ResolvedModule {
342 file_id: FileId(0),
343 path: PathBuf::from("/p/index.ts"),
344 exports: vec![type_export, named_export("v")],
345 ..Default::default()
346 }];
347 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
348 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
349 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
350 assert!(keys.contains("index.ts::v"));
351 assert!(!keys.contains("index.ts::T"), "type-only export skipped");
352 }
353}