1use 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#[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#[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#[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
187fn 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 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 #[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}