1use std::path::{Component, Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_graph::resolve::OUTPUT_DIRS;
7use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
8use fallow_types::path_util::is_absolute_path_any_platform;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
12
13fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
14 if skipped_entries.is_empty() {
15 return None;
16 }
17
18 let mut entries = skipped_entries
19 .iter()
20 .map(|(path, count)| (path.as_str(), *count))
21 .collect::<Vec<_>>();
22 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
23
24 let preview = entries
25 .iter()
26 .take(SKIPPED_ENTRY_WARNING_PREVIEW)
27 .map(|(path, count)| {
28 if *count > 1 {
29 format!("{path} ({count}x)")
30 } else {
31 (*path).to_owned()
32 }
33 })
34 .collect::<Vec<_>>();
35
36 let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
37 let tail = if omitted > 0 {
38 format!(" (and {omitted} more)")
39 } else {
40 String::new()
41 };
42 let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
43 let noun = if total == 1 {
44 "package.json entry point"
45 } else {
46 "package.json entry points"
47 };
48
49 Some(format!(
50 "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
51 preview.join(", ")
52 ))
53}
54
55pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
56 let Some(message) = format_skipped_entry_warning(skipped_entries) else {
57 return;
58 };
59 if should_warn_skipped_entry(&message) {
60 tracing::warn!("{message}");
61 }
62}
63
64fn should_warn_skipped_entry(message: &str) -> bool {
68 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
69 std::sync::OnceLock::new();
70 let warned = WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()));
71 warned
72 .lock()
73 .map_or(true, |mut set| set.insert(message.to_owned()))
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct CategorizedEntryPoints {
79 pub(crate) all: Vec<EntryPoint>,
80 pub(crate) runtime: Vec<EntryPoint>,
81 pub(crate) test: Vec<EntryPoint>,
82}
83
84impl CategorizedEntryPoints {
85 fn push_runtime(&mut self, entry: EntryPoint) {
86 self.runtime.push(entry.clone());
87 self.all.push(entry);
88 }
89
90 fn push_test(&mut self, entry: EntryPoint) {
91 self.test.push(entry.clone());
92 self.all.push(entry);
93 }
94
95 fn push_support(&mut self, entry: EntryPoint) {
96 self.all.push(entry);
97 }
98
99 pub(crate) fn extend_runtime<I>(&mut self, entries: I)
100 where
101 I: IntoIterator<Item = EntryPoint>,
102 {
103 for entry in entries {
104 self.push_runtime(entry);
105 }
106 }
107
108 pub fn extend_test<I>(&mut self, entries: I)
109 where
110 I: IntoIterator<Item = EntryPoint>,
111 {
112 for entry in entries {
113 self.push_test(entry);
114 }
115 }
116
117 pub fn extend_support<I>(&mut self, entries: I)
118 where
119 I: IntoIterator<Item = EntryPoint>,
120 {
121 for entry in entries {
122 self.push_support(entry);
123 }
124 }
125
126 pub(crate) fn extend(&mut self, other: Self) {
127 self.all.extend(other.all);
128 self.runtime.extend(other.runtime);
129 self.test.extend(other.test);
130 }
131
132 #[must_use]
133 pub(crate) fn dedup(mut self) -> Self {
134 dedup_entry_paths(&mut self.all);
135 dedup_entry_paths(&mut self.runtime);
136 dedup_entry_paths(&mut self.test);
137 self
138 }
139}
140
141fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
142 entries.sort_by(|a, b| a.path.cmp(&b.path));
143 entries.dedup_by(|a, b| a.path == b.path);
144}
145
146#[derive(Debug, Default)]
147pub struct EntryPointDiscovery {
148 pub entries: Vec<EntryPoint>,
149 pub skipped_entries: FxHashMap<String, usize>,
150}
151
152fn resolve_entry_path_with_tracking(
159 base: &Path,
160 entry: &str,
161 canonical_root: &Path,
162 source: EntryPointSource,
163 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
164) -> Option<EntryPoint> {
165 if entry.contains('*') {
166 return None;
167 }
168
169 if entry_has_parent_dir(entry) {
170 record_or_warn_skipped_entry(
171 skipped_entries.as_deref_mut(),
172 entry,
173 "Skipping entry point containing parent directory traversal",
174 );
175 return None;
176 }
177
178 if let OutputDirEntry::ShortCircuit(result) = resolve_entry_via_output_dir(
179 base,
180 entry,
181 canonical_root,
182 source.clone(),
183 skipped_entries.as_deref_mut(),
184 ) {
185 return result;
186 }
187
188 resolve_entry_via_filesystem_probe(base, entry, canonical_root, source, skipped_entries)
189}
190
191fn record_or_warn_skipped_entry(
193 skipped_entries: Option<&mut FxHashMap<String, usize>>,
194 entry: &str,
195 warning: &str,
196) {
197 if let Some(skipped_entries) = skipped_entries {
198 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
199 } else {
200 tracing::warn!(path = %entry, "{warning}");
201 }
202}
203
204enum OutputDirEntry {
210 ShortCircuit(Option<EntryPoint>),
211 Continue,
212}
213
214fn resolve_entry_via_output_dir(
216 base: &Path,
217 entry: &str,
218 canonical_root: &Path,
219 source: EntryPointSource,
220 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
221) -> OutputDirEntry {
222 if let Some(source_path) = try_output_to_source_path(base, entry) {
223 return OutputDirEntry::ShortCircuit(validated_entry_point(
224 &source_path,
225 canonical_root,
226 entry,
227 source,
228 skipped_entries.as_deref_mut(),
229 ));
230 }
231
232 if is_entry_in_output_dir(entry)
233 && let Some(source_path) = try_source_index_fallback(base)
234 {
235 tracing::info!(
236 entry = %entry,
237 fallback = %source_path.display(),
238 "package.json entry resolves to an ignored output directory; falling back to source index"
239 );
240 return OutputDirEntry::ShortCircuit(validated_entry_point(
241 &source_path,
242 canonical_root,
243 entry,
244 source,
245 skipped_entries,
246 ));
247 }
248
249 OutputDirEntry::Continue
250}
251
252fn resolve_entry_via_filesystem_probe(
255 base: &Path,
256 entry: &str,
257 canonical_root: &Path,
258 source: EntryPointSource,
259 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
260) -> Option<EntryPoint> {
261 let resolved = base.join(entry);
262
263 if resolved.is_file() {
264 return validated_entry_point(
265 &resolved,
266 canonical_root,
267 entry,
268 source,
269 skipped_entries.as_deref_mut(),
270 );
271 }
272
273 for ext in SOURCE_EXTENSIONS {
274 let with_ext = resolved.with_extension(ext);
275 if with_ext.is_file() {
276 return validated_entry_point(
277 &with_ext,
278 canonical_root,
279 entry,
280 source,
281 skipped_entries.as_deref_mut(),
282 );
283 }
284 }
285
286 if let Some(index_entry) = try_directory_index_entry(&resolved) {
287 return validated_entry_point(
288 &index_entry,
289 canonical_root,
290 entry,
291 source,
292 skipped_entries.as_deref_mut(),
293 );
294 }
295
296 if is_package_root_index_entry(entry)
297 && let Some(source_path) = try_source_index_fallback(base)
298 {
299 tracing::info!(
300 entry = %entry,
301 fallback = %source_path.display(),
302 "package.json root index entry is missing; falling back to source index"
303 );
304 return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
305 }
306 None
307}
308
309fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
310 for ext in SOURCE_EXTENSIONS {
311 let candidate = resolved.join(format!("index.{ext}"));
312 if candidate.is_file() {
313 return Some(candidate);
314 }
315 }
316 None
317}
318
319fn entry_has_parent_dir(entry: &str) -> bool {
320 Path::new(entry)
321 .components()
322 .any(|component| matches!(component, Component::ParentDir))
323}
324
325fn is_package_root_index_entry(entry: &str) -> bool {
326 let mut components = Path::new(entry)
327 .components()
328 .filter(|component| !matches!(component, Component::CurDir));
329
330 let Some(Component::Normal(file_name)) = components.next() else {
331 return false;
332 };
333 if components.next().is_some() {
334 return false;
335 }
336
337 file_name
338 .to_str()
339 .is_some_and(|name| name == "index" || name.starts_with("index."))
340}
341
342fn validated_entry_point(
343 candidate: &Path,
344 canonical_root: &Path,
345 entry: &str,
346 source: EntryPointSource,
347 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
348) -> Option<EntryPoint> {
349 let canonical_candidate = match dunce::canonicalize(candidate) {
350 Ok(path) => path,
351 Err(err) => {
352 tracing::warn!(
353 path = %candidate.display(),
354 %entry,
355 error = %err,
356 "Skipping entry point that could not be canonicalized"
357 );
358 return None;
359 }
360 };
361
362 if !canonical_candidate.starts_with(canonical_root) {
363 if let Some(skipped_entries) = skipped_entries.as_mut() {
364 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
365 } else {
366 tracing::warn!(
367 path = %candidate.display(),
368 %entry,
369 "Skipping entry point outside project root"
370 );
371 }
372 return None;
373 }
374
375 Some(EntryPoint {
376 path: candidate.to_path_buf(),
377 source,
378 })
379}
380
381pub fn resolve_entry_path(
382 base: &Path,
383 entry: &str,
384 canonical_root: &Path,
385 source: EntryPointSource,
386) -> Option<EntryPoint> {
387 resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
388}
389fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
401 let entry_path = Path::new(entry);
402 let components: Vec<_> = entry_path.components().collect();
403
404 let output_pos = components.iter().rposition(|c| {
405 if let std::path::Component::Normal(s) = c
406 && let Some(name) = s.to_str()
407 {
408 return OUTPUT_DIRS.contains(&name);
409 }
410 false
411 })?;
412
413 let prefix: PathBuf = components[..output_pos]
414 .iter()
415 .filter(|c| !matches!(c, std::path::Component::CurDir))
416 .collect();
417
418 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
419
420 for ext in SOURCE_EXTENSIONS {
421 let source_candidate = base
422 .join(&prefix)
423 .join("src")
424 .join(suffix.with_extension(ext));
425 if source_candidate.exists() {
426 return Some(source_candidate);
427 }
428 }
429
430 None
431}
432
433const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
436
437fn is_entry_in_output_dir(entry: &str) -> bool {
442 Path::new(entry).components().any(|c| {
443 matches!(
444 c,
445 std::path::Component::Normal(s)
446 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
447 )
448 })
449}
450
451fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
458 for stem in SOURCE_INDEX_FALLBACK_STEMS {
459 for ext in SOURCE_EXTENSIONS {
460 let candidate = base.join(format!("{stem}.{ext}"));
461 if candidate.is_file() {
462 return Some(candidate);
463 }
464 }
465 }
466 None
467}
468
469const DEFAULT_INDEX_PATTERNS: &[&str] = &[
471 "src/index.{ts,tsx,js,jsx}",
472 "src/main.{ts,tsx,js,jsx}",
473 "index.{ts,tsx,js,jsx}",
474 "main.{ts,tsx,js,jsx}",
475];
476
477fn apply_default_fallback(
482 files: &[DiscoveredFile],
483 root: &Path,
484 ws_filter: Option<&Path>,
485) -> Vec<EntryPoint> {
486 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
487 .iter()
488 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
489 .collect();
490
491 let mut entries = Vec::new();
492 for file in files {
493 if let Some(ws_root) = ws_filter
494 && file.path.strip_prefix(ws_root).is_err()
495 {
496 continue;
497 }
498 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
499 let relative_str = relative.to_string_lossy();
500 if default_matchers
501 .iter()
502 .any(|m| m.is_match(relative_str.as_ref()))
503 {
504 entries.push(EntryPoint {
505 path: file.path.clone(),
506 source: EntryPointSource::DefaultIndex,
507 });
508 }
509 }
510 entries
511}
512
513fn relative_paths_for(files: &[DiscoveredFile], root: &Path) -> Vec<String> {
515 files
516 .iter()
517 .map(|f| {
518 f.path
519 .strip_prefix(root)
520 .unwrap_or(&f.path)
521 .to_string_lossy()
522 .into_owned()
523 })
524 .collect()
525}
526
527#[expect(
529 clippy::expect_used,
530 reason = "entry glob patterns are validated before entry point discovery"
531)]
532fn push_manual_entry_matches(
533 entries: &mut Vec<EntryPoint>,
534 config: &ResolvedConfig,
535 relative_paths: &[String],
536 files: &[DiscoveredFile],
537) {
538 let mut builder = globset::GlobSetBuilder::new();
539 for pattern in &config.entry_patterns {
540 builder.add(
541 globset::Glob::new(pattern).expect("entry pattern was validated at config load time"),
542 );
543 }
544 let Ok(glob_set) = builder.build() else {
545 return;
546 };
547 if glob_set.is_empty() {
548 return;
549 }
550 for (idx, rel) in relative_paths.iter().enumerate() {
551 if glob_set.is_match(rel) {
552 entries.push(EntryPoint {
553 path: files[idx].path.clone(),
554 source: EntryPointSource::ManualEntry,
555 });
556 }
557 }
558}
559
560fn push_package_json_entries(
562 discovery: &mut EntryPointDiscovery,
563 root: &Path,
564 pkg: &PackageJson,
565 canonical_root: &Path,
566) {
567 for entry_path in pkg.entry_points() {
568 if let Some(ep) = resolve_entry_path_with_tracking(
569 root,
570 &entry_path,
571 canonical_root,
572 EntryPointSource::PackageJsonMain,
573 Some(&mut discovery.skipped_entries),
574 ) {
575 discovery.entries.push(ep);
576 }
577 }
578
579 let Some(scripts) = &pkg.scripts else {
580 return;
581 };
582 for script_value in scripts.values() {
583 for file_ref in extract_script_file_refs(script_value) {
584 if let Some(ep) = resolve_entry_path_with_tracking(
585 root,
586 &file_ref,
587 canonical_root,
588 EntryPointSource::PackageJsonScript,
589 Some(&mut discovery.skipped_entries),
590 ) {
591 discovery.entries.push(ep);
592 }
593 }
594 }
595}
596
597fn discover_entry_points_with_warnings_impl(
599 config: &ResolvedConfig,
600 files: &[DiscoveredFile],
601 root_pkg: Option<&PackageJson>,
602 include_nested_package_entries: bool,
603) -> EntryPointDiscovery {
604 let _span = tracing::info_span!("discover_entry_points").entered();
605 let mut discovery = EntryPointDiscovery::default();
606
607 let relative_paths = relative_paths_for(files, &config.root);
608 push_manual_entry_matches(&mut discovery.entries, config, &relative_paths, files);
609
610 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
611 if let Some(pkg) = root_pkg {
612 push_package_json_entries(&mut discovery, &config.root, pkg, &canonical_root);
613 }
614
615 if include_nested_package_entries {
616 let exports_dirs = root_pkg
617 .map(PackageJson::exports_subdirectories)
618 .unwrap_or_default();
619 discover_nested_package_entries(
620 &config.root,
621 files,
622 &mut discovery.entries,
623 &canonical_root,
624 &exports_dirs,
625 &mut discovery.skipped_entries,
626 );
627 }
628
629 if discovery.entries.is_empty() {
630 discovery.entries = apply_default_fallback(files, &config.root, None);
631 }
632
633 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
634 discovery.entries.dedup_by(|a, b| a.path == b.path);
635
636 discovery
637}
638
639pub fn discover_entry_points_with_warnings_from_pkg(
640 config: &ResolvedConfig,
641 files: &[DiscoveredFile],
642 root_pkg: Option<&PackageJson>,
643 include_nested_package_entries: bool,
644) -> EntryPointDiscovery {
645 discover_entry_points_with_warnings_impl(
646 config,
647 files,
648 root_pkg,
649 include_nested_package_entries,
650 )
651}
652
653pub fn discover_entry_points_with_warnings(
654 config: &ResolvedConfig,
655 files: &[DiscoveredFile],
656) -> EntryPointDiscovery {
657 let root_pkg = fallow_config::load_dir_package_json(&config.root);
658 discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
659}
660
661pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
662 let discovery = discover_entry_points_with_warnings(config, files);
663 warn_skipped_entry_summary(&discovery.skipped_entries);
664 discovery.entries
665}
666
667fn discover_nested_package_entries(
677 root: &Path,
678 _files: &[DiscoveredFile],
679 entries: &mut Vec<EntryPoint>,
680 canonical_root: &Path,
681 exports_subdirectories: &[String],
682 skipped_entries: &mut FxHashMap<String, usize>,
683) {
684 let mut visited = rustc_hash::FxHashSet::default();
685
686 let search_dirs = [
687 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
688 ];
689 for dir_name in &search_dirs {
690 let search_dir = root.join(dir_name);
691 if !search_dir.is_dir() {
692 continue;
693 }
694 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
695 continue;
696 };
697 for entry in read_dir.flatten() {
698 let pkg_dir = entry.path();
699 if visited.insert(pkg_dir.clone()) {
700 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
701 }
702 }
703 }
704
705 for dir_name in exports_subdirectories {
706 let pkg_dir = root.join(dir_name);
707 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
708 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
709 }
710 }
711}
712
713fn collect_nested_package_entries(
715 pkg_dir: &Path,
716 entries: &mut Vec<EntryPoint>,
717 canonical_root: &Path,
718 skipped_entries: &mut FxHashMap<String, usize>,
719) {
720 let Some(pkg) = fallow_config::load_dir_package_json(pkg_dir) else {
721 return;
722 };
723 for entry_path in pkg.entry_points() {
724 if entry_path.contains('*') {
725 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
726 } else if let Some(ep) = resolve_entry_path_with_tracking(
727 pkg_dir,
728 &entry_path,
729 canonical_root,
730 EntryPointSource::PackageJsonExports,
731 Some(&mut *skipped_entries),
732 ) {
733 entries.push(ep);
734 }
735 }
736 if let Some(scripts) = &pkg.scripts {
737 for script_value in scripts.values() {
738 for file_ref in extract_script_file_refs(script_value) {
739 if let Some(ep) = resolve_entry_path_with_tracking(
740 pkg_dir,
741 &file_ref,
742 canonical_root,
743 EntryPointSource::PackageJsonScript,
744 Some(&mut *skipped_entries),
745 ) {
746 entries.push(ep);
747 }
748 }
749 }
750 }
751}
752
753fn expand_wildcard_entries(
759 base: &Path,
760 pattern: &str,
761 canonical_root: &Path,
762 entries: &mut Vec<EntryPoint>,
763) {
764 let full_pattern = base.join(pattern).to_string_lossy().to_string();
765 let Ok(matches) = glob::glob(&full_pattern) else {
766 return;
767 };
768 for path_result in matches {
769 let Ok(path) = path_result else {
770 continue;
771 };
772 if let Ok(canonical) = dunce::canonicalize(&path)
773 && canonical.starts_with(canonical_root)
774 {
775 entries.push(EntryPoint {
776 path,
777 source: EntryPointSource::PackageJsonExports,
778 });
779 }
780 }
781}
782
783#[must_use]
785fn discover_workspace_entry_points_with_warnings_impl(
786 ws_root: &Path,
787 all_files: &[DiscoveredFile],
788 pkg: Option<&PackageJson>,
789) -> EntryPointDiscovery {
790 let mut discovery = EntryPointDiscovery::default();
791
792 if let Some(pkg) = pkg {
793 let canonical_ws_root =
794 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
795 for entry_path in pkg.entry_points() {
796 if entry_path.contains('*') {
797 expand_wildcard_entries(
798 ws_root,
799 &entry_path,
800 &canonical_ws_root,
801 &mut discovery.entries,
802 );
803 } else if let Some(ep) = resolve_entry_path_with_tracking(
804 ws_root,
805 &entry_path,
806 &canonical_ws_root,
807 EntryPointSource::PackageJsonMain,
808 Some(&mut discovery.skipped_entries),
809 ) {
810 discovery.entries.push(ep);
811 }
812 }
813
814 if let Some(scripts) = &pkg.scripts {
815 for script_value in scripts.values() {
816 for file_ref in extract_script_file_refs(script_value) {
817 if let Some(ep) = resolve_entry_path_with_tracking(
818 ws_root,
819 &file_ref,
820 &canonical_ws_root,
821 EntryPointSource::PackageJsonScript,
822 Some(&mut discovery.skipped_entries),
823 ) {
824 discovery.entries.push(ep);
825 }
826 }
827 }
828 }
829 }
830
831 if discovery.entries.is_empty() {
832 discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
833 }
834
835 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
836 discovery.entries.dedup_by(|a, b| a.path == b.path);
837 discovery
838}
839
840pub fn discover_workspace_entry_points_with_warnings_from_pkg(
841 ws_root: &Path,
842 all_files: &[DiscoveredFile],
843 pkg: Option<&PackageJson>,
844) -> EntryPointDiscovery {
845 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
846}
847
848#[must_use]
849pub fn discover_workspace_entry_points_with_warnings(
850 ws_root: &Path,
851 _config: &ResolvedConfig,
852 all_files: &[DiscoveredFile],
853) -> EntryPointDiscovery {
854 let pkg = fallow_config::load_dir_package_json(ws_root);
855 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
856}
857
858#[must_use]
859pub fn discover_workspace_entry_points(
860 ws_root: &Path,
861 config: &ResolvedConfig,
862 all_files: &[DiscoveredFile],
863) -> Vec<EntryPoint> {
864 let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
865 warn_skipped_entry_summary(&discovery.skipped_entries);
866 discovery.entries
867}
868
869#[must_use]
874pub fn discover_plugin_entry_points(
875 plugin_result: &crate::plugins::AggregatedPluginResult,
876 config: &ResolvedConfig,
877 files: &[DiscoveredFile],
878) -> Vec<EntryPoint> {
879 discover_plugin_entry_point_sets(plugin_result, config, files).all
880}
881
882#[must_use]
884pub fn discover_plugin_entry_point_sets(
885 plugin_result: &crate::plugins::AggregatedPluginResult,
886 config: &ResolvedConfig,
887 files: &[DiscoveredFile],
888) -> CategorizedEntryPoints {
889 let mut entries = CategorizedEntryPoints::default();
890
891 let relative_paths = relative_paths_for(files, &config.root);
892 let (glob_set, glob_meta) = build_plugin_glob_meta(plugin_result);
893 if let Some(glob_set) = glob_set.filter(|set| !set.is_empty()) {
894 match_plugin_entry_files(&mut entries, &glob_set, &glob_meta, &relative_paths, files);
895 }
896
897 push_plugin_setup_files(&mut entries, &config.root, plugin_result);
898
899 entries.dedup()
900}
901
902fn build_plugin_glob_meta(
904 plugin_result: &crate::plugins::AggregatedPluginResult,
905) -> (Option<globset::GlobSet>, Vec<CompiledEntryRule<'_>>) {
906 let mut builder = globset::GlobSetBuilder::new();
907 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
908 for (rule, pname) in &plugin_result.entry_patterns {
909 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
910 builder.add(include);
911 glob_meta.push(compiled);
912 }
913 }
914 for (pattern, pname) in plugin_result
915 .discovered_always_used
916 .iter()
917 .chain(plugin_result.always_used.iter())
918 .chain(plugin_result.fixture_patterns.iter())
919 {
920 if let Ok(glob) = globset::GlobBuilder::new(pattern)
921 .literal_separator(true)
922 .build()
923 {
924 builder.add(glob);
925 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
926 &crate::plugins::PathRule::new(pattern.clone()),
927 "support entry pattern",
928 ) {
929 glob_meta.push(CompiledEntryRule {
930 path,
931 plugin_name: pname,
932 role: EntryPointRole::Support,
933 });
934 }
935 }
936 }
937 (builder.build().ok(), glob_meta)
938}
939
940fn match_plugin_entry_files(
942 entries: &mut CategorizedEntryPoints,
943 glob_set: &globset::GlobSet,
944 glob_meta: &[CompiledEntryRule<'_>],
945 relative_paths: &[String],
946 files: &[DiscoveredFile],
947) {
948 for (idx, rel) in relative_paths.iter().enumerate() {
949 let matches: Vec<usize> = glob_set
950 .matches(rel)
951 .into_iter()
952 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
953 .collect();
954 if matches.is_empty() {
955 continue;
956 }
957 let name = glob_meta[matches[0]].plugin_name;
958 let entry = EntryPoint {
959 path: files[idx].path.clone(),
960 source: EntryPointSource::Plugin {
961 name: name.to_string(),
962 },
963 };
964 categorize_plugin_match(entries, entry, glob_meta, &matches);
965 }
966}
967
968fn categorize_plugin_match(
970 entries: &mut CategorizedEntryPoints,
971 entry: EntryPoint,
972 glob_meta: &[CompiledEntryRule<'_>],
973 matches: &[usize],
974) {
975 let mut has_runtime = false;
976 let mut has_test = false;
977 let mut has_support = false;
978 for &match_idx in matches {
979 match glob_meta[match_idx].role {
980 EntryPointRole::Runtime => has_runtime = true,
981 EntryPointRole::Test => has_test = true,
982 EntryPointRole::Support => has_support = true,
983 }
984 }
985
986 if has_runtime {
987 entries.push_runtime(entry.clone());
988 }
989 if has_test {
990 entries.push_test(entry.clone());
991 }
992 if has_support || (!has_runtime && !has_test) {
993 entries.push_support(entry);
994 }
995}
996
997fn push_plugin_setup_files(
999 entries: &mut CategorizedEntryPoints,
1000 root: &Path,
1001 plugin_result: &crate::plugins::AggregatedPluginResult,
1002) {
1003 for (setup_file, pname) in &plugin_result.setup_files {
1004 let resolved = resolve_plugin_setup_file(root, setup_file);
1005 if resolved.exists() {
1006 entries.push_support(EntryPoint {
1007 path: resolved,
1008 source: EntryPointSource::Plugin {
1009 name: pname.clone(),
1010 },
1011 });
1012 continue;
1013 }
1014 for ext in SOURCE_EXTENSIONS {
1015 let with_ext = resolved.with_extension(ext);
1016 if with_ext.exists() {
1017 entries.push_support(EntryPoint {
1018 path: with_ext,
1019 source: EntryPointSource::Plugin {
1020 name: pname.clone(),
1021 },
1022 });
1023 break;
1024 }
1025 }
1026 }
1027}
1028
1029fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
1030 if is_absolute_path_any_platform(setup_file) {
1031 setup_file.to_path_buf()
1032 } else {
1033 root.join(setup_file)
1034 }
1035}
1036
1037#[expect(
1042 clippy::expect_used,
1043 reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
1044)]
1045#[must_use]
1046pub fn discover_dynamically_loaded_entry_points(
1047 config: &ResolvedConfig,
1048 files: &[DiscoveredFile],
1049) -> Vec<EntryPoint> {
1050 if config.dynamically_loaded.is_empty() {
1051 return Vec::new();
1052 }
1053
1054 let mut builder = globset::GlobSetBuilder::new();
1055 for pattern in &config.dynamically_loaded {
1056 builder.add(
1057 globset::Glob::new(pattern)
1058 .expect("dynamicallyLoaded pattern was validated at config load time"),
1059 );
1060 }
1061 let Ok(glob_set) = builder.build() else {
1062 return Vec::new();
1063 };
1064 if glob_set.is_empty() {
1065 return Vec::new();
1066 }
1067
1068 let mut entries = Vec::new();
1069 for file in files {
1070 let rel = file
1071 .path
1072 .strip_prefix(&config.root)
1073 .unwrap_or(&file.path)
1074 .to_string_lossy();
1075 if glob_set.is_match(rel.as_ref()) {
1076 entries.push(EntryPoint {
1077 path: file.path.clone(),
1078 source: EntryPointSource::DynamicallyLoaded,
1079 });
1080 }
1081 }
1082 entries
1083}
1084
1085struct CompiledEntryRule<'a> {
1086 path: crate::plugins::CompiledPathRule,
1087 plugin_name: &'a str,
1088 role: EntryPointRole,
1089}
1090
1091impl CompiledEntryRule<'_> {
1092 fn matches(&self, path: &str) -> bool {
1093 self.path.matches(path)
1094 }
1095}
1096
1097fn compile_entry_rule<'a>(
1098 rule: &'a crate::plugins::PathRule,
1099 plugin_name: &'a str,
1100 plugin_result: &'a crate::plugins::AggregatedPluginResult,
1101) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1102 let include = match globset::GlobBuilder::new(&rule.pattern)
1103 .literal_separator(true)
1104 .build()
1105 {
1106 Ok(glob) => glob,
1107 Err(err) => {
1108 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1109 return None;
1110 }
1111 };
1112 let role = plugin_result
1113 .entry_point_roles
1114 .get(plugin_name)
1115 .copied()
1116 .unwrap_or(EntryPointRole::Support);
1117 Some((
1118 include,
1119 CompiledEntryRule {
1120 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1121 plugin_name,
1122 role,
1123 },
1124 ))
1125}
1126
1127#[must_use]
1129pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1130 if patterns.is_empty() {
1131 return None;
1132 }
1133 let mut builder = globset::GlobSetBuilder::new();
1134 for pattern in patterns {
1135 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1136 .literal_separator(true)
1137 .build()
1138 {
1139 builder.add(glob);
1140 }
1141 }
1142 builder.build().ok()
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1149 use fallow_types::discover::FileId;
1150 use proptest::prelude::*;
1151
1152 proptest! {
1153 #[test]
1155 fn glob_patterns_never_panic_on_compile(
1156 prefix in "[a-zA-Z0-9_]{1,20}",
1157 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1158 ) {
1159 let pattern = format!("**/{prefix}*.{ext}");
1160 let result = globset::Glob::new(&pattern);
1161 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1162 }
1163
1164 #[test]
1166 fn non_source_extensions_not_in_list(
1167 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1168 ) {
1169 prop_assert!(
1170 !SOURCE_EXTENSIONS.contains(&ext),
1171 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1172 );
1173 }
1174
1175 #[test]
1177 fn compile_glob_set_no_panic(
1178 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1179 ) {
1180 let _ = compile_glob_set(&patterns);
1181 }
1182 }
1183
1184 #[test]
1185 fn compile_glob_set_empty_input() {
1186 assert!(
1187 compile_glob_set(&[]).is_none(),
1188 "empty patterns should return None"
1189 );
1190 }
1191
1192 #[test]
1193 fn compile_glob_set_valid_patterns() {
1194 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1195 let set = compile_glob_set(&patterns);
1196 assert!(set.is_some(), "valid patterns should compile");
1197 let set = set.unwrap();
1198 assert!(set.is_match("src/foo.ts"));
1199 assert!(set.is_match("src/bar.js"));
1200 assert!(!set.is_match("src/bar.py"));
1201 }
1202
1203 #[test]
1204 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1205 let patterns = vec!["composables/*.{ts,js}".to_string()];
1206 let set = compile_glob_set(&patterns).expect("pattern should compile");
1207
1208 assert!(set.is_match("composables/useFoo.ts"));
1209 assert!(!set.is_match("composables/nested/useFoo.ts"));
1210 }
1211
1212 #[test]
1213 #[expect(
1214 clippy::too_many_lines,
1215 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
1216 )]
1217 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1218 let dir = tempfile::tempdir().expect("create temp dir");
1219 let root = dir.path();
1220 std::fs::create_dir_all(root.join("src")).unwrap();
1221 std::fs::create_dir_all(root.join("tests")).unwrap();
1222 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1223 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1224 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1225
1226 let config = FallowConfig {
1227 type_aware: fallow_config::TypeAwareConfig::default(),
1228 schema: None,
1229 extends: vec![],
1230 entry: vec![],
1231 ignore_patterns: vec![],
1232 ignore_findings: vec![],
1233 framework: vec![],
1234 workspaces: None,
1235 ignore_dependencies: vec![],
1236 ignore_unresolved_imports: vec![],
1237 ignore_exports: vec![],
1238 ignore_catalog_references: vec![],
1239 ignore_dependency_overrides: vec![],
1240 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1241 used_class_members: vec![],
1242 ignore_decorators: vec![],
1243 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1244 duplicates: fallow_config::DuplicatesConfig::default(),
1245 health: fallow_config::HealthConfig::default(),
1246 rules: RulesConfig::default(),
1247 boundaries: fallow_config::BoundaryConfig::default(),
1248 production: false.into(),
1249 plugins: vec![],
1250 rule_packs: vec![],
1251 dynamically_loaded: vec![],
1252 overrides: vec![],
1253 regression: None,
1254 audit: fallow_config::AuditConfig::default(),
1255 codeowners: None,
1256 public_packages: vec![],
1257 flags: fallow_config::FlagsConfig::default(),
1258 security: fallow_config::SecurityConfig::default(),
1259 fix: fallow_config::FixConfig::default(),
1260 resolve: fallow_config::ResolveConfig::default(),
1261 sealed: false,
1262 include_entry_exports: false,
1263 auto_imports: false,
1264 cache: fallow_config::CacheConfig::default(),
1265 }
1266 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1267
1268 let files = vec![
1269 DiscoveredFile {
1270 id: FileId(0),
1271 path: root.join("src/runtime.ts"),
1272 size_bytes: 1,
1273 },
1274 DiscoveredFile {
1275 id: FileId(1),
1276 path: root.join("src/setup.ts"),
1277 size_bytes: 1,
1278 },
1279 DiscoveredFile {
1280 id: FileId(2),
1281 path: root.join("tests/app.test.ts"),
1282 size_bytes: 1,
1283 },
1284 ];
1285
1286 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1287 plugin_result.entry_patterns.push((
1288 crate::plugins::PathRule::new("src/runtime.ts"),
1289 "runtime-plugin".to_string(),
1290 ));
1291 plugin_result.entry_patterns.push((
1292 crate::plugins::PathRule::new("tests/app.test.ts"),
1293 "test-plugin".to_string(),
1294 ));
1295 plugin_result
1296 .always_used
1297 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1298 plugin_result
1299 .entry_point_roles
1300 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1301 plugin_result
1302 .entry_point_roles
1303 .insert("test-plugin".to_string(), EntryPointRole::Test);
1304 plugin_result
1305 .entry_point_roles
1306 .insert("support-plugin".to_string(), EntryPointRole::Support);
1307
1308 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1309
1310 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1311 assert!(
1312 entries.runtime[0].path.ends_with("src/runtime.ts"),
1313 "runtime entry should stay runtime-only"
1314 );
1315 assert_eq!(entries.test.len(), 1, "expected one test entry");
1316 assert!(
1317 entries.test[0].path.ends_with("tests/app.test.ts"),
1318 "test entry should stay test-only"
1319 );
1320 assert_eq!(
1321 entries.all.len(),
1322 3,
1323 "support entries should stay in all entries"
1324 );
1325 assert!(
1326 entries
1327 .all
1328 .iter()
1329 .any(|entry| entry.path.ends_with("src/setup.ts")),
1330 "support entries should remain in the overall entry-point set"
1331 );
1332 assert!(
1333 !entries
1334 .runtime
1335 .iter()
1336 .any(|entry| entry.path.ends_with("src/setup.ts")),
1337 "support entries should not bleed into runtime reachability"
1338 );
1339 assert!(
1340 !entries
1341 .test
1342 .iter()
1343 .any(|entry| entry.path.ends_with("src/setup.ts")),
1344 "support entries should not bleed into test reachability"
1345 );
1346 }
1347
1348 #[test]
1349 fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1350 let root = Path::new("/workspace/project");
1351 let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1352
1353 assert_eq!(
1354 resolve_plugin_setup_file(root, setup_file),
1355 setup_file.to_path_buf()
1356 );
1357 }
1358
1359 #[cfg(windows)]
1360 #[test]
1361 fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1362 let root = Path::new(r"C:\workspace\project");
1363 let setup_file = Path::new(r"/workspace/project/setup.ts");
1364
1365 assert_eq!(
1366 resolve_plugin_setup_file(root, setup_file),
1367 setup_file.to_path_buf()
1368 );
1369 }
1370
1371 #[test]
1372 fn plugin_entry_point_rules_respect_exclusions() {
1373 let dir = tempfile::tempdir().expect("create temp dir");
1374 let root = dir.path();
1375 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1376 std::fs::write(
1377 root.join("app/pages/index.tsx"),
1378 "export default function Page() { return null; }",
1379 )
1380 .unwrap();
1381 std::fs::write(
1382 root.join("app/pages/-helper.ts"),
1383 "export const helper = 1;",
1384 )
1385 .unwrap();
1386
1387 let config = FallowConfig {
1388 type_aware: fallow_config::TypeAwareConfig::default(),
1389 schema: None,
1390 extends: vec![],
1391 entry: vec![],
1392 ignore_patterns: vec![],
1393 ignore_findings: vec![],
1394 framework: vec![],
1395 workspaces: None,
1396 ignore_dependencies: vec![],
1397 ignore_unresolved_imports: vec![],
1398 ignore_exports: vec![],
1399 ignore_catalog_references: vec![],
1400 ignore_dependency_overrides: vec![],
1401 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1402 used_class_members: vec![],
1403 ignore_decorators: vec![],
1404 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1405 duplicates: fallow_config::DuplicatesConfig::default(),
1406 health: fallow_config::HealthConfig::default(),
1407 rules: RulesConfig::default(),
1408 boundaries: fallow_config::BoundaryConfig::default(),
1409 production: false.into(),
1410 plugins: vec![],
1411 rule_packs: vec![],
1412 dynamically_loaded: vec![],
1413 overrides: vec![],
1414 regression: None,
1415 audit: fallow_config::AuditConfig::default(),
1416 codeowners: None,
1417 public_packages: vec![],
1418 flags: fallow_config::FlagsConfig::default(),
1419 security: fallow_config::SecurityConfig::default(),
1420 fix: fallow_config::FixConfig::default(),
1421 resolve: fallow_config::ResolveConfig::default(),
1422 sealed: false,
1423 include_entry_exports: false,
1424 auto_imports: false,
1425 cache: fallow_config::CacheConfig::default(),
1426 }
1427 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1428
1429 let files = vec![
1430 DiscoveredFile {
1431 id: FileId(0),
1432 path: root.join("app/pages/index.tsx"),
1433 size_bytes: 1,
1434 },
1435 DiscoveredFile {
1436 id: FileId(1),
1437 path: root.join("app/pages/-helper.ts"),
1438 size_bytes: 1,
1439 },
1440 ];
1441
1442 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1443 plugin_result.entry_patterns.push((
1444 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1445 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1446 "tanstack-router".to_string(),
1447 ));
1448 plugin_result
1449 .entry_point_roles
1450 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1451
1452 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1453 let entry_paths: Vec<_> = entries
1454 .all
1455 .iter()
1456 .map(|entry| {
1457 entry
1458 .path
1459 .strip_prefix(root)
1460 .unwrap()
1461 .to_string_lossy()
1462 .into_owned()
1463 })
1464 .collect();
1465
1466 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1467 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1468 }
1469
1470 mod resolve_entry_path_tests {
1471 use super::*;
1472
1473 #[test]
1474 fn resolves_existing_file() {
1475 let dir = tempfile::tempdir().expect("create temp dir");
1476 let src = dir.path().join("src");
1477 std::fs::create_dir_all(&src).unwrap();
1478 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1479
1480 let canonical = dunce::canonicalize(dir.path()).unwrap();
1481 let result = resolve_entry_path(
1482 dir.path(),
1483 "src/index.ts",
1484 &canonical,
1485 EntryPointSource::PackageJsonMain,
1486 );
1487 assert!(result.is_some(), "should resolve an existing file");
1488 assert!(result.unwrap().path.ends_with("src/index.ts"));
1489 }
1490
1491 #[test]
1492 fn resolves_with_extension_fallback() {
1493 let dir = tempfile::tempdir().expect("create temp dir");
1494 let canonical = dunce::canonicalize(dir.path()).unwrap();
1495 let src = canonical.join("src");
1496 std::fs::create_dir_all(&src).unwrap();
1497 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1498
1499 let result = resolve_entry_path(
1500 &canonical,
1501 "src/index",
1502 &canonical,
1503 EntryPointSource::PackageJsonMain,
1504 );
1505 assert!(
1506 result.is_some(),
1507 "should resolve via extension fallback when exact path doesn't exist"
1508 );
1509 let ep = result.unwrap();
1510 assert!(
1511 ep.path.to_string_lossy().contains("index.ts"),
1512 "should find index.ts via extension fallback"
1513 );
1514 }
1515
1516 #[test]
1517 fn exact_file_wins_before_directory_index_fallback() {
1518 let dir = tempfile::tempdir().expect("create temp dir");
1519 let canonical = dunce::canonicalize(dir.path()).unwrap();
1520 let scripts = canonical.join("scripts");
1521 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1522 std::fs::write(
1523 scripts.join("process-messages.js"),
1524 "export const direct = true;",
1525 )
1526 .unwrap();
1527 std::fs::write(
1528 scripts.join("process-messages").join("index.js"),
1529 "export const index = true;",
1530 )
1531 .unwrap();
1532
1533 let result = resolve_entry_path(
1534 &canonical,
1535 "scripts/process-messages.js",
1536 &canonical,
1537 EntryPointSource::PackageJsonScript,
1538 )
1539 .expect("exact file should resolve");
1540
1541 assert!(result.path.ends_with("scripts/process-messages.js"));
1542 }
1543
1544 #[test]
1545 fn extension_fallback_wins_before_directory_index_fallback() {
1546 let dir = tempfile::tempdir().expect("create temp dir");
1547 let canonical = dunce::canonicalize(dir.path()).unwrap();
1548 let scripts = canonical.join("scripts");
1549 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1550 std::fs::write(
1551 scripts.join("process-messages.ts"),
1552 "export const withExt = true;",
1553 )
1554 .unwrap();
1555 std::fs::write(
1556 scripts.join("process-messages").join("index.js"),
1557 "export const index = true;",
1558 )
1559 .unwrap();
1560
1561 let result = resolve_entry_path(
1562 &canonical,
1563 "scripts/process-messages",
1564 &canonical,
1565 EntryPointSource::PackageJsonScript,
1566 )
1567 .expect("extension fallback should resolve");
1568
1569 assert!(result.path.ends_with("scripts/process-messages.ts"));
1570 }
1571
1572 #[test]
1573 fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1574 let dir = tempfile::tempdir().expect("create temp dir");
1575 let canonical = dunce::canonicalize(dir.path()).unwrap();
1576 let scripts = canonical.join("scripts/process-messages");
1577 std::fs::create_dir_all(&scripts).unwrap();
1578 std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1579
1580 let result = resolve_entry_path(
1581 &canonical,
1582 "scripts/process-messages",
1583 &canonical,
1584 EntryPointSource::PackageJsonScript,
1585 )
1586 .expect("directory index should resolve");
1587
1588 assert!(result.path.ends_with("scripts/process-messages/index.js"));
1589 }
1590
1591 #[test]
1592 fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1593 let dir = tempfile::tempdir().expect("create temp dir");
1594 let canonical = dunce::canonicalize(dir.path()).unwrap();
1595 std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1596 std::fs::write(
1597 canonical.join("scripts/process-messages/index.js"),
1598 "export const index = true;",
1599 )
1600 .unwrap();
1601
1602 for entry in [
1603 "scripts/*",
1604 "https://example.com/scripts/process-messages",
1605 "@scope/package/scripts/process-messages",
1606 ] {
1607 let result = resolve_entry_path(
1608 &canonical,
1609 entry,
1610 &canonical,
1611 EntryPointSource::PackageJsonScript,
1612 );
1613 assert!(result.is_none(), "{entry} should not resolve");
1614 }
1615 }
1616
1617 #[test]
1618 fn returns_none_for_nonexistent_file() {
1619 let dir = tempfile::tempdir().expect("create temp dir");
1620 let canonical = dunce::canonicalize(dir.path()).unwrap();
1621 let result = resolve_entry_path(
1622 dir.path(),
1623 "does/not/exist.ts",
1624 &canonical,
1625 EntryPointSource::PackageJsonMain,
1626 );
1627 assert!(result.is_none(), "should return None for nonexistent files");
1628 }
1629
1630 #[test]
1631 fn maps_dist_output_to_src() {
1632 let dir = tempfile::tempdir().expect("create temp dir");
1633 let src = dir.path().join("src");
1634 std::fs::create_dir_all(&src).unwrap();
1635 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1636
1637 let dist = dir.path().join("dist");
1638 std::fs::create_dir_all(&dist).unwrap();
1639 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1640
1641 let canonical = dunce::canonicalize(dir.path()).unwrap();
1642 let result = resolve_entry_path(
1643 dir.path(),
1644 "./dist/utils.js",
1645 &canonical,
1646 EntryPointSource::PackageJsonExports,
1647 );
1648 assert!(result.is_some(), "should resolve dist/ path to src/");
1649 let ep = result.unwrap();
1650 assert!(
1651 ep.path
1652 .to_string_lossy()
1653 .replace('\\', "/")
1654 .contains("src/utils.ts"),
1655 "should map ./dist/utils.js to src/utils.ts"
1656 );
1657 }
1658
1659 #[test]
1660 fn maps_build_output_to_src() {
1661 let dir = tempfile::tempdir().expect("create temp dir");
1662 let canonical = dunce::canonicalize(dir.path()).unwrap();
1663 let src = canonical.join("src");
1664 std::fs::create_dir_all(&src).unwrap();
1665 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1666
1667 let result = resolve_entry_path(
1668 &canonical,
1669 "./build/index.js",
1670 &canonical,
1671 EntryPointSource::PackageJsonExports,
1672 );
1673 assert!(result.is_some(), "should map build/ output to src/");
1674 let ep = result.unwrap();
1675 assert!(
1676 ep.path
1677 .to_string_lossy()
1678 .replace('\\', "/")
1679 .contains("src/index.tsx"),
1680 "should map ./build/index.js to src/index.tsx"
1681 );
1682 }
1683
1684 #[test]
1685 fn preserves_entry_point_source() {
1686 let dir = tempfile::tempdir().expect("create temp dir");
1687 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1688
1689 let canonical = dunce::canonicalize(dir.path()).unwrap();
1690 let result = resolve_entry_path(
1691 dir.path(),
1692 "index.ts",
1693 &canonical,
1694 EntryPointSource::PackageJsonScript,
1695 );
1696 assert!(result.is_some());
1697 assert!(
1698 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1699 "should preserve the source kind"
1700 );
1701 }
1702 #[test]
1703 fn tracks_skipped_entries_without_logging_each_repeat() {
1704 let dir = tempfile::tempdir().expect("create temp dir");
1705 let canonical = dunce::canonicalize(dir.path()).unwrap();
1706 let mut skipped_entries = FxHashMap::default();
1707
1708 let result = resolve_entry_path_with_tracking(
1709 dir.path(),
1710 "../scripts/build.js",
1711 &canonical,
1712 EntryPointSource::PackageJsonScript,
1713 Some(&mut skipped_entries),
1714 );
1715
1716 assert!(result.is_none(), "unsafe entry should be skipped");
1717 assert_eq!(
1718 skipped_entries.get("../scripts/build.js"),
1719 Some(&1),
1720 "warning tracker should count the skipped path"
1721 );
1722 }
1723
1724 #[test]
1725 fn formats_skipped_entry_warning_with_counts() {
1726 let mut skipped_entries = FxHashMap::default();
1727 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1728 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1729
1730 let warning =
1731 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1732
1733 assert_eq!(
1734 warning,
1735 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1736 );
1737 }
1738
1739 #[test]
1740 fn skipped_entry_summary_dedupes_identical_messages() {
1741 let message = format!(
1742 "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
1743 std::process::id()
1744 );
1745 assert!(
1746 should_warn_skipped_entry(&message),
1747 "first occurrence of a message emits"
1748 );
1749 assert!(
1750 !should_warn_skipped_entry(&message),
1751 "identical repeat is suppressed"
1752 );
1753 }
1754
1755 #[test]
1756 fn rejects_parent_dir_escape_for_exact_file() {
1757 let sandbox = tempfile::tempdir().expect("create sandbox");
1758 let root = sandbox.path().join("project");
1759 std::fs::create_dir_all(&root).unwrap();
1760 std::fs::write(
1761 sandbox.path().join("escape.ts"),
1762 "export const escape = true;",
1763 )
1764 .unwrap();
1765
1766 let canonical = dunce::canonicalize(&root).unwrap();
1767 let result = resolve_entry_path(
1768 &root,
1769 "../escape.ts",
1770 &canonical,
1771 EntryPointSource::PackageJsonMain,
1772 );
1773
1774 assert!(
1775 result.is_none(),
1776 "should reject exact paths that escape the root"
1777 );
1778 }
1779
1780 #[test]
1781 fn rejects_parent_dir_escape_via_extension_fallback() {
1782 let sandbox = tempfile::tempdir().expect("create sandbox");
1783 let root = sandbox.path().join("project");
1784 std::fs::create_dir_all(&root).unwrap();
1785 std::fs::write(
1786 sandbox.path().join("escape.ts"),
1787 "export const escape = true;",
1788 )
1789 .unwrap();
1790
1791 let canonical = dunce::canonicalize(&root).unwrap();
1792 let result = resolve_entry_path(
1793 &root,
1794 "../escape",
1795 &canonical,
1796 EntryPointSource::PackageJsonMain,
1797 );
1798
1799 assert!(
1800 result.is_none(),
1801 "should reject extension fallback paths that escape the root"
1802 );
1803 }
1804 }
1805
1806 mod output_to_source_tests {
1807 use super::*;
1808
1809 #[test]
1810 fn maps_dist_to_src_with_ts_extension() {
1811 let dir = tempfile::tempdir().expect("create temp dir");
1812 let src = dir.path().join("src");
1813 std::fs::create_dir_all(&src).unwrap();
1814 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1815
1816 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1817 assert!(result.is_some());
1818 assert!(
1819 result
1820 .unwrap()
1821 .to_string_lossy()
1822 .replace('\\', "/")
1823 .contains("src/utils.ts")
1824 );
1825 }
1826
1827 #[test]
1828 fn returns_none_when_no_source_file_exists() {
1829 let dir = tempfile::tempdir().expect("create temp dir");
1830 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1831 assert!(result.is_none());
1832 }
1833
1834 #[test]
1835 fn ignores_non_output_directories() {
1836 let dir = tempfile::tempdir().expect("create temp dir");
1837 let src = dir.path().join("src");
1838 std::fs::create_dir_all(&src).unwrap();
1839 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1840
1841 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1842 assert!(result.is_none());
1843 }
1844
1845 #[test]
1846 fn maps_nested_output_path_preserving_prefix() {
1847 let dir = tempfile::tempdir().expect("create temp dir");
1848 let modules_src = dir.path().join("modules").join("src");
1849 std::fs::create_dir_all(&modules_src).unwrap();
1850 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1851
1852 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1853 assert!(result.is_some());
1854 assert!(
1855 result
1856 .unwrap()
1857 .to_string_lossy()
1858 .replace('\\', "/")
1859 .contains("modules/src/helper.ts")
1860 );
1861 }
1862 }
1863
1864 mod source_index_fallback_tests {
1865 use super::*;
1866
1867 #[test]
1868 fn detects_dist_entry_in_output_dir() {
1869 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1870 assert!(is_entry_in_output_dir("dist/index.js"));
1871 assert!(is_entry_in_output_dir("./build/index.js"));
1872 assert!(is_entry_in_output_dir("./out/main.js"));
1873 assert!(is_entry_in_output_dir("./esm/index.js"));
1874 assert!(is_entry_in_output_dir("./cjs/index.js"));
1875 }
1876
1877 #[test]
1878 fn rejects_non_output_entry_paths() {
1879 assert!(!is_entry_in_output_dir("./src/index.ts"));
1880 assert!(!is_entry_in_output_dir("src/main.ts"));
1881 assert!(!is_entry_in_output_dir("./index.js"));
1882 assert!(!is_entry_in_output_dir(""));
1883 }
1884
1885 #[test]
1886 fn root_index_entries_are_recognized_for_source_fallback() {
1887 assert!(is_package_root_index_entry("./index.js"));
1888 assert!(is_package_root_index_entry("index.cjs"));
1889 assert!(is_package_root_index_entry("./index.d.ts"));
1890 assert!(!is_package_root_index_entry("./src/index.js"));
1891 assert!(!is_package_root_index_entry("./main.js"));
1892 assert!(!is_package_root_index_entry(""));
1893 }
1894
1895 #[test]
1896 fn rejects_substring_match_for_output_dir() {
1897 assert!(!is_entry_in_output_dir("./distro/index.js"));
1898 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1899 }
1900
1901 #[test]
1902 fn finds_src_index_ts() {
1903 let dir = tempfile::tempdir().expect("create temp dir");
1904 let src = dir.path().join("src");
1905 std::fs::create_dir_all(&src).unwrap();
1906 let index_path = src.join("index.ts");
1907 std::fs::write(&index_path, "export const a = 1;").unwrap();
1908
1909 let result = try_source_index_fallback(dir.path());
1910 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1911 }
1912
1913 #[test]
1914 fn finds_src_index_tsx_when_ts_missing() {
1915 let dir = tempfile::tempdir().expect("create temp dir");
1916 let src = dir.path().join("src");
1917 std::fs::create_dir_all(&src).unwrap();
1918 let index_path = src.join("index.tsx");
1919 std::fs::write(&index_path, "export default 1;").unwrap();
1920
1921 let result = try_source_index_fallback(dir.path());
1922 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1923 }
1924
1925 #[test]
1926 fn prefers_src_index_over_root_index() {
1927 let dir = tempfile::tempdir().expect("create temp dir");
1928 let src = dir.path().join("src");
1929 std::fs::create_dir_all(&src).unwrap();
1930 let src_index = src.join("index.ts");
1931 std::fs::write(&src_index, "export const a = 1;").unwrap();
1932 let root_index = dir.path().join("index.ts");
1933 std::fs::write(&root_index, "export const b = 2;").unwrap();
1934
1935 let result = try_source_index_fallback(dir.path());
1936 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1937 }
1938
1939 #[test]
1940 fn falls_back_to_src_main() {
1941 let dir = tempfile::tempdir().expect("create temp dir");
1942 let src = dir.path().join("src");
1943 std::fs::create_dir_all(&src).unwrap();
1944 let main_path = src.join("main.ts");
1945 std::fs::write(&main_path, "export const a = 1;").unwrap();
1946
1947 let result = try_source_index_fallback(dir.path());
1948 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1949 }
1950
1951 #[test]
1952 fn falls_back_to_root_index_when_no_src() {
1953 let dir = tempfile::tempdir().expect("create temp dir");
1954 let index_path = dir.path().join("index.js");
1955 std::fs::write(&index_path, "module.exports = {};").unwrap();
1956
1957 let result = try_source_index_fallback(dir.path());
1958 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1959 }
1960
1961 #[test]
1962 fn returns_none_when_nothing_matches() {
1963 let dir = tempfile::tempdir().expect("create temp dir");
1964 let result = try_source_index_fallback(dir.path());
1965 assert!(result.is_none());
1966 }
1967
1968 #[test]
1969 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1970 let dir = tempfile::tempdir().expect("create temp dir");
1971 let canonical = dunce::canonicalize(dir.path()).unwrap();
1972
1973 let dist_dir = canonical.join("dist").join("esm2022");
1974 std::fs::create_dir_all(&dist_dir).unwrap();
1975 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1976
1977 let src = canonical.join("src");
1978 std::fs::create_dir_all(&src).unwrap();
1979 let src_index = src.join("index.ts");
1980 std::fs::write(&src_index, "export const x = 1;").unwrap();
1981
1982 let result = resolve_entry_path(
1983 &canonical,
1984 "./dist/esm2022/index.js",
1985 &canonical,
1986 EntryPointSource::PackageJsonMain,
1987 );
1988 assert!(result.is_some());
1989 let entry = result.unwrap();
1990 assert_eq!(entry.path, src_index);
1991 }
1992
1993 #[test]
1994 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1995 let dir = tempfile::tempdir().expect("create temp dir");
1996 let canonical = dunce::canonicalize(dir.path()).unwrap();
1997
1998 let src_mirror = canonical.join("src").join("esm2022");
1999 std::fs::create_dir_all(&src_mirror).unwrap();
2000 let mirror_index = src_mirror.join("index.ts");
2001 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2002
2003 let src_index = canonical.join("src").join("index.ts");
2004 std::fs::write(&src_index, "export const y = 2;").unwrap();
2005
2006 let result = resolve_entry_path(
2007 &canonical,
2008 "./dist/esm2022/index.js",
2009 &canonical,
2010 EntryPointSource::PackageJsonMain,
2011 );
2012 assert_eq!(result.map(|e| e.path), Some(mirror_index));
2013 }
2014
2015 #[test]
2016 fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2017 let dir = tempfile::tempdir().expect("create temp dir");
2018 let canonical = dunce::canonicalize(dir.path()).unwrap();
2019
2020 let src = canonical.join("src");
2021 std::fs::create_dir_all(&src).unwrap();
2022 let src_index = src.join("index.ts");
2023 std::fs::write(&src_index, "export const x = 1;").unwrap();
2024
2025 let result = resolve_entry_path(
2026 &canonical,
2027 "./index.js",
2028 &canonical,
2029 EntryPointSource::PackageJsonMain,
2030 );
2031 assert_eq!(result.map(|entry| entry.path), Some(src_index));
2032 }
2033 }
2034
2035 mod default_fallback_tests {
2036 use super::*;
2037
2038 #[test]
2039 fn finds_src_index_ts_as_fallback() {
2040 let dir = tempfile::tempdir().expect("create temp dir");
2041 let src = dir.path().join("src");
2042 std::fs::create_dir_all(&src).unwrap();
2043 let index_path = src.join("index.ts");
2044 std::fs::write(&index_path, "export const a = 1;").unwrap();
2045
2046 let files = vec![DiscoveredFile {
2047 id: FileId(0),
2048 path: index_path.clone(),
2049 size_bytes: 20,
2050 }];
2051
2052 let entries = apply_default_fallback(&files, dir.path(), None);
2053 assert_eq!(entries.len(), 1);
2054 assert_eq!(entries[0].path, index_path);
2055 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2056 }
2057
2058 #[test]
2059 fn finds_root_index_js_as_fallback() {
2060 let dir = tempfile::tempdir().expect("create temp dir");
2061 let index_path = dir.path().join("index.js");
2062 std::fs::write(&index_path, "module.exports = {};").unwrap();
2063
2064 let files = vec![DiscoveredFile {
2065 id: FileId(0),
2066 path: index_path.clone(),
2067 size_bytes: 21,
2068 }];
2069
2070 let entries = apply_default_fallback(&files, dir.path(), None);
2071 assert_eq!(entries.len(), 1);
2072 assert_eq!(entries[0].path, index_path);
2073 }
2074
2075 #[test]
2076 fn returns_empty_when_no_index_file() {
2077 let dir = tempfile::tempdir().expect("create temp dir");
2078 let other_path = dir.path().join("src").join("utils.ts");
2079
2080 let files = vec![DiscoveredFile {
2081 id: FileId(0),
2082 path: other_path,
2083 size_bytes: 10,
2084 }];
2085
2086 let entries = apply_default_fallback(&files, dir.path(), None);
2087 assert!(
2088 entries.is_empty(),
2089 "non-index files should not match default fallback"
2090 );
2091 }
2092
2093 #[test]
2094 fn workspace_filter_restricts_scope() {
2095 let dir = tempfile::tempdir().expect("create temp dir");
2096 let ws_a = dir.path().join("packages").join("a").join("src");
2097 std::fs::create_dir_all(&ws_a).unwrap();
2098 let ws_b = dir.path().join("packages").join("b").join("src");
2099 std::fs::create_dir_all(&ws_b).unwrap();
2100
2101 let index_a = ws_a.join("index.ts");
2102 let index_b = ws_b.join("index.ts");
2103
2104 let files = vec![
2105 DiscoveredFile {
2106 id: FileId(0),
2107 path: index_a.clone(),
2108 size_bytes: 10,
2109 },
2110 DiscoveredFile {
2111 id: FileId(1),
2112 path: index_b,
2113 size_bytes: 10,
2114 },
2115 ];
2116
2117 let ws_root = dir.path().join("packages").join("a");
2118 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2119 assert_eq!(entries.len(), 1);
2120 assert_eq!(entries[0].path, index_a);
2121 }
2122 }
2123
2124 mod wildcard_entry_tests {
2125 use super::*;
2126
2127 #[test]
2128 fn expands_wildcard_css_entries() {
2129 let dir = tempfile::tempdir().expect("create temp dir");
2130 let themes = dir.path().join("src").join("themes");
2131 std::fs::create_dir_all(&themes).unwrap();
2132 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2133 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2134
2135 let canonical = dunce::canonicalize(dir.path()).unwrap();
2136 let mut entries = Vec::new();
2137 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2138
2139 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2140 let paths: Vec<String> = entries
2141 .iter()
2142 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2143 .collect();
2144 assert!(paths.contains(&"dark.css".to_string()));
2145 assert!(paths.contains(&"light.css".to_string()));
2146 assert!(
2147 entries
2148 .iter()
2149 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2150 );
2151 }
2152
2153 #[test]
2154 fn wildcard_does_not_match_nonexistent_files() {
2155 let dir = tempfile::tempdir().expect("create temp dir");
2156 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2157
2158 let canonical = dunce::canonicalize(dir.path()).unwrap();
2159 let mut entries = Vec::new();
2160 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2161
2162 assert!(
2163 entries.is_empty(),
2164 "should return empty when no files match the wildcard"
2165 );
2166 }
2167
2168 #[test]
2169 fn wildcard_only_matches_specified_extension() {
2170 let dir = tempfile::tempdir().expect("create temp dir");
2171 let themes = dir.path().join("src").join("themes");
2172 std::fs::create_dir_all(&themes).unwrap();
2173 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2174 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2175
2176 let canonical = dunce::canonicalize(dir.path()).unwrap();
2177 let mut entries = Vec::new();
2178 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2179
2180 assert_eq!(entries.len(), 1, "should only match CSS files");
2181 assert!(
2182 entries[0]
2183 .path
2184 .file_name()
2185 .unwrap()
2186 .to_string_lossy()
2187 .ends_with(".css")
2188 );
2189 }
2190 }
2191
2192 fn config_for(root: &Path) -> ResolvedConfig {
2193 FallowConfig::default().resolve(
2194 root.to_path_buf(),
2195 OutputFormat::Human,
2196 1,
2197 true,
2198 true,
2199 None,
2200 )
2201 }
2202
2203 fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
2204 DiscoveredFile {
2205 id: FileId(id),
2206 path: root.join(rel),
2207 size_bytes: 1,
2208 }
2209 }
2210
2211 #[test]
2212 fn root_entry_points_use_package_json_scripts() {
2213 let dir = tempfile::tempdir().expect("tempdir");
2214 let root = dir.path();
2215 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2216 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2217 .expect("script file");
2218 std::fs::write(
2219 root.join("package.json"),
2220 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2221 )
2222 .expect("package file");
2223 let config = config_for(root);
2224 let files = [discovered(root, "scripts/build.ts", 0)];
2225
2226 let entries = discover_entry_points(&config, &files);
2227
2228 assert_eq!(entries.len(), 1);
2229 assert!(entries[0].path.ends_with("scripts/build.ts"));
2230 assert!(matches!(
2231 entries[0].source,
2232 EntryPointSource::PackageJsonScript
2233 ));
2234 }
2235
2236 #[test]
2237 fn root_entry_points_fall_back_to_default_index() {
2238 let dir = tempfile::tempdir().expect("tempdir");
2239 let root = dir.path();
2240 std::fs::create_dir_all(root.join("src")).expect("src dir");
2241 std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
2242 let config = config_for(root);
2243 let files = [discovered(root, "src/index.ts", 0)];
2244
2245 let entries = discover_entry_points(&config, &files);
2246
2247 assert_eq!(entries.len(), 1);
2248 assert!(entries[0].path.ends_with("src/index.ts"));
2249 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2250 }
2251
2252 #[test]
2253 fn workspace_entry_points_use_workspace_package_json() {
2254 let dir = tempfile::tempdir().expect("tempdir");
2255 let root = dir.path();
2256 let ws_root = root.join("packages/app");
2257 std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
2258 std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
2259 .expect("workspace main");
2260 std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
2261 .expect("workspace package file");
2262 let config = config_for(root);
2263 let files = [discovered(root, "packages/app/src/main.ts", 0)];
2264
2265 let entries = discover_workspace_entry_points(&ws_root, &config, &files);
2266
2267 assert_eq!(entries.len(), 1);
2268 assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
2269 assert!(matches!(
2270 entries[0].source,
2271 EntryPointSource::PackageJsonMain
2272 ));
2273 }
2274}