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