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