Skip to main content

fallow_graph/graph/
public_exports.rs

1//! Exports-aware public-export-key computation (the hard 80% of 6.A).
2//!
3//! Given the set of *public-API entry points* (the `package.json` `exports`-mapped
4//! modules plus the no-`exports` source-index fallback; computed in core's
5//! `public_api_package_entry_points`, which already encodes rule R4), this
6//! resolves the set of export symbols reachable through that public surface and
7//! returns one stable `"<rel_path>::<name>"` key per public export.
8//!
9//! A name is public when it resolves to one unique value binding on a public-API
10//! entry point. Candidates come from the entry's own exports and its `export *`
11//! closure. Shadowed and ambiguous star names are not public bindings.
12//!
13//! Keying on the surface AS EXPOSED (the entry's own name, e.g. `index.js::pub`),
14//! not the origin's internal name (`src/impl.ts::pub`), is what makes the delta
15//! exports-aware and avoids double-counting one symbol on both the barrel and
16//! the origin. A symbol re-exported only through an INTERNAL barrel that is not
17//! in `exports` never resolves on a public entry, so it produces
18//! ZERO public-API delta (the Aisha repro); one re-exported through the
19//! `exports`-mapped entry lands on that entry once (exactly one). This mirrors
20//! the exports-aware reachability the `unprovided-inject` and
21//! `unrendered-component` detectors use, kept in the graph crate so the review
22//! brief (cli) can call it directly off the retained graph.
23
24use std::path::{Path, PathBuf};
25
26use fallow_types::discover::FileId;
27use rustc_hash::FxHashSet;
28
29use super::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace, ModuleGraph};
30
31/// Direct declaration exposed through at least one public package entry.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct PublicExportOrigin {
34    file_id: FileId,
35    export_name: String,
36}
37
38impl PublicExportOrigin {
39    /// Module that owns the public declaration.
40    #[must_use]
41    pub const fn file_id(&self) -> FileId {
42        self.file_id
43    }
44
45    /// Name of the declaration in its owning module.
46    #[must_use]
47    pub fn export_name(&self) -> &str {
48        &self.export_name
49    }
50}
51
52impl ModuleGraph {
53    /// Compute the set of public-export keys reachable through the given
54    /// `public_api_entry_points` (an exports-aware set; see module docs).
55    ///
56    /// Keys are `"<root-relative forward-slashed path>::<export name>"`.
57    /// Type-only exports are skipped: a type erased at build carries no runtime
58    /// contract, so it never widens the public *value* surface that 6.A tracks.
59    #[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    /// Resolve the direct declarations exposed through public package entries.
86    /// Shadowed and ambiguous star candidates contribute no origins.
87    #[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    /// Resolve direct declarations exposed through public package entries in
96    /// one export namespace.
97    ///
98    /// Type-only public surfaces matter to structural consumers such as class
99    /// member analysis, while runtime consumers can continue to request only
100    /// the value namespace.
101    #[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    /// Unique value bindings exposed through at least one public package entry.
118    #[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    /// Direct declaration bindings exposed through public entries, including
147    /// declarations nested under public namespace-object exports.
148    #[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    /// The `export *` closure rooted at the public-API entry points: every module
209    /// reachable through a chain of `export * from './x'` edges starting from a
210    /// public entry. Their exported names are candidates for effective
211    /// resolution on each public entry.
212    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
247/// Strip `root` and forward-slash-normalize a module path (mirrors
248/// `impact_closure::relativize` for cross-platform key parity).
249fn 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    /// index (0, the exports entry) re-exports `pub` from impl (1, NOT public);
318    /// internal-barrel (2) re-exports `priv` from impl. consumer (3) imports both.
319    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        // The exports-mapped entry set: only index.js (PackageJsonExports).
361        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        // `pub` is re-exported through the exports-mapped index.js, so it appears
370        // on the public surface keyed at the entry (the exposed name), not the
371        // internal origin.
372        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        // `priv` reaches a consumer ONLY through the internal (non-exports)
383        // barrel, so it is on no public-surface key (neither the entry nor a
384        // star-target).
385        assert!(
386            !keys.iter().any(|k| k.ends_with("::priv")),
387            "internal-barrel-only symbol must NOT be public: {keys:?}"
388        );
389    }
390
391    /// Build the Aisha-repro graph parameterized by which impl symbols exist and
392    /// which is re-exported through the exports-mapped `index.js`. `internal`
393    /// (if present) is re-exported only through the non-exports internal barrel.
394    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        // Base: impl exports `pub`, re-exported through the exports-mapped index.
443        let (base_graph, base_entries) = build_aisha_graph(&["pub"], &["pub"], &[]);
444        let base = base_graph.public_export_keys(&base_entries, root);
445
446        // Head A: add an internal-barrel symbol NOT in exports -> 0 public deltas.
447        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        // Head B: add a symbol reachable through the exports path -> exactly 1.
457        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}