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