1use std::path::{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};
7
8const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
12
13#[derive(Debug, Clone, Default)]
15pub struct CategorizedEntryPoints {
16 pub all: Vec<EntryPoint>,
17 pub runtime: Vec<EntryPoint>,
18 pub test: Vec<EntryPoint>,
19}
20
21impl CategorizedEntryPoints {
22 pub fn push_runtime(&mut self, entry: EntryPoint) {
23 self.runtime.push(entry.clone());
24 self.all.push(entry);
25 }
26
27 pub fn push_test(&mut self, entry: EntryPoint) {
28 self.test.push(entry.clone());
29 self.all.push(entry);
30 }
31
32 pub fn push_support(&mut self, entry: EntryPoint) {
33 self.all.push(entry);
34 }
35
36 pub fn extend_runtime<I>(&mut self, entries: I)
37 where
38 I: IntoIterator<Item = EntryPoint>,
39 {
40 for entry in entries {
41 self.push_runtime(entry);
42 }
43 }
44
45 pub fn extend_test<I>(&mut self, entries: I)
46 where
47 I: IntoIterator<Item = EntryPoint>,
48 {
49 for entry in entries {
50 self.push_test(entry);
51 }
52 }
53
54 pub fn extend_support<I>(&mut self, entries: I)
55 where
56 I: IntoIterator<Item = EntryPoint>,
57 {
58 for entry in entries {
59 self.push_support(entry);
60 }
61 }
62
63 pub fn extend(&mut self, other: Self) {
64 self.all.extend(other.all);
65 self.runtime.extend(other.runtime);
66 self.test.extend(other.test);
67 }
68
69 #[must_use]
70 pub fn dedup(mut self) -> Self {
71 dedup_entry_paths(&mut self.all);
72 dedup_entry_paths(&mut self.runtime);
73 dedup_entry_paths(&mut self.test);
74 self
75 }
76}
77
78fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
79 entries.sort_by(|a, b| a.path.cmp(&b.path));
80 entries.dedup_by(|a, b| a.path == b.path);
81}
82
83pub fn resolve_entry_path(
90 base: &Path,
91 entry: &str,
92 canonical_root: &Path,
93 source: EntryPointSource,
94) -> Option<EntryPoint> {
95 if entry.contains('*') {
98 return None;
99 }
100
101 let resolved = base.join(entry);
102 let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
104 if !canonical_resolved.starts_with(canonical_root) {
105 tracing::warn!(path = %entry, "Skipping entry point outside project root");
106 return None;
107 }
108
109 if let Some(source_path) = try_output_to_source_path(base, entry) {
114 if let Ok(canonical_source) = dunce::canonicalize(&source_path)
116 && canonical_source.starts_with(canonical_root)
117 {
118 return Some(EntryPoint {
119 path: source_path,
120 source,
121 });
122 }
123 }
124
125 if resolved.exists() {
126 return Some(EntryPoint {
127 path: resolved,
128 source,
129 });
130 }
131 for ext in SOURCE_EXTENSIONS {
133 let with_ext = resolved.with_extension(ext);
134 if with_ext.exists() {
135 return Some(EntryPoint {
136 path: with_ext,
137 source,
138 });
139 }
140 }
141 None
142}
143
144fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
156 let entry_path = Path::new(entry);
157 let components: Vec<_> = entry_path.components().collect();
158
159 let output_pos = components.iter().rposition(|c| {
161 if let std::path::Component::Normal(s) = c
162 && let Some(name) = s.to_str()
163 {
164 return OUTPUT_DIRS.contains(&name);
165 }
166 false
167 })?;
168
169 let prefix: PathBuf = components[..output_pos]
171 .iter()
172 .filter(|c| !matches!(c, std::path::Component::CurDir))
173 .collect();
174
175 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
177
178 for ext in SOURCE_EXTENSIONS {
180 let source_candidate = base
181 .join(&prefix)
182 .join("src")
183 .join(suffix.with_extension(ext));
184 if source_candidate.exists() {
185 return Some(source_candidate);
186 }
187 }
188
189 None
190}
191
192const DEFAULT_INDEX_PATTERNS: &[&str] = &[
194 "src/index.{ts,tsx,js,jsx}",
195 "src/main.{ts,tsx,js,jsx}",
196 "index.{ts,tsx,js,jsx}",
197 "main.{ts,tsx,js,jsx}",
198];
199
200fn apply_default_fallback(
205 files: &[DiscoveredFile],
206 root: &Path,
207 ws_filter: Option<&Path>,
208) -> Vec<EntryPoint> {
209 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
210 .iter()
211 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
212 .collect();
213
214 let mut entries = Vec::new();
215 for file in files {
216 if let Some(ws_root) = ws_filter
218 && file.path.strip_prefix(ws_root).is_err()
219 {
220 continue;
221 }
222 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
223 let relative_str = relative.to_string_lossy();
224 if default_matchers
225 .iter()
226 .any(|m| m.is_match(relative_str.as_ref()))
227 {
228 entries.push(EntryPoint {
229 path: file.path.clone(),
230 source: EntryPointSource::DefaultIndex,
231 });
232 }
233 }
234 entries
235}
236
237pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
239 let _span = tracing::info_span!("discover_entry_points").entered();
240 let mut entries = Vec::new();
241
242 let relative_paths: Vec<String> = files
244 .iter()
245 .map(|f| {
246 f.path
247 .strip_prefix(&config.root)
248 .unwrap_or(&f.path)
249 .to_string_lossy()
250 .into_owned()
251 })
252 .collect();
253
254 {
257 let mut builder = globset::GlobSetBuilder::new();
258 for pattern in &config.entry_patterns {
259 if let Ok(glob) = globset::Glob::new(pattern) {
260 builder.add(glob);
261 }
262 }
263 if let Ok(glob_set) = builder.build()
264 && !glob_set.is_empty()
265 {
266 for (idx, rel) in relative_paths.iter().enumerate() {
267 if glob_set.is_match(rel) {
268 entries.push(EntryPoint {
269 path: files[idx].path.clone(),
270 source: EntryPointSource::ManualEntry,
271 });
272 }
273 }
274 }
275 }
276
277 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
280 let pkg_path = config.root.join("package.json");
281 let root_pkg = PackageJson::load(&pkg_path).ok();
282 if let Some(pkg) = &root_pkg {
283 for entry_path in pkg.entry_points() {
284 if let Some(ep) = resolve_entry_path(
285 &config.root,
286 &entry_path,
287 &canonical_root,
288 EntryPointSource::PackageJsonMain,
289 ) {
290 entries.push(ep);
291 }
292 }
293
294 if let Some(scripts) = &pkg.scripts {
296 for script_value in scripts.values() {
297 for file_ref in extract_script_file_refs(script_value) {
298 if let Some(ep) = resolve_entry_path(
299 &config.root,
300 &file_ref,
301 &canonical_root,
302 EntryPointSource::PackageJsonScript,
303 ) {
304 entries.push(ep);
305 }
306 }
307 }
308 }
309
310 }
312
313 let exports_dirs = root_pkg
317 .map(|pkg| pkg.exports_subdirectories())
318 .unwrap_or_default();
319 discover_nested_package_entries(
320 &config.root,
321 files,
322 &mut entries,
323 &canonical_root,
324 &exports_dirs,
325 );
326
327 if entries.is_empty() {
329 entries = apply_default_fallback(files, &config.root, None);
330 }
331
332 entries.sort_by(|a, b| a.path.cmp(&b.path));
334 entries.dedup_by(|a, b| a.path == b.path);
335
336 entries
337}
338
339fn discover_nested_package_entries(
349 root: &Path,
350 _files: &[DiscoveredFile],
351 entries: &mut Vec<EntryPoint>,
352 canonical_root: &Path,
353 exports_subdirectories: &[String],
354) {
355 let mut visited = rustc_hash::FxHashSet::default();
356
357 let search_dirs = [
359 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
360 ];
361 for dir_name in &search_dirs {
362 let search_dir = root.join(dir_name);
363 if !search_dir.is_dir() {
364 continue;
365 }
366 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
367 continue;
368 };
369 for entry in read_dir.flatten() {
370 let pkg_dir = entry.path();
371 if visited.insert(pkg_dir.clone()) {
372 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
373 }
374 }
375 }
376
377 for dir_name in exports_subdirectories {
379 let pkg_dir = root.join(dir_name);
380 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
381 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
382 }
383 }
384}
385
386fn collect_nested_package_entries(
388 pkg_dir: &Path,
389 entries: &mut Vec<EntryPoint>,
390 canonical_root: &Path,
391) {
392 let pkg_path = pkg_dir.join("package.json");
393 if !pkg_path.exists() {
394 return;
395 }
396 let Ok(pkg) = PackageJson::load(&pkg_path) else {
397 return;
398 };
399 for entry_path in pkg.entry_points() {
400 if entry_path.contains('*') {
401 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
402 } else if let Some(ep) = resolve_entry_path(
403 pkg_dir,
404 &entry_path,
405 canonical_root,
406 EntryPointSource::PackageJsonExports,
407 ) {
408 entries.push(ep);
409 }
410 }
411 if let Some(scripts) = &pkg.scripts {
412 for script_value in scripts.values() {
413 for file_ref in extract_script_file_refs(script_value) {
414 if let Some(ep) = resolve_entry_path(
415 pkg_dir,
416 &file_ref,
417 canonical_root,
418 EntryPointSource::PackageJsonScript,
419 ) {
420 entries.push(ep);
421 }
422 }
423 }
424 }
425}
426
427fn expand_wildcard_entries(
433 base: &Path,
434 pattern: &str,
435 canonical_root: &Path,
436 entries: &mut Vec<EntryPoint>,
437) {
438 let full_pattern = base.join(pattern).to_string_lossy().to_string();
439 let Ok(matches) = glob::glob(&full_pattern) else {
440 return;
441 };
442 for path_result in matches {
443 let Ok(path) = path_result else {
444 continue;
445 };
446 if let Ok(canonical) = dunce::canonicalize(&path)
447 && canonical.starts_with(canonical_root)
448 {
449 entries.push(EntryPoint {
450 path,
451 source: EntryPointSource::PackageJsonExports,
452 });
453 }
454 }
455}
456
457#[must_use]
459pub fn discover_workspace_entry_points(
460 ws_root: &Path,
461 _config: &ResolvedConfig,
462 all_files: &[DiscoveredFile],
463) -> Vec<EntryPoint> {
464 let mut entries = Vec::new();
465
466 let pkg_path = ws_root.join("package.json");
467 if let Ok(pkg) = PackageJson::load(&pkg_path) {
468 let canonical_ws_root =
469 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
470 for entry_path in pkg.entry_points() {
471 if entry_path.contains('*') {
472 expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
473 } else if let Some(ep) = resolve_entry_path(
474 ws_root,
475 &entry_path,
476 &canonical_ws_root,
477 EntryPointSource::PackageJsonMain,
478 ) {
479 entries.push(ep);
480 }
481 }
482
483 if let Some(scripts) = &pkg.scripts {
485 for script_value in scripts.values() {
486 for file_ref in extract_script_file_refs(script_value) {
487 if let Some(ep) = resolve_entry_path(
488 ws_root,
489 &file_ref,
490 &canonical_ws_root,
491 EntryPointSource::PackageJsonScript,
492 ) {
493 entries.push(ep);
494 }
495 }
496 }
497 }
498
499 }
501
502 if entries.is_empty() {
504 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
505 }
506
507 entries.sort_by(|a, b| a.path.cmp(&b.path));
508 entries.dedup_by(|a, b| a.path == b.path);
509 entries
510}
511
512#[must_use]
517pub fn discover_plugin_entry_points(
518 plugin_result: &crate::plugins::AggregatedPluginResult,
519 config: &ResolvedConfig,
520 files: &[DiscoveredFile],
521) -> Vec<EntryPoint> {
522 discover_plugin_entry_point_sets(plugin_result, config, files).all
523}
524
525#[must_use]
527pub fn discover_plugin_entry_point_sets(
528 plugin_result: &crate::plugins::AggregatedPluginResult,
529 config: &ResolvedConfig,
530 files: &[DiscoveredFile],
531) -> CategorizedEntryPoints {
532 let mut entries = CategorizedEntryPoints::default();
533
534 let relative_paths: Vec<String> = files
536 .iter()
537 .map(|f| {
538 f.path
539 .strip_prefix(&config.root)
540 .unwrap_or(&f.path)
541 .to_string_lossy()
542 .into_owned()
543 })
544 .collect();
545
546 let mut builder = globset::GlobSetBuilder::new();
549 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
550 for (rule, pname) in &plugin_result.entry_patterns {
551 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
552 builder.add(include);
553 glob_meta.push(compiled);
554 }
555 }
556 for (pattern, pname) in plugin_result
557 .discovered_always_used
558 .iter()
559 .chain(plugin_result.always_used.iter())
560 .chain(plugin_result.fixture_patterns.iter())
561 {
562 if let Ok(glob) = globset::GlobBuilder::new(pattern)
563 .literal_separator(true)
564 .build()
565 {
566 builder.add(glob);
567 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
568 &crate::plugins::PathRule::new(pattern.clone()),
569 "support entry pattern",
570 ) {
571 glob_meta.push(CompiledEntryRule {
572 path,
573 plugin_name: pname,
574 role: EntryPointRole::Support,
575 });
576 }
577 }
578 }
579 if let Ok(glob_set) = builder.build()
580 && !glob_set.is_empty()
581 {
582 for (idx, rel) in relative_paths.iter().enumerate() {
583 let matches: Vec<usize> = glob_set
584 .matches(rel)
585 .into_iter()
586 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
587 .collect();
588 if !matches.is_empty() {
589 let name = glob_meta[matches[0]].plugin_name;
590 let entry = EntryPoint {
591 path: files[idx].path.clone(),
592 source: EntryPointSource::Plugin {
593 name: name.to_string(),
594 },
595 };
596
597 let mut has_runtime = false;
598 let mut has_test = false;
599 let mut has_support = false;
600 for match_idx in matches {
601 match glob_meta[match_idx].role {
602 EntryPointRole::Runtime => has_runtime = true,
603 EntryPointRole::Test => has_test = true,
604 EntryPointRole::Support => has_support = true,
605 }
606 }
607
608 if has_runtime {
609 entries.push_runtime(entry.clone());
610 }
611 if has_test {
612 entries.push_test(entry.clone());
613 }
614 if has_support || (!has_runtime && !has_test) {
615 entries.push_support(entry);
616 }
617 }
618 }
619 }
620
621 for (setup_file, pname) in &plugin_result.setup_files {
623 let resolved = if setup_file.is_absolute() {
624 setup_file.clone()
625 } else {
626 config.root.join(setup_file)
627 };
628 if resolved.exists() {
629 entries.push_support(EntryPoint {
630 path: resolved,
631 source: EntryPointSource::Plugin {
632 name: pname.clone(),
633 },
634 });
635 } else {
636 for ext in SOURCE_EXTENSIONS {
638 let with_ext = resolved.with_extension(ext);
639 if with_ext.exists() {
640 entries.push_support(EntryPoint {
641 path: with_ext,
642 source: EntryPointSource::Plugin {
643 name: pname.clone(),
644 },
645 });
646 break;
647 }
648 }
649 }
650 }
651
652 entries.dedup()
653}
654
655#[must_use]
660pub fn discover_dynamically_loaded_entry_points(
661 config: &ResolvedConfig,
662 files: &[DiscoveredFile],
663) -> Vec<EntryPoint> {
664 if config.dynamically_loaded.is_empty() {
665 return Vec::new();
666 }
667
668 let mut builder = globset::GlobSetBuilder::new();
669 for pattern in &config.dynamically_loaded {
670 if let Ok(glob) = globset::Glob::new(pattern) {
671 builder.add(glob);
672 }
673 }
674 let Ok(glob_set) = builder.build() else {
675 return Vec::new();
676 };
677 if glob_set.is_empty() {
678 return Vec::new();
679 }
680
681 let mut entries = Vec::new();
682 for file in files {
683 let rel = file
684 .path
685 .strip_prefix(&config.root)
686 .unwrap_or(&file.path)
687 .to_string_lossy();
688 if glob_set.is_match(rel.as_ref()) {
689 entries.push(EntryPoint {
690 path: file.path.clone(),
691 source: EntryPointSource::DynamicallyLoaded,
692 });
693 }
694 }
695 entries
696}
697
698struct CompiledEntryRule<'a> {
699 path: crate::plugins::CompiledPathRule,
700 plugin_name: &'a str,
701 role: EntryPointRole,
702}
703
704impl CompiledEntryRule<'_> {
705 fn matches(&self, path: &str) -> bool {
706 self.path.matches(path)
707 }
708}
709
710fn compile_entry_rule<'a>(
711 rule: &'a crate::plugins::PathRule,
712 plugin_name: &'a str,
713 plugin_result: &'a crate::plugins::AggregatedPluginResult,
714) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
715 let include = match globset::GlobBuilder::new(&rule.pattern)
716 .literal_separator(true)
717 .build()
718 {
719 Ok(glob) => glob,
720 Err(err) => {
721 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
722 return None;
723 }
724 };
725 let role = plugin_result
726 .entry_point_roles
727 .get(plugin_name)
728 .copied()
729 .unwrap_or(EntryPointRole::Support);
730 Some((
731 include,
732 CompiledEntryRule {
733 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
734 plugin_name,
735 role,
736 },
737 ))
738}
739
740#[must_use]
742pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
743 if patterns.is_empty() {
744 return None;
745 }
746 let mut builder = globset::GlobSetBuilder::new();
747 for pattern in patterns {
748 if let Ok(glob) = globset::GlobBuilder::new(pattern)
749 .literal_separator(true)
750 .build()
751 {
752 builder.add(glob);
753 }
754 }
755 builder.build().ok()
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
762 use fallow_types::discover::FileId;
763 use proptest::prelude::*;
764
765 proptest! {
766 #[test]
768 fn glob_patterns_never_panic_on_compile(
769 prefix in "[a-zA-Z0-9_]{1,20}",
770 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
771 ) {
772 let pattern = format!("**/{prefix}*.{ext}");
773 let result = globset::Glob::new(&pattern);
775 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
776 }
777
778 #[test]
780 fn non_source_extensions_not_in_list(
781 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
782 ) {
783 prop_assert!(
784 !SOURCE_EXTENSIONS.contains(&ext),
785 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
786 );
787 }
788
789 #[test]
791 fn compile_glob_set_no_panic(
792 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
793 ) {
794 let _ = compile_glob_set(&patterns);
796 }
797 }
798
799 #[test]
801 fn compile_glob_set_empty_input() {
802 assert!(
803 compile_glob_set(&[]).is_none(),
804 "empty patterns should return None"
805 );
806 }
807
808 #[test]
809 fn compile_glob_set_valid_patterns() {
810 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
811 let set = compile_glob_set(&patterns);
812 assert!(set.is_some(), "valid patterns should compile");
813 let set = set.unwrap();
814 assert!(set.is_match("src/foo.ts"));
815 assert!(set.is_match("src/bar.js"));
816 assert!(!set.is_match("src/bar.py"));
817 }
818
819 #[test]
820 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
821 let patterns = vec!["composables/*.{ts,js}".to_string()];
822 let set = compile_glob_set(&patterns).expect("pattern should compile");
823
824 assert!(set.is_match("composables/useFoo.ts"));
825 assert!(!set.is_match("composables/nested/useFoo.ts"));
826 }
827
828 #[test]
829 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
830 let dir = tempfile::tempdir().expect("create temp dir");
831 let root = dir.path();
832 std::fs::create_dir_all(root.join("src")).unwrap();
833 std::fs::create_dir_all(root.join("tests")).unwrap();
834 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
835 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
836 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
837
838 let config = FallowConfig {
839 schema: None,
840 extends: vec![],
841 entry: vec![],
842 ignore_patterns: vec![],
843 framework: vec![],
844 workspaces: None,
845 ignore_dependencies: vec![],
846 ignore_exports: vec![],
847 duplicates: fallow_config::DuplicatesConfig::default(),
848 health: fallow_config::HealthConfig::default(),
849 rules: RulesConfig::default(),
850 boundaries: fallow_config::BoundaryConfig::default(),
851 production: false,
852 plugins: vec![],
853 dynamically_loaded: vec![],
854 overrides: vec![],
855 regression: None,
856 codeowners: None,
857 public_packages: vec![],
858 }
859 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
860
861 let files = vec![
862 DiscoveredFile {
863 id: FileId(0),
864 path: root.join("src/runtime.ts"),
865 size_bytes: 1,
866 },
867 DiscoveredFile {
868 id: FileId(1),
869 path: root.join("src/setup.ts"),
870 size_bytes: 1,
871 },
872 DiscoveredFile {
873 id: FileId(2),
874 path: root.join("tests/app.test.ts"),
875 size_bytes: 1,
876 },
877 ];
878
879 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
880 plugin_result.entry_patterns.push((
881 crate::plugins::PathRule::new("src/runtime.ts"),
882 "runtime-plugin".to_string(),
883 ));
884 plugin_result.entry_patterns.push((
885 crate::plugins::PathRule::new("tests/app.test.ts"),
886 "test-plugin".to_string(),
887 ));
888 plugin_result
889 .always_used
890 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
891 plugin_result
892 .entry_point_roles
893 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
894 plugin_result
895 .entry_point_roles
896 .insert("test-plugin".to_string(), EntryPointRole::Test);
897 plugin_result
898 .entry_point_roles
899 .insert("support-plugin".to_string(), EntryPointRole::Support);
900
901 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
902
903 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
904 assert!(
905 entries.runtime[0].path.ends_with("src/runtime.ts"),
906 "runtime entry should stay runtime-only"
907 );
908 assert_eq!(entries.test.len(), 1, "expected one test entry");
909 assert!(
910 entries.test[0].path.ends_with("tests/app.test.ts"),
911 "test entry should stay test-only"
912 );
913 assert_eq!(
914 entries.all.len(),
915 3,
916 "support entries should stay in all entries"
917 );
918 assert!(
919 entries
920 .all
921 .iter()
922 .any(|entry| entry.path.ends_with("src/setup.ts")),
923 "support entries should remain in the overall entry-point set"
924 );
925 assert!(
926 !entries
927 .runtime
928 .iter()
929 .any(|entry| entry.path.ends_with("src/setup.ts")),
930 "support entries should not bleed into runtime reachability"
931 );
932 assert!(
933 !entries
934 .test
935 .iter()
936 .any(|entry| entry.path.ends_with("src/setup.ts")),
937 "support entries should not bleed into test reachability"
938 );
939 }
940
941 #[test]
942 fn plugin_entry_point_rules_respect_exclusions() {
943 let dir = tempfile::tempdir().expect("create temp dir");
944 let root = dir.path();
945 std::fs::create_dir_all(root.join("app/pages")).unwrap();
946 std::fs::write(
947 root.join("app/pages/index.tsx"),
948 "export default function Page() { return null; }",
949 )
950 .unwrap();
951 std::fs::write(
952 root.join("app/pages/-helper.ts"),
953 "export const helper = 1;",
954 )
955 .unwrap();
956
957 let config = FallowConfig {
958 schema: None,
959 extends: vec![],
960 entry: vec![],
961 ignore_patterns: vec![],
962 framework: vec![],
963 workspaces: None,
964 ignore_dependencies: vec![],
965 ignore_exports: vec![],
966 duplicates: fallow_config::DuplicatesConfig::default(),
967 health: fallow_config::HealthConfig::default(),
968 rules: RulesConfig::default(),
969 boundaries: fallow_config::BoundaryConfig::default(),
970 production: false,
971 plugins: vec![],
972 dynamically_loaded: vec![],
973 overrides: vec![],
974 regression: None,
975 codeowners: None,
976 public_packages: vec![],
977 }
978 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
979
980 let files = vec![
981 DiscoveredFile {
982 id: FileId(0),
983 path: root.join("app/pages/index.tsx"),
984 size_bytes: 1,
985 },
986 DiscoveredFile {
987 id: FileId(1),
988 path: root.join("app/pages/-helper.ts"),
989 size_bytes: 1,
990 },
991 ];
992
993 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
994 plugin_result.entry_patterns.push((
995 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
996 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
997 "tanstack-router".to_string(),
998 ));
999 plugin_result
1000 .entry_point_roles
1001 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1002
1003 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1004 let entry_paths: Vec<_> = entries
1005 .all
1006 .iter()
1007 .map(|entry| {
1008 entry
1009 .path
1010 .strip_prefix(root)
1011 .unwrap()
1012 .to_string_lossy()
1013 .into_owned()
1014 })
1015 .collect();
1016
1017 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1018 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1019 }
1020
1021 mod resolve_entry_path_tests {
1023 use super::*;
1024
1025 #[test]
1026 fn resolves_existing_file() {
1027 let dir = tempfile::tempdir().expect("create temp dir");
1028 let src = dir.path().join("src");
1029 std::fs::create_dir_all(&src).unwrap();
1030 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1031
1032 let canonical = dunce::canonicalize(dir.path()).unwrap();
1033 let result = resolve_entry_path(
1034 dir.path(),
1035 "src/index.ts",
1036 &canonical,
1037 EntryPointSource::PackageJsonMain,
1038 );
1039 assert!(result.is_some(), "should resolve an existing file");
1040 assert!(result.unwrap().path.ends_with("src/index.ts"));
1041 }
1042
1043 #[test]
1044 fn resolves_with_extension_fallback() {
1045 let dir = tempfile::tempdir().expect("create temp dir");
1046 let canonical = dunce::canonicalize(dir.path()).unwrap();
1048 let src = canonical.join("src");
1049 std::fs::create_dir_all(&src).unwrap();
1050 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1051
1052 let result = resolve_entry_path(
1054 &canonical,
1055 "src/index",
1056 &canonical,
1057 EntryPointSource::PackageJsonMain,
1058 );
1059 assert!(
1060 result.is_some(),
1061 "should resolve via extension fallback when exact path doesn't exist"
1062 );
1063 let ep = result.unwrap();
1064 assert!(
1065 ep.path.to_string_lossy().contains("index.ts"),
1066 "should find index.ts via extension fallback"
1067 );
1068 }
1069
1070 #[test]
1071 fn returns_none_for_nonexistent_file() {
1072 let dir = tempfile::tempdir().expect("create temp dir");
1073 let canonical = dunce::canonicalize(dir.path()).unwrap();
1074 let result = resolve_entry_path(
1075 dir.path(),
1076 "does/not/exist.ts",
1077 &canonical,
1078 EntryPointSource::PackageJsonMain,
1079 );
1080 assert!(result.is_none(), "should return None for nonexistent files");
1081 }
1082
1083 #[test]
1084 fn maps_dist_output_to_src() {
1085 let dir = tempfile::tempdir().expect("create temp dir");
1086 let src = dir.path().join("src");
1087 std::fs::create_dir_all(&src).unwrap();
1088 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1089
1090 let dist = dir.path().join("dist");
1092 std::fs::create_dir_all(&dist).unwrap();
1093 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1094
1095 let canonical = dunce::canonicalize(dir.path()).unwrap();
1096 let result = resolve_entry_path(
1097 dir.path(),
1098 "./dist/utils.js",
1099 &canonical,
1100 EntryPointSource::PackageJsonExports,
1101 );
1102 assert!(result.is_some(), "should resolve dist/ path to src/");
1103 let ep = result.unwrap();
1104 assert!(
1105 ep.path
1106 .to_string_lossy()
1107 .replace('\\', "/")
1108 .contains("src/utils.ts"),
1109 "should map ./dist/utils.js to src/utils.ts"
1110 );
1111 }
1112
1113 #[test]
1114 fn maps_build_output_to_src() {
1115 let dir = tempfile::tempdir().expect("create temp dir");
1116 let canonical = dunce::canonicalize(dir.path()).unwrap();
1118 let src = canonical.join("src");
1119 std::fs::create_dir_all(&src).unwrap();
1120 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1121
1122 let result = resolve_entry_path(
1123 &canonical,
1124 "./build/index.js",
1125 &canonical,
1126 EntryPointSource::PackageJsonExports,
1127 );
1128 assert!(result.is_some(), "should map build/ output to src/");
1129 let ep = result.unwrap();
1130 assert!(
1131 ep.path
1132 .to_string_lossy()
1133 .replace('\\', "/")
1134 .contains("src/index.tsx"),
1135 "should map ./build/index.js to src/index.tsx"
1136 );
1137 }
1138
1139 #[test]
1140 fn preserves_entry_point_source() {
1141 let dir = tempfile::tempdir().expect("create temp dir");
1142 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1143
1144 let canonical = dunce::canonicalize(dir.path()).unwrap();
1145 let result = resolve_entry_path(
1146 dir.path(),
1147 "index.ts",
1148 &canonical,
1149 EntryPointSource::PackageJsonScript,
1150 );
1151 assert!(result.is_some());
1152 assert!(
1153 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1154 "should preserve the source kind"
1155 );
1156 }
1157 }
1158
1159 mod output_to_source_tests {
1161 use super::*;
1162
1163 #[test]
1164 fn maps_dist_to_src_with_ts_extension() {
1165 let dir = tempfile::tempdir().expect("create temp dir");
1166 let src = dir.path().join("src");
1167 std::fs::create_dir_all(&src).unwrap();
1168 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1169
1170 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1171 assert!(result.is_some());
1172 assert!(
1173 result
1174 .unwrap()
1175 .to_string_lossy()
1176 .replace('\\', "/")
1177 .contains("src/utils.ts")
1178 );
1179 }
1180
1181 #[test]
1182 fn returns_none_when_no_source_file_exists() {
1183 let dir = tempfile::tempdir().expect("create temp dir");
1184 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1186 assert!(result.is_none());
1187 }
1188
1189 #[test]
1190 fn ignores_non_output_directories() {
1191 let dir = tempfile::tempdir().expect("create temp dir");
1192 let src = dir.path().join("src");
1193 std::fs::create_dir_all(&src).unwrap();
1194 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1195
1196 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1198 assert!(result.is_none());
1199 }
1200
1201 #[test]
1202 fn maps_nested_output_path_preserving_prefix() {
1203 let dir = tempfile::tempdir().expect("create temp dir");
1204 let modules_src = dir.path().join("modules").join("src");
1205 std::fs::create_dir_all(&modules_src).unwrap();
1206 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1207
1208 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1209 assert!(result.is_some());
1210 assert!(
1211 result
1212 .unwrap()
1213 .to_string_lossy()
1214 .replace('\\', "/")
1215 .contains("modules/src/helper.ts")
1216 );
1217 }
1218 }
1219
1220 mod default_fallback_tests {
1222 use super::*;
1223
1224 #[test]
1225 fn finds_src_index_ts_as_fallback() {
1226 let dir = tempfile::tempdir().expect("create temp dir");
1227 let src = dir.path().join("src");
1228 std::fs::create_dir_all(&src).unwrap();
1229 let index_path = src.join("index.ts");
1230 std::fs::write(&index_path, "export const a = 1;").unwrap();
1231
1232 let files = vec![DiscoveredFile {
1233 id: FileId(0),
1234 path: index_path.clone(),
1235 size_bytes: 20,
1236 }];
1237
1238 let entries = apply_default_fallback(&files, dir.path(), None);
1239 assert_eq!(entries.len(), 1);
1240 assert_eq!(entries[0].path, index_path);
1241 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1242 }
1243
1244 #[test]
1245 fn finds_root_index_js_as_fallback() {
1246 let dir = tempfile::tempdir().expect("create temp dir");
1247 let index_path = dir.path().join("index.js");
1248 std::fs::write(&index_path, "module.exports = {};").unwrap();
1249
1250 let files = vec![DiscoveredFile {
1251 id: FileId(0),
1252 path: index_path.clone(),
1253 size_bytes: 21,
1254 }];
1255
1256 let entries = apply_default_fallback(&files, dir.path(), None);
1257 assert_eq!(entries.len(), 1);
1258 assert_eq!(entries[0].path, index_path);
1259 }
1260
1261 #[test]
1262 fn returns_empty_when_no_index_file() {
1263 let dir = tempfile::tempdir().expect("create temp dir");
1264 let other_path = dir.path().join("src").join("utils.ts");
1265
1266 let files = vec![DiscoveredFile {
1267 id: FileId(0),
1268 path: other_path,
1269 size_bytes: 10,
1270 }];
1271
1272 let entries = apply_default_fallback(&files, dir.path(), None);
1273 assert!(
1274 entries.is_empty(),
1275 "non-index files should not match default fallback"
1276 );
1277 }
1278
1279 #[test]
1280 fn workspace_filter_restricts_scope() {
1281 let dir = tempfile::tempdir().expect("create temp dir");
1282 let ws_a = dir.path().join("packages").join("a").join("src");
1283 std::fs::create_dir_all(&ws_a).unwrap();
1284 let ws_b = dir.path().join("packages").join("b").join("src");
1285 std::fs::create_dir_all(&ws_b).unwrap();
1286
1287 let index_a = ws_a.join("index.ts");
1288 let index_b = ws_b.join("index.ts");
1289
1290 let files = vec![
1291 DiscoveredFile {
1292 id: FileId(0),
1293 path: index_a.clone(),
1294 size_bytes: 10,
1295 },
1296 DiscoveredFile {
1297 id: FileId(1),
1298 path: index_b,
1299 size_bytes: 10,
1300 },
1301 ];
1302
1303 let ws_root = dir.path().join("packages").join("a");
1305 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1306 assert_eq!(entries.len(), 1);
1307 assert_eq!(entries[0].path, index_a);
1308 }
1309 }
1310
1311 mod wildcard_entry_tests {
1313 use super::*;
1314
1315 #[test]
1316 fn expands_wildcard_css_entries() {
1317 let dir = tempfile::tempdir().expect("create temp dir");
1320 let themes = dir.path().join("src").join("themes");
1321 std::fs::create_dir_all(&themes).unwrap();
1322 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1323 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1324
1325 let canonical = dunce::canonicalize(dir.path()).unwrap();
1326 let mut entries = Vec::new();
1327 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1328
1329 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1330 let paths: Vec<String> = entries
1331 .iter()
1332 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1333 .collect();
1334 assert!(paths.contains(&"dark.css".to_string()));
1335 assert!(paths.contains(&"light.css".to_string()));
1336 assert!(
1337 entries
1338 .iter()
1339 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1340 );
1341 }
1342
1343 #[test]
1344 fn wildcard_does_not_match_nonexistent_files() {
1345 let dir = tempfile::tempdir().expect("create temp dir");
1346 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1348
1349 let canonical = dunce::canonicalize(dir.path()).unwrap();
1350 let mut entries = Vec::new();
1351 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1352
1353 assert!(
1354 entries.is_empty(),
1355 "should return empty when no files match the wildcard"
1356 );
1357 }
1358
1359 #[test]
1360 fn wildcard_only_matches_specified_extension() {
1361 let dir = tempfile::tempdir().expect("create temp dir");
1363 let themes = dir.path().join("src").join("themes");
1364 std::fs::create_dir_all(&themes).unwrap();
1365 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1366 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1367
1368 let canonical = dunce::canonicalize(dir.path()).unwrap();
1369 let mut entries = Vec::new();
1370 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1371
1372 assert_eq!(entries.len(), 1, "should only match CSS files");
1373 assert!(
1374 entries[0]
1375 .path
1376 .file_name()
1377 .unwrap()
1378 .to_string_lossy()
1379 .ends_with(".css")
1380 );
1381 }
1382 }
1383}