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 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 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 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 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 assert!(
251 !keys.iter().any(|k| k.ends_with("::priv")),
252 "internal-barrel-only symbol must NOT be public: {keys:?}"
253 );
254 }
255
256 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 let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
309 let base = base_graph.public_export_keys(&base_entries, root);
310
311 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 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}