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