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