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