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 from_style: false,
309 span: oxc_span::Span::new(0, 10),
310 source_span: oxc_span::Span::default(),
311 },
312 target: ResolveResult::InternalModule(target),
313 }
314 }
315
316 fn build_graph() -> (ModuleGraph, FxHashSet<FileId>) {
319 let files = vec![
320 file(0, "/p/index.js"),
321 file(1, "/p/src/impl.ts"),
322 file(2, "/p/src/internal.ts"),
323 file(3, "/p/src/consumer.ts"),
324 ];
325 let entry_points = vec![EntryPoint {
326 path: PathBuf::from("/p/index.js"),
327 source: EntryPointSource::PackageJsonExports,
328 }];
329 let resolved = vec![
330 ResolvedModule {
331 file_id: FileId(0),
332 path: PathBuf::from("/p/index.js"),
333 re_exports: vec![re_export("pub", "pub", FileId(1))],
334 ..Default::default()
335 },
336 ResolvedModule {
337 file_id: FileId(1),
338 path: PathBuf::from("/p/src/impl.ts"),
339 exports: vec![named_export("pub"), named_export("priv")].into(),
340 ..Default::default()
341 },
342 ResolvedModule {
343 file_id: FileId(2),
344 path: PathBuf::from("/p/src/internal.ts"),
345 re_exports: vec![re_export("priv", "priv", FileId(1))],
346 ..Default::default()
347 },
348 ResolvedModule {
349 file_id: FileId(3),
350 path: PathBuf::from("/p/src/consumer.ts"),
351 resolved_imports: vec![
352 named_import("pub", FileId(0)),
353 named_import("priv", FileId(2)),
354 ],
355 ..Default::default()
356 },
357 ];
358 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
359 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
361 (graph, public_entries)
362 }
363
364 #[test]
365 fn export_reexported_through_exports_path_is_public() {
366 let (graph, public_entries) = build_graph();
367 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
368 assert!(
372 keys.contains("index.js::pub"),
373 "exports-reachable symbol must be public: {keys:?}"
374 );
375 }
376
377 #[test]
378 fn export_reexported_only_through_internal_barrel_is_not_public() {
379 let (graph, public_entries) = build_graph();
380 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
381 assert!(
385 !keys.iter().any(|k| k.ends_with("::priv")),
386 "internal-barrel-only symbol must NOT be public: {keys:?}"
387 );
388 }
389
390 fn build_aisha_graph(
394 impl_exports: &[&str],
395 exports_reexported: &[&str],
396 internal_reexported: &[&str],
397 ) -> (ModuleGraph, FxHashSet<FileId>) {
398 let files = vec![
399 file(0, "/p/index.js"),
400 file(1, "/p/src/impl.ts"),
401 file(2, "/p/src/internal.ts"),
402 ];
403 let entry_points = vec![EntryPoint {
404 path: PathBuf::from("/p/index.js"),
405 source: EntryPointSource::PackageJsonExports,
406 }];
407 let resolved = vec![
408 ResolvedModule {
409 file_id: FileId(0),
410 path: PathBuf::from("/p/index.js"),
411 re_exports: exports_reexported
412 .iter()
413 .map(|n| re_export(n, n, FileId(1)))
414 .collect(),
415 ..Default::default()
416 },
417 ResolvedModule {
418 file_id: FileId(1),
419 path: PathBuf::from("/p/src/impl.ts"),
420 exports: impl_exports.iter().map(|n| named_export(n)).collect(),
421 ..Default::default()
422 },
423 ResolvedModule {
424 file_id: FileId(2),
425 path: PathBuf::from("/p/src/internal.ts"),
426 re_exports: internal_reexported
427 .iter()
428 .map(|n| re_export(n, n, FileId(1)))
429 .collect(),
430 ..Default::default()
431 },
432 ];
433 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
434 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
435 (graph, public_entries)
436 }
437
438 #[test]
439 fn done_condition_internal_zero_exports_one() {
440 let root = Path::new("/p");
441 let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
443 let base = base_graph.public_export_keys(&base_entries, root);
444
445 let (head_a_graph, head_a_entries) =
447 build_aisha_graph(&["pub", "internalOnly"], &["pub"], &["internalOnly"]);
448 let head_a = head_a_graph.public_export_keys(&head_a_entries, root);
449 let internal_delta: Vec<_> = head_a.difference(&base).collect();
450 assert!(
451 internal_delta.is_empty(),
452 "internal-barrel symbol must yield ZERO public-API delta: {internal_delta:?}"
453 );
454
455 let (head_b_graph, head_b_entries) =
457 build_aisha_graph(&["pub", "widget"], &["pub", "widget"], &[]);
458 let head_b = head_b_graph.public_export_keys(&head_b_entries, root);
459 let exports_delta: Vec<_> = head_b.difference(&base).collect();
460 assert_eq!(
461 exports_delta.len(),
462 1,
463 "exports-reachable symbol must yield EXACTLY ONE public-API delta: {exports_delta:?}"
464 );
465 assert_eq!(exports_delta[0], "index.js::widget");
466 }
467
468 #[test]
469 fn type_only_exports_are_skipped() {
470 let files = vec![file(0, "/p/index.ts")];
471 let entry_points = vec![EntryPoint {
472 path: PathBuf::from("/p/index.ts"),
473 source: EntryPointSource::PackageJsonExports,
474 }];
475 let mut type_export = named_export("T");
476 type_export.is_type_only = true;
477 let resolved = vec![ResolvedModule {
478 file_id: FileId(0),
479 path: PathBuf::from("/p/index.ts"),
480 exports: vec![type_export, named_export("v")].into(),
481 ..Default::default()
482 }];
483 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
484 let public_entries: FxHashSet<FileId> = std::iter::once(FileId(0)).collect();
485 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
486 assert!(keys.contains("index.ts::v"));
487 assert!(!keys.contains("index.ts::T"), "type-only export skipped");
488
489 let type_origins =
490 graph.public_export_origins_in_namespace(&public_entries, ExportNamespace::Type);
491 assert!(
492 type_origins
493 .iter()
494 .any(|origin| origin.export_name() == "T"),
495 "type namespace keeps the public declaration"
496 );
497 assert!(
498 graph
499 .public_export_origins(&public_entries)
500 .iter()
501 .all(|origin| origin.export_name() != "T"),
502 "value-only consumers still exclude type-only declarations"
503 );
504 }
505
506 #[test]
507 fn public_namespace_objects_expand_to_declaration_bindings() {
508 let (graph, public_entries) = build_star_surface_graph(
509 vec![re_export("*", "SDK", FileId(1))],
510 vec![ResolvedModule {
511 file_id: FileId(1),
512 path: PathBuf::from("/p/sdk.ts"),
513 exports: vec![named_export("Client")].into(),
514 ..Default::default()
515 }],
516 );
517
518 let declarations = graph.public_export_declaration_bindings(&public_entries);
519
520 assert_eq!(declarations.len(), 1);
521 let binding = *declarations.iter().next().expect("public Client binding");
522 let origin = graph.export_binding_origin(binding).expect("direct origin");
523 assert_eq!(origin.file_id(), FileId(1));
524 assert_eq!(origin.export().name.to_string(), "Client");
525 }
526
527 fn build_star_surface_graph(
528 entry_re_exports: Vec<ResolvedReExport>,
529 source_modules: Vec<ResolvedModule>,
530 ) -> (ModuleGraph, FxHashSet<FileId>) {
531 let mut files = vec![file(0, "/p/index.ts")];
532 files.extend(source_modules.iter().map(|module| {
533 file(
534 module.file_id.0,
535 module.path.to_str().expect("test path is UTF-8"),
536 )
537 }));
538 let entry_points = vec![EntryPoint {
539 path: PathBuf::from("/p/index.ts"),
540 source: EntryPointSource::PackageJsonExports,
541 }];
542 let mut resolved = vec![ResolvedModule {
543 file_id: FileId(0),
544 path: PathBuf::from("/p/index.ts"),
545 re_exports: entry_re_exports,
546 ..Default::default()
547 }];
548 resolved.extend(source_modules);
549 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
550 (graph, std::iter::once(FileId(0)).collect())
551 }
552
553 #[test]
554 fn explicit_export_hides_shadowed_star_binding_from_public_surface() {
555 let (graph, public_entries) = build_star_surface_graph(
556 vec![
557 re_export("*", "*", FileId(1)),
558 re_export("foo", "foo", FileId(2)),
559 ],
560 vec![
561 ResolvedModule {
562 file_id: FileId(1),
563 path: PathBuf::from("/p/star-source.ts"),
564 exports: vec![named_export("foo")].into(),
565 ..Default::default()
566 },
567 ResolvedModule {
568 file_id: FileId(2),
569 path: PathBuf::from("/p/explicit-source.ts"),
570 exports: vec![named_export("foo")].into(),
571 ..Default::default()
572 },
573 ],
574 );
575
576 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
577
578 assert_eq!(keys, FxHashSet::from_iter(["index.ts::foo".to_string()]));
579 }
580
581 #[test]
582 fn ambiguous_star_binding_is_absent_from_public_surface() {
583 let (graph, public_entries) = build_star_surface_graph(
584 vec![
585 re_export("*", "*", FileId(1)),
586 re_export("*", "*", FileId(2)),
587 ],
588 vec![
589 ResolvedModule {
590 file_id: FileId(1),
591 path: PathBuf::from("/p/left.ts"),
592 exports: vec![named_export("foo")].into(),
593 ..Default::default()
594 },
595 ResolvedModule {
596 file_id: FileId(2),
597 path: PathBuf::from("/p/right.ts"),
598 exports: vec![named_export("foo")].into(),
599 ..Default::default()
600 },
601 ],
602 );
603
604 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
605
606 assert!(
607 keys.is_empty(),
608 "ambiguous foo is not a public binding: {keys:?}"
609 );
610 }
611
612 #[test]
613 fn convergent_star_diamond_has_one_exposed_public_key() {
614 let (graph, public_entries) = build_star_surface_graph(
615 vec![
616 re_export("*", "*", FileId(1)),
617 re_export("*", "*", FileId(2)),
618 ],
619 vec![
620 ResolvedModule {
621 file_id: FileId(1),
622 path: PathBuf::from("/p/left.ts"),
623 re_exports: vec![re_export("*", "*", FileId(3))],
624 ..Default::default()
625 },
626 ResolvedModule {
627 file_id: FileId(2),
628 path: PathBuf::from("/p/right.ts"),
629 re_exports: vec![re_export("*", "*", FileId(3))],
630 ..Default::default()
631 },
632 ResolvedModule {
633 file_id: FileId(3),
634 path: PathBuf::from("/p/source.ts"),
635 exports: vec![named_export("foo")].into(),
636 ..Default::default()
637 },
638 ],
639 );
640
641 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
642
643 assert_eq!(keys, FxHashSet::from_iter(["index.ts::foo".to_string()]));
644 }
645
646 #[test]
647 fn public_surface_preserves_aliases_default_and_value_namespace() {
648 let mut default_export = named_export("Widget");
649 default_export.name = ExportName::Default;
650 let mut type_export = named_export("T");
651 type_export.is_type_only = true;
652 let mut type_re_export = re_export("T", "PublicType", FileId(1));
653 type_re_export.info.is_type_only = true;
654 let (graph, public_entries) = build_star_surface_graph(
655 vec![
656 re_export("default", "default", FileId(1)),
657 re_export("default", "Widget", FileId(1)),
658 type_re_export,
659 ],
660 vec![ResolvedModule {
661 file_id: FileId(1),
662 path: PathBuf::from("/p/widget.ts"),
663 exports: vec![default_export, type_export].into(),
664 ..Default::default()
665 }],
666 );
667
668 let keys = graph.public_export_keys(&public_entries, Path::new("/p"));
669
670 assert_eq!(
671 keys,
672 FxHashSet::from_iter([
673 "index.ts::default".to_string(),
674 "index.ts::Widget".to_string(),
675 ])
676 );
677 }
678}