1use std::path::Path;
25
26use super::relativize;
27
28use fallow_types::discover::FileId;
29use rustc_hash::FxHashSet;
30
31use super::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace, ModuleGraph};
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct PublicExportOrigin {
36 file_id: FileId,
37 export_name: String,
38}
39
40impl PublicExportOrigin {
41 #[must_use]
43 pub const fn file_id(&self) -> FileId {
44 self.file_id
45 }
46
47 #[must_use]
49 pub fn export_name(&self) -> &str {
50 &self.export_name
51 }
52}
53
54impl ModuleGraph {
55 #[must_use]
62 pub fn public_export_keys(
63 &self,
64 public_api_entry_points: &FxHashSet<FileId>,
65 root: &Path,
66 ) -> FxHashSet<String> {
67 let candidate_names = self.public_value_export_names(public_api_entry_points);
68 let mut keys: FxHashSet<String> = FxHashSet::default();
69
70 for entry_id in public_api_entry_points {
71 let Some(entry) = self.modules.get(entry_id.0 as usize) else {
72 continue;
73 };
74 let rel = relativize(&entry.path, root);
75 for name in &candidate_names {
76 if matches!(
77 self.resolve_export(*entry_id, name, ExportNamespace::Value),
78 EffectiveExportResolution::Unique(_)
79 ) {
80 keys.insert(format!("{rel}::{name}"));
81 }
82 }
83 }
84 keys
85 }
86
87 #[must_use]
90 pub fn public_export_origins(
91 &self,
92 public_api_entry_points: &FxHashSet<FileId>,
93 ) -> FxHashSet<PublicExportOrigin> {
94 self.public_export_origins_in_namespace(public_api_entry_points, ExportNamespace::Value)
95 }
96
97 #[must_use]
104 pub fn public_export_origins_in_namespace(
105 &self,
106 public_api_entry_points: &FxHashSet<FileId>,
107 namespace: ExportNamespace,
108 ) -> FxHashSet<PublicExportOrigin> {
109 self.public_export_declaration_bindings_in_namespace(public_api_entry_points, namespace)
110 .into_iter()
111 .filter_map(|binding| self.export_binding_origin(binding))
112 .map(|origin| PublicExportOrigin {
113 file_id: origin.file_id(),
114 export_name: origin.export().name.to_string(),
115 })
116 .collect()
117 }
118
119 #[must_use]
121 pub fn public_export_bindings(
122 &self,
123 public_api_entry_points: &FxHashSet<FileId>,
124 ) -> FxHashSet<EffectiveExportBinding> {
125 self.public_export_bindings_in_namespace(public_api_entry_points, ExportNamespace::Value)
126 }
127
128 fn public_export_bindings_in_namespace(
129 &self,
130 public_api_entry_points: &FxHashSet<FileId>,
131 namespace: ExportNamespace,
132 ) -> FxHashSet<EffectiveExportBinding> {
133 let candidate_names = self.public_export_names(public_api_entry_points, namespace);
134 public_api_entry_points
135 .iter()
136 .flat_map(|entry| {
137 candidate_names.iter().filter_map(|name| {
138 match self.resolve_export(*entry, name, namespace) {
139 EffectiveExportResolution::Unique(binding) => Some(binding),
140 EffectiveExportResolution::Missing
141 | EffectiveExportResolution::Ambiguous => None,
142 }
143 })
144 })
145 .collect()
146 }
147
148 #[must_use]
151 pub fn public_export_declaration_bindings(
152 &self,
153 public_api_entry_points: &FxHashSet<FileId>,
154 ) -> FxHashSet<EffectiveExportBinding> {
155 self.public_export_declaration_bindings_in_namespace(
156 public_api_entry_points,
157 ExportNamespace::Value,
158 )
159 }
160
161 fn public_export_declaration_bindings_in_namespace(
162 &self,
163 public_api_entry_points: &FxHashSet<FileId>,
164 namespace: ExportNamespace,
165 ) -> FxHashSet<EffectiveExportBinding> {
166 let mut declarations = FxHashSet::default();
167 let mut visited = FxHashSet::default();
168 let mut stack: Vec<_> = self
169 .public_export_bindings_in_namespace(public_api_entry_points, namespace)
170 .into_iter()
171 .collect();
172 while let Some(binding) = stack.pop() {
173 if !visited.insert(binding) {
174 continue;
175 }
176 if let Some(source) = binding.namespace_source() {
177 stack.extend(self.unique_export_bindings(source, namespace));
178 } else if self.export_binding_origin(binding).is_some() {
179 declarations.insert(binding);
180 }
181 }
182 declarations
183 }
184
185 fn public_value_export_names(
186 &self,
187 public_api_entry_points: &FxHashSet<FileId>,
188 ) -> FxHashSet<String> {
189 self.public_export_names(public_api_entry_points, ExportNamespace::Value)
190 }
191
192 fn public_export_names(
193 &self,
194 public_api_entry_points: &FxHashSet<FileId>,
195 namespace: ExportNamespace,
196 ) -> FxHashSet<String> {
197 let star_targets = self.public_star_re_export_targets(public_api_entry_points);
198 let mut names: FxHashSet<String> = public_api_entry_points
199 .iter()
200 .chain(star_targets.iter())
201 .filter_map(|id| self.modules.get(id.0 as usize))
202 .flat_map(|module| &module.exports)
203 .filter(|export| namespace == ExportNamespace::Type || !export.is_type_only)
204 .map(|export| export.name.to_string())
205 .collect();
206 names.insert("default".to_string());
207 names
208 }
209
210 fn public_star_re_export_targets(
215 &self,
216 public_api_entry_points: &FxHashSet<FileId>,
217 ) -> FxHashSet<FileId> {
218 let mut targets: FxHashSet<FileId> = public_api_entry_points
219 .iter()
220 .filter_map(|id| self.modules.get(id.0 as usize))
221 .flat_map(|module| {
222 module
223 .re_exports
224 .iter()
225 .filter(|re| re.exported_name == "*")
226 .map(|re| re.source_file)
227 })
228 .collect();
229
230 let mut stack: Vec<FileId> = targets.iter().copied().collect();
231 while let Some(id) = stack.pop() {
232 let Some(module) = self.modules.get(id.0 as usize) else {
233 continue;
234 };
235 for re in module
236 .re_exports
237 .iter()
238 .filter(|re| re.exported_name == "*")
239 {
240 if targets.insert(re.source_file) {
241 stack.push(re.source_file);
242 }
243 }
244 }
245 targets
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
253 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
254 use fallow_types::extract::{
255 ExportInfo, ExportName, ImportInfo, ImportedName, ReExportInfo, VisibilityTag,
256 };
257 use std::path::PathBuf;
258
259 fn file(id: u32, path: &str) -> DiscoveredFile {
260 DiscoveredFile {
261 id: FileId(id),
262 path: PathBuf::from(path),
263 size_bytes: 10,
264 }
265 }
266
267 fn named_export(name: &str) -> ExportInfo {
268 ExportInfo {
269 name: ExportName::Named(name.to_string()),
270 local_name: Some(name.to_string()),
271 is_type_only: false,
272 visibility: VisibilityTag::None,
273 expected_unused_reason: None,
274 span: oxc_span::Span::new(0, 20),
275 members: vec![],
276 is_side_effect_used: false,
277 super_class: None,
278 }
279 }
280
281 fn re_export(imported: &str, exported: &str, target: FileId) -> ResolvedReExport {
282 ResolvedReExport {
283 info: ReExportInfo {
284 source: "./impl".to_string(),
285 imported_name: imported.to_string(),
286 exported_name: exported.to_string(),
287 is_type_only: false,
288 span: oxc_span::Span::new(0, 10),
289 statement_span: oxc_span::Span::new(0, 0),
290 source_span: oxc_span::Span::new(0, 0),
291 },
292 target: ResolveResult::InternalModule(target),
293 }
294 }
295
296 fn named_import(name: &str, target: FileId) -> ResolvedImport {
297 ResolvedImport {
298 info: ImportInfo {
299 source: "./x".to_string(),
300 imported_name: ImportedName::Named(name.to_string()),
301 local_name: name.to_string(),
302 is_type_only: false,
303 is_type_only_star: false,
304 from_style: false,
305 span: oxc_span::Span::new(0, 10),
306 source_span: oxc_span::Span::default(),
307 },
308 target: ResolveResult::InternalModule(target),
309 }
310 }
311
312 fn build_graph() -> (ModuleGraph, FxHashSet<FileId>) {
315 let files = vec![
316 file(0, "/p/index.js"),
317 file(1, "/p/src/impl.ts"),
318 file(2, "/p/src/internal.ts"),
319 file(3, "/p/src/consumer.ts"),
320 ];
321 let entry_points = vec![EntryPoint {
322 path: PathBuf::from("/p/index.js"),
323 source: EntryPointSource::PackageJsonExports,
324 }];
325 let resolved = vec![
326 ResolvedModule {
327 file_id: FileId(0),
328 path: PathBuf::from("/p/index.js"),
329 re_exports: vec![re_export("pub", "pub", FileId(1))],
330 ..Default::default()
331 },
332 ResolvedModule {
333 file_id: FileId(1),
334 path: PathBuf::from("/p/src/impl.ts"),
335 exports: vec![named_export("pub"), named_export("priv")].into(),
336 ..Default::default()
337 },
338 ResolvedModule {
339 file_id: FileId(2),
340 path: PathBuf::from("/p/src/internal.ts"),
341 re_exports: vec![re_export("priv", "priv", FileId(1))],
342 ..Default::default()
343 },
344 ResolvedModule {
345 file_id: FileId(3),
346 path: PathBuf::from("/p/src/consumer.ts"),
347 resolved_imports: vec![
348 named_import("pub", FileId(0)),
349 named_import("priv", FileId(2)),
350 ],
351 ..Default::default()
352 },
353 ];
354 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
355 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
357 (graph, public_entries)
358 }
359
360 #[test]
361 fn export_reexported_through_exports_path_is_public() {
362 let (graph, public_entries) = build_graph();
363 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
364 assert!(
368 keys.contains("index.js::pub"),
369 "exports-reachable symbol must be public: {keys:?}"
370 );
371 }
372
373 #[test]
374 fn export_reexported_only_through_internal_barrel_is_not_public() {
375 let (graph, public_entries) = build_graph();
376 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
377 assert!(
381 !keys.iter().any(|k| k.ends_with("::priv")),
382 "internal-barrel-only symbol must NOT be public: {keys:?}"
383 );
384 }
385
386 fn build_aisha_graph(
390 impl_exports: &[&str],
391 exports_reexported: &[&str],
392 internal_reexported: &[&str],
393 ) -> (ModuleGraph, FxHashSet<FileId>) {
394 let files = vec![
395 file(0, "/p/index.js"),
396 file(1, "/p/src/impl.ts"),
397 file(2, "/p/src/internal.ts"),
398 ];
399 let entry_points = vec![EntryPoint {
400 path: PathBuf::from("/p/index.js"),
401 source: EntryPointSource::PackageJsonExports,
402 }];
403 let resolved = vec![
404 ResolvedModule {
405 file_id: FileId(0),
406 path: PathBuf::from("/p/index.js"),
407 re_exports: exports_reexported
408 .iter()
409 .map(|n| re_export(n, n, FileId(1)))
410 .collect(),
411 ..Default::default()
412 },
413 ResolvedModule {
414 file_id: FileId(1),
415 path: PathBuf::from("/p/src/impl.ts"),
416 exports: impl_exports.iter().map(|n| named_export(n)).collect(),
417 ..Default::default()
418 },
419 ResolvedModule {
420 file_id: FileId(2),
421 path: PathBuf::from("/p/src/internal.ts"),
422 re_exports: internal_reexported
423 .iter()
424 .map(|n| re_export(n, n, FileId(1)))
425 .collect(),
426 ..Default::default()
427 },
428 ];
429 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
430 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
431 (graph, public_entries)
432 }
433
434 #[test]
435 fn done_condition_internal_zero_exports_one() {
436 let root = Path::new("/p");
437 let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
439 let base = base_graph.public_export_keys(&base_entries, root);
440
441 let (head_a_graph, head_a_entries) =
443 build_aisha_graph(&["pub", "internalOnly"], &["pub"], &["internalOnly"]);
444 let head_a = head_a_graph.public_export_keys(&head_a_entries, root);
445 let internal_delta: Vec<_> = head_a.difference(&base).collect();
446 assert!(
447 internal_delta.is_empty(),
448 "internal-barrel symbol must yield ZERO public-API delta: {internal_delta:?}"
449 );
450
451 let (head_b_graph, head_b_entries) =
453 build_aisha_graph(&["pub", "widget"], &["pub", "widget"], &[]);
454 let head_b = head_b_graph.public_export_keys(&head_b_entries, root);
455 let exports_delta: Vec<_> = head_b.difference(&base).collect();
456 assert_eq!(
457 exports_delta.len(),
458 1,
459 "exports-reachable symbol must yield EXACTLY ONE public-API delta: {exports_delta:?}"
460 );
461 assert_eq!(exports_delta[0], "index.js::widget");
462 }
463
464 #[test]
465 fn type_only_exports_are_skipped() {
466 let files = vec![file(0, "/p/index.ts")];
467 let entry_points = vec![EntryPoint {
468 path: PathBuf::from("/p/index.ts"),
469 source: EntryPointSource::PackageJsonExports,
470 }];
471 let mut type_export = named_export("T");
472 type_export.is_type_only = true;
473 let resolved = vec![ResolvedModule {
474 file_id: FileId(0),
475 path: PathBuf::from("/p/index.ts"),
476 exports: vec![type_export, named_export("v")].into(),
477 ..Default::default()
478 }];
479 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
480 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
481 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
482 assert!(keys.contains("index.ts::v"));
483 assert!(!keys.contains("index.ts::T"), "type-only export skipped");
484
485 let type_origins =
486 graph.public_export_origins_in_namespace(&public_entries, ExportNamespace::Type);
487 assert!(
488 type_origins
489 .iter()
490 .any(|origin| origin.export_name() == "T"),
491 "type namespace keeps the public declaration"
492 );
493 assert!(
494 graph
495 .public_export_origins(&public_entries)
496 .iter()
497 .all(|origin| origin.export_name() != "T"),
498 "value-only consumers still exclude type-only declarations"
499 );
500 }
501
502 #[test]
503 fn public_namespace_objects_expand_to_declaration_bindings() {
504 let (graph, public_entries) = build_star_surface_graph(
505 vec![re_export("*", "SDK", FileId(1))],
506 vec![ResolvedModule {
507 file_id: FileId(1),
508 path: PathBuf::from("/p/sdk.ts"),
509 exports: vec![named_export("Client")].into(),
510 ..Default::default()
511 }],
512 );
513
514 let declarations = graph.public_export_declaration_bindings(&public_entries);
515
516 assert_eq!(declarations.len(), 1);
517 let binding = *declarations.iter().next().expect("public Client binding");
518 let origin = graph.export_binding_origin(binding).expect("direct origin");
519 assert_eq!(origin.file_id(), FileId(1));
520 assert_eq!(origin.export().name.to_string(), "Client");
521 }
522
523 fn build_star_surface_graph(
524 entry_re_exports: Vec<ResolvedReExport>,
525 source_modules: Vec<ResolvedModule>,
526 ) -> (ModuleGraph, FxHashSet<FileId>) {
527 let mut files = vec![file(0, "/p/index.ts")];
528 files.extend(source_modules.iter().map(|module| {
529 file(
530 module.file_id.0,
531 module.path.to_str().expect("test path is UTF-8"),
532 )
533 }));
534 let entry_points = vec![EntryPoint {
535 path: PathBuf::from("/p/index.ts"),
536 source: EntryPointSource::PackageJsonExports,
537 }];
538 let mut resolved = vec![ResolvedModule {
539 file_id: FileId(0),
540 path: PathBuf::from("/p/index.ts"),
541 re_exports: entry_re_exports,
542 ..Default::default()
543 }];
544 resolved.extend(source_modules);
545 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
546 (graph, std::iter::once(FileId(0)).collect())
547 }
548
549 #[test]
550 fn explicit_export_hides_shadowed_star_binding_from_public_surface() {
551 let (graph, public_entries) = build_star_surface_graph(
552 vec![
553 re_export("*", "*", FileId(1)),
554 re_export("foo", "foo", FileId(2)),
555 ],
556 vec![
557 ResolvedModule {
558 file_id: FileId(1),
559 path: PathBuf::from("/p/star-source.ts"),
560 exports: vec![named_export("foo")].into(),
561 ..Default::default()
562 },
563 ResolvedModule {
564 file_id: FileId(2),
565 path: PathBuf::from("/p/explicit-source.ts"),
566 exports: vec![named_export("foo")].into(),
567 ..Default::default()
568 },
569 ],
570 );
571
572 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
573
574 assert_eq!(keys, FxHashSet::from_iter(["index.ts::foo".to_string()]));
575 }
576
577 #[test]
578 fn ambiguous_star_binding_is_absent_from_public_surface() {
579 let (graph, public_entries) = build_star_surface_graph(
580 vec![
581 re_export("*", "*", FileId(1)),
582 re_export("*", "*", FileId(2)),
583 ],
584 vec![
585 ResolvedModule {
586 file_id: FileId(1),
587 path: PathBuf::from("/p/left.ts"),
588 exports: vec![named_export("foo")].into(),
589 ..Default::default()
590 },
591 ResolvedModule {
592 file_id: FileId(2),
593 path: PathBuf::from("/p/right.ts"),
594 exports: vec![named_export("foo")].into(),
595 ..Default::default()
596 },
597 ],
598 );
599
600 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
601
602 assert!(
603 keys.is_empty(),
604 "ambiguous foo is not a public binding: {keys:?}"
605 );
606 }
607
608 #[test]
609 fn convergent_star_diamond_has_one_exposed_public_key() {
610 let (graph, public_entries) = build_star_surface_graph(
611 vec![
612 re_export("*", "*", FileId(1)),
613 re_export("*", "*", FileId(2)),
614 ],
615 vec![
616 ResolvedModule {
617 file_id: FileId(1),
618 path: PathBuf::from("/p/left.ts"),
619 re_exports: vec![re_export("*", "*", FileId(3))],
620 ..Default::default()
621 },
622 ResolvedModule {
623 file_id: FileId(2),
624 path: PathBuf::from("/p/right.ts"),
625 re_exports: vec![re_export("*", "*", FileId(3))],
626 ..Default::default()
627 },
628 ResolvedModule {
629 file_id: FileId(3),
630 path: PathBuf::from("/p/source.ts"),
631 exports: vec![named_export("foo")].into(),
632 ..Default::default()
633 },
634 ],
635 );
636
637 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
638
639 assert_eq!(keys, FxHashSet::from_iter(["index.ts::foo".to_string()]));
640 }
641
642 #[test]
643 fn public_surface_preserves_aliases_default_and_value_namespace() {
644 let mut default_export = named_export("Widget");
645 default_export.name = ExportName::Default;
646 let mut type_export = named_export("T");
647 type_export.is_type_only = true;
648 let mut type_re_export = re_export("T", "PublicType", FileId(1));
649 type_re_export.info.is_type_only = true;
650 let (graph, public_entries) = build_star_surface_graph(
651 vec![
652 re_export("default", "default", FileId(1)),
653 re_export("default", "Widget", FileId(1)),
654 type_re_export,
655 ],
656 vec![ResolvedModule {
657 file_id: FileId(1),
658 path: PathBuf::from("/p/widget.ts"),
659 exports: vec![default_export, type_export].into(),
660 ..Default::default()
661 }],
662 );
663
664 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
665
666 assert_eq!(
667 keys,
668 FxHashSet::from_iter([
669 "index.ts::default".to_string(),
670 "index.ts::Widget".to_string(),
671 ])
672 );
673 }
674}