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