Skip to main content

fallow_engine/
public_api.rs

1//! Public API graph helpers owned by the engine boundary.
2
3use std::path::{Component, Path, PathBuf};
4
5use fallow_config::{
6    PackageJson, ResolvedConfig, TsconfigOutputMap, TsconfigOutputResolution, WorkspaceInfo,
7};
8use fallow_types::discover::FileId;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use fallow_graph::resolve::{OUTPUT_DIRS, directory_index_entry, output_entry_to_source_path};
12
13use crate::{
14    discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
15    module_graph::RetainedModuleGraph,
16};
17
18/// Compute the exports-aware public API entry-point set for a project graph.
19#[must_use]
20pub fn public_api_package_entry_points(
21    graph: &RetainedModuleGraph,
22    config: &ResolvedConfig,
23    root_pkg: Option<&PackageJson>,
24    workspaces: &[WorkspaceInfo],
25) -> FxHashSet<FileId> {
26    let graph = graph.as_graph();
27    let mut public_api_entry_points = FxHashSet::default();
28    let path_to_file_id = graph_path_to_file_id(graph);
29    let canonical_project_root =
30        dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
31
32    add_root_public_api_entry_points(
33        &mut public_api_entry_points,
34        graph,
35        &path_to_file_id,
36        config,
37        root_pkg,
38        &canonical_project_root,
39    );
40    add_workspace_public_api_entry_points(
41        &mut public_api_entry_points,
42        graph,
43        &path_to_file_id,
44        workspaces,
45        &config.public_packages,
46        &canonical_project_root,
47    );
48
49    public_api_entry_points
50}
51
52/// Compute public export keys for a retained project graph.
53#[must_use]
54pub fn public_export_keys_for_graph(
55    graph: &RetainedModuleGraph,
56    config: &ResolvedConfig,
57    workspaces: &[WorkspaceInfo],
58    root: &Path,
59) -> FxHashSet<String> {
60    let root_pkg = fallow_config::load_dir_package_json(&config.root);
61    let public_entries =
62        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
63    graph.public_export_keys(&public_entries, root)
64}
65
66/// Resolve exports-aware package entry points to their source paths for
67/// semantic API-surface queries.
68#[must_use]
69pub fn public_api_entry_paths_for_graph(
70    graph: &RetainedModuleGraph,
71    config: &ResolvedConfig,
72    workspaces: &[WorkspaceInfo],
73) -> Vec<PathBuf> {
74    let root_pkg = fallow_config::load_dir_package_json(&config.root);
75    let public_entries =
76        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
77    let mut paths = public_entries
78        .into_iter()
79        .filter_map(|file_id| {
80            graph
81                .as_graph()
82                .modules
83                .get(file_id.0 as usize)
84                .map(|module| module.path.clone())
85        })
86        .collect::<Vec<_>>();
87    paths.sort();
88    paths.dedup();
89    paths
90}
91
92fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
93    graph
94        .modules
95        .iter()
96        .map(|module| (module.path.clone(), module.file_id))
97        .collect()
98}
99
100fn add_root_public_api_entry_points(
101    public_api_entry_points: &mut FxHashSet<FileId>,
102    graph: &fallow_graph::graph::ModuleGraph,
103    path_to_file_id: &FxHashMap<PathBuf, FileId>,
104    config: &ResolvedConfig,
105    root_pkg: Option<&PackageJson>,
106    canonical_project_root: &Path,
107) {
108    if let Some(pkg) = root_pkg {
109        add_package_public_api_entry_points(
110            public_api_entry_points,
111            graph,
112            path_to_file_id,
113            &config.root,
114            pkg,
115            canonical_project_root,
116        );
117        add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
118    }
119}
120
121fn add_workspace_public_api_entry_points(
122    public_api_entry_points: &mut FxHashSet<FileId>,
123    graph: &fallow_graph::graph::ModuleGraph,
124    path_to_file_id: &FxHashMap<PathBuf, FileId>,
125    workspaces: &[WorkspaceInfo],
126    public_packages: &[String],
127    canonical_project_root: &Path,
128) {
129    for workspace in workspaces
130        .iter()
131        .filter(|workspace| fallow_config::workspace_is_public(&workspace.name, public_packages))
132    {
133        let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
134            continue;
135        };
136        add_package_public_api_entry_points(
137            public_api_entry_points,
138            graph,
139            path_to_file_id,
140            &workspace.root,
141            &pkg,
142            canonical_project_root,
143        );
144        add_exportless_package_source_indexes(
145            public_api_entry_points,
146            graph,
147            &workspace.root,
148            &pkg,
149        );
150    }
151}
152
153fn add_package_public_api_entry_points(
154    public_api_entry_points: &mut FxHashSet<FileId>,
155    graph: &fallow_graph::graph::ModuleGraph,
156    path_to_file_id: &FxHashMap<PathBuf, FileId>,
157    package_root: &Path,
158    package_json: &PackageJson,
159    canonical_project_root: &Path,
160) {
161    if package_json.private.unwrap_or(false) {
162        return;
163    }
164
165    let output_map = TsconfigOutputMap::from_project(package_root);
166    let file_id_for = |path: &Path| {
167        graph.package_entry_file_id(package_root, path, |candidate| {
168            path_to_file_id.get(candidate).copied()
169        })
170    };
171    for entry in package_json.entry_points() {
172        let Some(entry_point) = resolve_public_api_entry_path(
173            package_root,
174            &entry,
175            canonical_project_root,
176            EntryPointSource::PackageJsonExports,
177            &output_map,
178            &|path| file_id_for(path).is_some(),
179        ) else {
180            continue;
181        };
182
183        if let Some(file_id) = file_id_for(&entry_point.path) {
184            public_api_entry_points.insert(file_id);
185        }
186    }
187}
188
189/// Resolve a package entry to a source path.
190///
191/// `is_discovered` returns `true` for a path in the graph. A `lib/` entry
192/// outside the graph maps to `src/`, the same as a missing `lib/` entry.
193fn resolve_public_api_entry_path(
194    base: &Path,
195    entry: &str,
196    canonical_root: &Path,
197    source: EntryPointSource,
198    output_map: &TsconfigOutputMap,
199    is_discovered: &dyn Fn(&Path) -> bool,
200) -> Option<EntryPoint> {
201    if entry.contains('*') || entry_has_parent_dir(entry) {
202        return None;
203    }
204
205    match output_map.resolve_source_for_entry(entry, SOURCE_EXTENSIONS) {
206        TsconfigOutputResolution::Resolved(source_path) => {
207            return validated_entry_point(&source_path, canonical_root, source);
208        }
209        TsconfigOutputResolution::ConfiguredButUnresolved => {}
210        TsconfigOutputResolution::ConfiguredWithoutSource => {
211            // The tsconfig claims the output but names no file, so a bundler
212            // can own it. Only an analyzed source file under `src/` counts.
213            if let Some(source_path) =
214                output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, is_discovered)
215                    .filter(|source_path| is_discovered(source_path))
216            {
217                return validated_entry_point(&source_path, canonical_root, source);
218            }
219        }
220        TsconfigOutputResolution::Unconfigured => {
221            if let Some(source_path) =
222                output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, is_discovered)
223            {
224                return validated_entry_point(&source_path, canonical_root, source);
225            }
226
227            if is_entry_in_output_dir(entry)
228                && let Some(source_path) = try_source_index_fallback(base)
229            {
230                return validated_entry_point(&source_path, canonical_root, source);
231            }
232        }
233    }
234
235    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
236}
237
238fn resolve_entry_via_filesystem_probe(
239    base: &Path,
240    entry: &str,
241    canonical_root: &Path,
242    source: EntryPointSource,
243) -> Option<EntryPoint> {
244    let resolved = base.join(entry);
245
246    if resolved.is_file() {
247        return validated_entry_point(&resolved, canonical_root, source);
248    }
249
250    for ext in SOURCE_EXTENSIONS {
251        let with_ext = resolved.with_extension(ext);
252        if with_ext.is_file() {
253            return validated_entry_point(&with_ext, canonical_root, source);
254        }
255    }
256
257    if let Some(index_entry) = directory_index_entry(&resolved, SOURCE_EXTENSIONS) {
258        return validated_entry_point(&index_entry, canonical_root, source);
259    }
260
261    if is_package_root_index_entry(entry)
262        && let Some(source_path) = try_source_index_fallback(base)
263    {
264        return validated_entry_point(&source_path, canonical_root, source);
265    }
266
267    None
268}
269
270fn entry_has_parent_dir(entry: &str) -> bool {
271    Path::new(entry)
272        .components()
273        .any(|component| matches!(component, Component::ParentDir))
274}
275
276fn validated_entry_point(
277    candidate: &Path,
278    canonical_root: &Path,
279    source: EntryPointSource,
280) -> Option<EntryPoint> {
281    let canonical_candidate = dunce::canonicalize(candidate).ok()?;
282    canonical_candidate
283        .starts_with(canonical_root)
284        .then(|| EntryPoint {
285            path: candidate.to_path_buf(),
286            source,
287        })
288}
289
290fn is_package_root_index_entry(entry: &str) -> bool {
291    let mut components = Path::new(entry)
292        .components()
293        .filter(|component| !matches!(component, Component::CurDir));
294
295    let Some(Component::Normal(file_name)) = components.next() else {
296        return false;
297    };
298    if components.next().is_some() {
299        return false;
300    }
301
302    file_name
303        .to_str()
304        .is_some_and(|name| name == "index" || name.starts_with("index."))
305}
306
307fn is_entry_in_output_dir(entry: &str) -> bool {
308    Path::new(entry).components().any(|component| {
309        if let Component::Normal(name) = component
310            && let Some(name) = name.to_str()
311        {
312            return OUTPUT_DIRS.contains(&name);
313        }
314        false
315    })
316}
317
318fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
319    for ext in SOURCE_EXTENSIONS {
320        let candidate = base.join("src").join(format!("index.{ext}"));
321        if candidate.is_file() {
322            return Some(candidate);
323        }
324    }
325    None
326}
327
328fn add_exportless_package_source_indexes(
329    public_api_entry_points: &mut FxHashSet<FileId>,
330    graph: &fallow_graph::graph::ModuleGraph,
331    package_root: &Path,
332    package_json: &PackageJson,
333) {
334    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
335        return;
336    }
337
338    let mut roots = vec![package_root.to_path_buf()];
339    if let Ok(canonical) = dunce::canonicalize(package_root) {
340        roots.push(canonical);
341    }
342
343    for module in &graph.modules {
344        if roots
345            .iter()
346            .any(|root| is_source_index_under_package(&module.path, root))
347        {
348            public_api_entry_points.insert(module.file_id);
349        }
350    }
351}
352
353fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
354    let Ok(relative) = path.strip_prefix(package_root) else {
355        return false;
356    };
357
358    if !matches!(
359        relative.components().next(),
360        Some(std::path::Component::Normal(segment)) if segment == "src"
361    ) {
362        return false;
363    }
364
365    path.file_stem()
366        .and_then(|stem| stem.to_str())
367        .is_some_and(|stem| stem == "index")
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::session::AnalysisSession;
374
375    fn fixture_root() -> PathBuf {
376        std::path::PathBuf::from(
377            std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
378        )
379        .join("../../tests/fixtures/public-package-members")
380    }
381
382    fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
383        let artifacts = session
384            .analyze_dead_code_with_artifacts(false, true)
385            .expect("analysis succeeds");
386        let graph = artifacts.graph.expect("retained graph");
387        public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
388    }
389
390    #[test]
391    fn workspace_public_entries_require_public_packages_selection() {
392        let root = fixture_root();
393        let unselected = AnalysisSession::load_with_config(&root, None, |config| {
394            config.public_packages.clear();
395        })
396        .expect("unselected session loads");
397
398        assert!(public_entry_paths(&unselected).is_empty());
399
400        let selected = AnalysisSession::load_with_config(&root, None, |config| {
401            config.public_packages = vec!["@workspace/public-lib".to_string()];
402        })
403        .expect("selected session loads");
404        let selected_paths = public_entry_paths(&selected);
405
406        assert_eq!(selected_paths.len(), 1);
407        assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
408    }
409
410    #[test]
411    fn package_output_entries_resolve_through_tsconfig_with_outputs_present_or_absent() {
412        let directory = tempfile::tempdir().expect("temporary project directory");
413        let root = directory.path();
414        std::fs::create_dir_all(root.join("source")).expect("source directory");
415        std::fs::create_dir_all(root.join("distribution")).expect("output directory");
416        std::fs::write(
417            root.join("package.json"),
418            r#"{
419                "name": "configured-output-package",
420                "exports": {
421                    ".": {
422                        "types": "./distribution/index.d.ts",
423                        "import": "./distribution/index.js"
424                    }
425                }
426            }"#,
427        )
428        .expect("package manifest");
429        std::fs::write(
430            root.join("tsconfig.build.json"),
431            r#"{
432                "compilerOptions": {
433                    "rootDir": "./source",
434                    "outDir": "./distribution"
435                },
436                "include": ["source"]
437            }"#,
438        )
439        .expect("TypeScript config");
440        std::fs::write(
441            root.join("source/index.ts"),
442            "export const publicValue = 1;\n",
443        )
444        .expect("source entry");
445        std::fs::write(
446            root.join("source/internal.ts"),
447            "export const internalValue = 1;\n",
448        )
449        .expect("unrelated source file");
450        std::fs::write(
451            root.join("distribution/index.js"),
452            "export const publicValue = 1;\n",
453        )
454        .expect("generated JavaScript entry");
455        std::fs::write(
456            root.join("distribution/index.d.ts"),
457            "export declare const publicValue: 1;\n",
458        )
459        .expect("generated declaration entry");
460
461        let with_outputs = AnalysisSession::load_with_config(root, None, |_| {})
462            .expect("project with outputs loads");
463        let expected = root.join("source/index.ts");
464        assert_eq!(
465            public_entry_paths(&with_outputs),
466            vec![expected.clone()],
467            "configured output entries should resolve to their source entry, without exposing unrelated source files"
468        );
469
470        std::fs::remove_dir_all(root.join("distribution")).expect("remove generated outputs");
471        let without_outputs = AnalysisSession::load_with_config(root, None, |_| {})
472            .expect("project without outputs loads");
473        assert_eq!(
474            public_entry_paths(&without_outputs),
475            vec![expected],
476            "configured output entries should still resolve when build artifacts are absent"
477        );
478    }
479
480    #[test]
481    fn out_dir_without_root_dir_resolves_public_subpaths_and_bin() {
482        let directory = tempfile::tempdir().expect("temporary project directory");
483        let root = directory.path();
484        std::fs::create_dir_all(root.join("src")).expect("source directory");
485        std::fs::write(
486            root.join("package.json"),
487            r#"{
488                "name": "out-dir-only-package",
489                "main": "./dist/index.js",
490                "bin": "./dist/cli.js",
491                "exports": {
492                    ".": "./dist/index.js",
493                    "./browser": "./dist/browser.js"
494                }
495            }"#,
496        )
497        .expect("package manifest");
498        std::fs::write(
499            root.join("tsconfig.json"),
500            r#"{"compilerOptions":{"outDir":"./dist"},"include":["src"]}"#,
501        )
502        .expect("TypeScript config");
503        for name in ["index.ts", "browser.ts", "cli.ts", "internal.ts"] {
504            std::fs::write(
505                root.join("src").join(name),
506                format!("export const {} = 1;\n", name.trim_end_matches(".ts")),
507            )
508            .expect("source module");
509        }
510
511        let session = AnalysisSession::load_with_config(root, None, |_| {})
512            .expect("outDir-only package loads");
513        let entries = public_entry_paths(&session);
514        for entry in ["src/index.ts", "src/browser.ts", "src/cli.ts"] {
515            assert!(
516                entries.iter().any(|path| path.ends_with(entry)),
517                "public output entry {entry} should map to its source, public entries: {entries:?}"
518            );
519        }
520        assert!(
521            !entries.iter().any(|path| path.ends_with("src/internal.ts")),
522            "mapping public outputs must not expose unrelated source files, public entries: {entries:?}"
523        );
524    }
525
526    #[test]
527    fn declaration_dir_without_out_dir_resolves_package_types_entries() {
528        for with_output in [true, false] {
529            let directory = tempfile::tempdir().expect("temporary project directory");
530            let root = directory.path();
531            std::fs::create_dir_all(root.join("source")).expect("source directory");
532            std::fs::write(
533                root.join("package.json"),
534                r#"{
535                    "name":"declaration-only-package",
536                    "types":"./types/index.d.ts",
537                    "exports":{".":{"types":"./types/index.d.ts"}}
538                }"#,
539            )
540            .expect("package manifest");
541            std::fs::write(
542                root.join("tsconfig.build.json"),
543                r#"{
544                    "compilerOptions": {
545                        "rootDir":"./source",
546                        "declarationDir":"./types"
547                    }
548                }"#,
549            )
550            .expect("TypeScript config");
551            std::fs::write(
552                root.join("source/index.ts"),
553                "export const publicValue = 1;\n",
554            )
555            .expect("source entry");
556            std::fs::write(
557                root.join("source/internal.ts"),
558                "export const internalValue = 1;\n",
559            )
560            .expect("unrelated source file");
561            if with_output {
562                std::fs::create_dir_all(root.join("types")).expect("types directory");
563                std::fs::write(
564                    root.join("types/index.d.ts"),
565                    "export declare const publicValue: 1;\n",
566                )
567                .expect("generated declaration entry");
568            }
569
570            let session = AnalysisSession::load_with_config(root, None, |_| {})
571                .expect("declaration-only project loads");
572            assert_eq!(
573                public_entry_paths(&session),
574                vec![root.join("source/index.ts")],
575                "declarationDir should resolve with output present={with_output}"
576            );
577        }
578    }
579
580    #[test]
581    fn lib_public_entry_maps_to_source_only_when_lib_is_missing() {
582        for with_lib in [false, true] {
583            let directory = tempfile::tempdir().expect("temporary project directory");
584            let root = directory.path();
585            std::fs::create_dir_all(root.join("src")).expect("source directory");
586            std::fs::write(
587                root.join("package.json"),
588                r#"{"name":"lib-output-package","exports":{".":"./lib/index.mjs","./package.json":"./package.json"}}"#,
589            )
590            .expect("package manifest");
591            std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
592                .expect("source entry");
593            if with_lib {
594                std::fs::create_dir_all(root.join("lib")).expect("lib directory");
595                std::fs::write(root.join("lib/index.mjs"), "export const value = 1;\n")
596                    .expect("hand-written lib entry");
597            }
598
599            let session =
600                AnalysisSession::load_with_config(root, None, |_| {}).expect("project loads");
601            let entries = public_entry_paths(&session);
602            assert_eq!(
603                entries.iter().any(|path| path.ends_with("src/index.ts")),
604                !with_lib,
605                "src/index.ts is a public entry only when lib/ is missing (with_lib={with_lib}), entries: {entries:?}"
606            );
607            assert_eq!(
608                entries.iter().any(|path| path.ends_with("lib/index.mjs")),
609                with_lib,
610                "a lib/ file on disk stays the public entry (with_lib={with_lib}), entries: {entries:?}"
611            );
612        }
613    }
614
615    /// A `noEmit` tsconfig claims `dist/` but maps no file. A bundler emits
616    /// `dist/core.mjs` from `src/core/index.ts`.
617    #[test]
618    fn configured_output_without_source_maps_public_entry_to_src() {
619        let directory = tempfile::tempdir().expect("temporary project directory");
620        let root = directory.path();
621        std::fs::create_dir_all(root.join("src/core")).expect("source directory");
622        std::fs::write(
623            root.join("package.json"),
624            r#"{"name":"bundled-package","exports":{"./core":{"types":"./dist/core/index.d.ts","import":"./dist/core.mjs"}}}"#,
625        )
626        .expect("package manifest");
627        std::fs::write(
628            root.join("tsconfig.json"),
629            r#"{"compilerOptions":{"rootDir":"./src","outDir":"./dist","noEmit":true}}"#,
630        )
631        .expect("TypeScript config");
632        std::fs::write(root.join("src/core/index.ts"), "export const value = 1;\n")
633            .expect("source entry");
634        std::fs::write(root.join("src/internal.ts"), "export const value = 1;\n")
635            .expect("unrelated source file");
636
637        let session = AnalysisSession::load_with_config(root, None, |_| {}).expect("project loads");
638        let entries = public_entry_paths(&session);
639        assert!(
640            entries
641                .iter()
642                .any(|path| path.ends_with("src/core/index.ts")),
643            "the bundled output maps to its source directory index, entries: {entries:?}"
644        );
645        assert!(
646            !entries.iter().any(|path| path.ends_with("src/internal.ts")),
647            "an unrelated source file must not become a public entry, entries: {entries:?}"
648        );
649    }
650
651    #[test]
652    fn ignored_lib_public_entry_on_disk_maps_to_source() {
653        let directory = tempfile::tempdir().expect("temporary project directory");
654        let root = directory.path();
655        std::fs::create_dir_all(root.join("src")).expect("source directory");
656        std::fs::create_dir_all(root.join("lib")).expect("lib directory");
657        std::fs::write(
658            root.join("package.json"),
659            r#"{"name":"lib-output-package","exports":{".":"./lib/index.mjs"}}"#,
660        )
661        .expect("package manifest");
662        std::fs::write(
663            root.join(".fallowrc.json"),
664            r#"{"ignorePatterns":["lib/**"]}"#,
665        )
666        .expect("fallow config");
667        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
668            .expect("source entry");
669        std::fs::write(root.join("lib/index.mjs"), "export const value = 1;\n")
670            .expect("ignored build output");
671
672        let session = AnalysisSession::load_with_config(root, None, |_| {}).expect("project loads");
673        let entries = public_entry_paths(&session);
674        assert!(
675            entries.iter().any(|path| path.ends_with("src/index.ts")),
676            "a lib/ entry outside the discovered file set maps to src/, entries: {entries:?}"
677        );
678    }
679
680    #[test]
681    fn ambiguous_configured_output_does_not_use_legacy_public_entry_guess() {
682        for with_output in [true, false] {
683            let directory = tempfile::tempdir().expect("temporary project directory");
684            let root = directory.path();
685            for source_dir in ["source", "src"] {
686                std::fs::create_dir_all(root.join(source_dir)).expect("source directory");
687                std::fs::write(
688                    root.join(source_dir).join("index.ts"),
689                    "export const value = 1;\n",
690                )
691                .expect("source entry");
692            }
693            std::fs::write(
694                root.join("package.json"),
695                r#"{"name":"ambiguous-output-package","exports":{".":"./dist/index.js","./runtime":"./runtime.ts"}}"#,
696            )
697            .expect("package manifest");
698            std::fs::write(root.join("runtime.ts"), "export const runtime = 1;\n")
699                .expect("independent runtime export");
700            for (config_name, source_dir) in [
701                ("tsconfig.source.json", "source"),
702                ("tsconfig.src.json", "src"),
703            ] {
704                std::fs::write(
705                    root.join(config_name),
706                    format!(
707                        r#"{{"compilerOptions":{{"rootDir":"./{source_dir}","outDir":"./dist"}}}}"#
708                    ),
709                )
710                .expect("TypeScript config");
711            }
712            if with_output {
713                std::fs::create_dir_all(root.join("dist")).expect("output directory");
714                std::fs::write(root.join("dist/index.js"), "export const value = 1;\n")
715                    .expect("generated output");
716            }
717
718            let session = AnalysisSession::load_with_config(root, None, |_| {})
719                .expect("ambiguous project loads");
720            let entries = public_entry_paths(&session);
721            assert!(
722                !entries.iter().any(|path| path.ends_with("src/index.ts")),
723                "an ambiguous configured map must not expose the legacy src guess with output present={with_output}, entries: {entries:?}"
724            );
725            assert!(
726                !entries.iter().any(|path| path.ends_with("source/index.ts")),
727                "an ambiguous configured map must not expose either source candidate, entries: {entries:?}"
728            );
729        }
730
731        let legacy = tempfile::tempdir().expect("legacy project directory");
732        std::fs::create_dir_all(legacy.path().join("src")).expect("legacy source directory");
733        std::fs::write(
734            legacy.path().join("package.json"),
735            r#"{"name":"legacy-output-package","main":"./dist/index.js"}"#,
736        )
737        .expect("package manifest");
738        std::fs::write(
739            legacy.path().join("src/index.ts"),
740            "export const value = 1;\n",
741        )
742        .expect("legacy source entry");
743        let session = AnalysisSession::load_with_config(legacy.path(), None, |_| {})
744            .expect("legacy project loads");
745        assert!(
746            public_entry_paths(&session)
747                .iter()
748                .any(|path| path.ends_with("src/index.ts")),
749            "the legacy dist/src convention should still resolve without a matching config"
750        );
751    }
752}