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