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 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
189fn 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 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 #[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}