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 flags: fallow_config::FlagsConfig::default(),
859 }
860 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
861
862 let files = vec![
863 DiscoveredFile {
864 id: FileId(0),
865 path: root.join("src/runtime.ts"),
866 size_bytes: 1,
867 },
868 DiscoveredFile {
869 id: FileId(1),
870 path: root.join("src/setup.ts"),
871 size_bytes: 1,
872 },
873 DiscoveredFile {
874 id: FileId(2),
875 path: root.join("tests/app.test.ts"),
876 size_bytes: 1,
877 },
878 ];
879
880 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
881 plugin_result.entry_patterns.push((
882 crate::plugins::PathRule::new("src/runtime.ts"),
883 "runtime-plugin".to_string(),
884 ));
885 plugin_result.entry_patterns.push((
886 crate::plugins::PathRule::new("tests/app.test.ts"),
887 "test-plugin".to_string(),
888 ));
889 plugin_result
890 .always_used
891 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
892 plugin_result
893 .entry_point_roles
894 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
895 plugin_result
896 .entry_point_roles
897 .insert("test-plugin".to_string(), EntryPointRole::Test);
898 plugin_result
899 .entry_point_roles
900 .insert("support-plugin".to_string(), EntryPointRole::Support);
901
902 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
903
904 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
905 assert!(
906 entries.runtime[0].path.ends_with("src/runtime.ts"),
907 "runtime entry should stay runtime-only"
908 );
909 assert_eq!(entries.test.len(), 1, "expected one test entry");
910 assert!(
911 entries.test[0].path.ends_with("tests/app.test.ts"),
912 "test entry should stay test-only"
913 );
914 assert_eq!(
915 entries.all.len(),
916 3,
917 "support entries should stay in all entries"
918 );
919 assert!(
920 entries
921 .all
922 .iter()
923 .any(|entry| entry.path.ends_with("src/setup.ts")),
924 "support entries should remain in the overall entry-point set"
925 );
926 assert!(
927 !entries
928 .runtime
929 .iter()
930 .any(|entry| entry.path.ends_with("src/setup.ts")),
931 "support entries should not bleed into runtime reachability"
932 );
933 assert!(
934 !entries
935 .test
936 .iter()
937 .any(|entry| entry.path.ends_with("src/setup.ts")),
938 "support entries should not bleed into test reachability"
939 );
940 }
941
942 #[test]
943 fn plugin_entry_point_rules_respect_exclusions() {
944 let dir = tempfile::tempdir().expect("create temp dir");
945 let root = dir.path();
946 std::fs::create_dir_all(root.join("app/pages")).unwrap();
947 std::fs::write(
948 root.join("app/pages/index.tsx"),
949 "export default function Page() { return null; }",
950 )
951 .unwrap();
952 std::fs::write(
953 root.join("app/pages/-helper.ts"),
954 "export const helper = 1;",
955 )
956 .unwrap();
957
958 let config = FallowConfig {
959 schema: None,
960 extends: vec![],
961 entry: vec![],
962 ignore_patterns: vec![],
963 framework: vec![],
964 workspaces: None,
965 ignore_dependencies: vec![],
966 ignore_exports: vec![],
967 duplicates: fallow_config::DuplicatesConfig::default(),
968 health: fallow_config::HealthConfig::default(),
969 rules: RulesConfig::default(),
970 boundaries: fallow_config::BoundaryConfig::default(),
971 production: false,
972 plugins: vec![],
973 dynamically_loaded: vec![],
974 overrides: vec![],
975 regression: None,
976 codeowners: None,
977 public_packages: vec![],
978 flags: fallow_config::FlagsConfig::default(),
979 }
980 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
981
982 let files = vec![
983 DiscoveredFile {
984 id: FileId(0),
985 path: root.join("app/pages/index.tsx"),
986 size_bytes: 1,
987 },
988 DiscoveredFile {
989 id: FileId(1),
990 path: root.join("app/pages/-helper.ts"),
991 size_bytes: 1,
992 },
993 ];
994
995 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
996 plugin_result.entry_patterns.push((
997 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
998 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
999 "tanstack-router".to_string(),
1000 ));
1001 plugin_result
1002 .entry_point_roles
1003 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1004
1005 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1006 let entry_paths: Vec<_> = entries
1007 .all
1008 .iter()
1009 .map(|entry| {
1010 entry
1011 .path
1012 .strip_prefix(root)
1013 .unwrap()
1014 .to_string_lossy()
1015 .into_owned()
1016 })
1017 .collect();
1018
1019 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1020 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1021 }
1022
1023 mod resolve_entry_path_tests {
1025 use super::*;
1026
1027 #[test]
1028 fn resolves_existing_file() {
1029 let dir = tempfile::tempdir().expect("create temp dir");
1030 let src = dir.path().join("src");
1031 std::fs::create_dir_all(&src).unwrap();
1032 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1033
1034 let canonical = dunce::canonicalize(dir.path()).unwrap();
1035 let result = resolve_entry_path(
1036 dir.path(),
1037 "src/index.ts",
1038 &canonical,
1039 EntryPointSource::PackageJsonMain,
1040 );
1041 assert!(result.is_some(), "should resolve an existing file");
1042 assert!(result.unwrap().path.ends_with("src/index.ts"));
1043 }
1044
1045 #[test]
1046 fn resolves_with_extension_fallback() {
1047 let dir = tempfile::tempdir().expect("create temp dir");
1048 let canonical = dunce::canonicalize(dir.path()).unwrap();
1050 let src = canonical.join("src");
1051 std::fs::create_dir_all(&src).unwrap();
1052 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1053
1054 let result = resolve_entry_path(
1056 &canonical,
1057 "src/index",
1058 &canonical,
1059 EntryPointSource::PackageJsonMain,
1060 );
1061 assert!(
1062 result.is_some(),
1063 "should resolve via extension fallback when exact path doesn't exist"
1064 );
1065 let ep = result.unwrap();
1066 assert!(
1067 ep.path.to_string_lossy().contains("index.ts"),
1068 "should find index.ts via extension fallback"
1069 );
1070 }
1071
1072 #[test]
1073 fn returns_none_for_nonexistent_file() {
1074 let dir = tempfile::tempdir().expect("create temp dir");
1075 let canonical = dunce::canonicalize(dir.path()).unwrap();
1076 let result = resolve_entry_path(
1077 dir.path(),
1078 "does/not/exist.ts",
1079 &canonical,
1080 EntryPointSource::PackageJsonMain,
1081 );
1082 assert!(result.is_none(), "should return None for nonexistent files");
1083 }
1084
1085 #[test]
1086 fn maps_dist_output_to_src() {
1087 let dir = tempfile::tempdir().expect("create temp dir");
1088 let src = dir.path().join("src");
1089 std::fs::create_dir_all(&src).unwrap();
1090 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1091
1092 let dist = dir.path().join("dist");
1094 std::fs::create_dir_all(&dist).unwrap();
1095 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1096
1097 let canonical = dunce::canonicalize(dir.path()).unwrap();
1098 let result = resolve_entry_path(
1099 dir.path(),
1100 "./dist/utils.js",
1101 &canonical,
1102 EntryPointSource::PackageJsonExports,
1103 );
1104 assert!(result.is_some(), "should resolve dist/ path to src/");
1105 let ep = result.unwrap();
1106 assert!(
1107 ep.path
1108 .to_string_lossy()
1109 .replace('\\', "/")
1110 .contains("src/utils.ts"),
1111 "should map ./dist/utils.js to src/utils.ts"
1112 );
1113 }
1114
1115 #[test]
1116 fn maps_build_output_to_src() {
1117 let dir = tempfile::tempdir().expect("create temp dir");
1118 let canonical = dunce::canonicalize(dir.path()).unwrap();
1120 let src = canonical.join("src");
1121 std::fs::create_dir_all(&src).unwrap();
1122 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1123
1124 let result = resolve_entry_path(
1125 &canonical,
1126 "./build/index.js",
1127 &canonical,
1128 EntryPointSource::PackageJsonExports,
1129 );
1130 assert!(result.is_some(), "should map build/ output to src/");
1131 let ep = result.unwrap();
1132 assert!(
1133 ep.path
1134 .to_string_lossy()
1135 .replace('\\', "/")
1136 .contains("src/index.tsx"),
1137 "should map ./build/index.js to src/index.tsx"
1138 );
1139 }
1140
1141 #[test]
1142 fn preserves_entry_point_source() {
1143 let dir = tempfile::tempdir().expect("create temp dir");
1144 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1145
1146 let canonical = dunce::canonicalize(dir.path()).unwrap();
1147 let result = resolve_entry_path(
1148 dir.path(),
1149 "index.ts",
1150 &canonical,
1151 EntryPointSource::PackageJsonScript,
1152 );
1153 assert!(result.is_some());
1154 assert!(
1155 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1156 "should preserve the source kind"
1157 );
1158 }
1159 }
1160
1161 mod output_to_source_tests {
1163 use super::*;
1164
1165 #[test]
1166 fn maps_dist_to_src_with_ts_extension() {
1167 let dir = tempfile::tempdir().expect("create temp dir");
1168 let src = dir.path().join("src");
1169 std::fs::create_dir_all(&src).unwrap();
1170 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1171
1172 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1173 assert!(result.is_some());
1174 assert!(
1175 result
1176 .unwrap()
1177 .to_string_lossy()
1178 .replace('\\', "/")
1179 .contains("src/utils.ts")
1180 );
1181 }
1182
1183 #[test]
1184 fn returns_none_when_no_source_file_exists() {
1185 let dir = tempfile::tempdir().expect("create temp dir");
1186 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1188 assert!(result.is_none());
1189 }
1190
1191 #[test]
1192 fn ignores_non_output_directories() {
1193 let dir = tempfile::tempdir().expect("create temp dir");
1194 let src = dir.path().join("src");
1195 std::fs::create_dir_all(&src).unwrap();
1196 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1197
1198 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1200 assert!(result.is_none());
1201 }
1202
1203 #[test]
1204 fn maps_nested_output_path_preserving_prefix() {
1205 let dir = tempfile::tempdir().expect("create temp dir");
1206 let modules_src = dir.path().join("modules").join("src");
1207 std::fs::create_dir_all(&modules_src).unwrap();
1208 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1209
1210 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1211 assert!(result.is_some());
1212 assert!(
1213 result
1214 .unwrap()
1215 .to_string_lossy()
1216 .replace('\\', "/")
1217 .contains("modules/src/helper.ts")
1218 );
1219 }
1220 }
1221
1222 mod default_fallback_tests {
1224 use super::*;
1225
1226 #[test]
1227 fn finds_src_index_ts_as_fallback() {
1228 let dir = tempfile::tempdir().expect("create temp dir");
1229 let src = dir.path().join("src");
1230 std::fs::create_dir_all(&src).unwrap();
1231 let index_path = src.join("index.ts");
1232 std::fs::write(&index_path, "export const a = 1;").unwrap();
1233
1234 let files = vec![DiscoveredFile {
1235 id: FileId(0),
1236 path: index_path.clone(),
1237 size_bytes: 20,
1238 }];
1239
1240 let entries = apply_default_fallback(&files, dir.path(), None);
1241 assert_eq!(entries.len(), 1);
1242 assert_eq!(entries[0].path, index_path);
1243 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1244 }
1245
1246 #[test]
1247 fn finds_root_index_js_as_fallback() {
1248 let dir = tempfile::tempdir().expect("create temp dir");
1249 let index_path = dir.path().join("index.js");
1250 std::fs::write(&index_path, "module.exports = {};").unwrap();
1251
1252 let files = vec![DiscoveredFile {
1253 id: FileId(0),
1254 path: index_path.clone(),
1255 size_bytes: 21,
1256 }];
1257
1258 let entries = apply_default_fallback(&files, dir.path(), None);
1259 assert_eq!(entries.len(), 1);
1260 assert_eq!(entries[0].path, index_path);
1261 }
1262
1263 #[test]
1264 fn returns_empty_when_no_index_file() {
1265 let dir = tempfile::tempdir().expect("create temp dir");
1266 let other_path = dir.path().join("src").join("utils.ts");
1267
1268 let files = vec![DiscoveredFile {
1269 id: FileId(0),
1270 path: other_path,
1271 size_bytes: 10,
1272 }];
1273
1274 let entries = apply_default_fallback(&files, dir.path(), None);
1275 assert!(
1276 entries.is_empty(),
1277 "non-index files should not match default fallback"
1278 );
1279 }
1280
1281 #[test]
1282 fn workspace_filter_restricts_scope() {
1283 let dir = tempfile::tempdir().expect("create temp dir");
1284 let ws_a = dir.path().join("packages").join("a").join("src");
1285 std::fs::create_dir_all(&ws_a).unwrap();
1286 let ws_b = dir.path().join("packages").join("b").join("src");
1287 std::fs::create_dir_all(&ws_b).unwrap();
1288
1289 let index_a = ws_a.join("index.ts");
1290 let index_b = ws_b.join("index.ts");
1291
1292 let files = vec![
1293 DiscoveredFile {
1294 id: FileId(0),
1295 path: index_a.clone(),
1296 size_bytes: 10,
1297 },
1298 DiscoveredFile {
1299 id: FileId(1),
1300 path: index_b,
1301 size_bytes: 10,
1302 },
1303 ];
1304
1305 let ws_root = dir.path().join("packages").join("a");
1307 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1308 assert_eq!(entries.len(), 1);
1309 assert_eq!(entries[0].path, index_a);
1310 }
1311 }
1312
1313 mod wildcard_entry_tests {
1315 use super::*;
1316
1317 #[test]
1318 fn expands_wildcard_css_entries() {
1319 let dir = tempfile::tempdir().expect("create temp dir");
1322 let themes = dir.path().join("src").join("themes");
1323 std::fs::create_dir_all(&themes).unwrap();
1324 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1325 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1326
1327 let canonical = dunce::canonicalize(dir.path()).unwrap();
1328 let mut entries = Vec::new();
1329 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1330
1331 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1332 let paths: Vec<String> = entries
1333 .iter()
1334 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1335 .collect();
1336 assert!(paths.contains(&"dark.css".to_string()));
1337 assert!(paths.contains(&"light.css".to_string()));
1338 assert!(
1339 entries
1340 .iter()
1341 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1342 );
1343 }
1344
1345 #[test]
1346 fn wildcard_does_not_match_nonexistent_files() {
1347 let dir = tempfile::tempdir().expect("create temp dir");
1348 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1350
1351 let canonical = dunce::canonicalize(dir.path()).unwrap();
1352 let mut entries = Vec::new();
1353 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1354
1355 assert!(
1356 entries.is_empty(),
1357 "should return empty when no files match the wildcard"
1358 );
1359 }
1360
1361 #[test]
1362 fn wildcard_only_matches_specified_extension() {
1363 let dir = tempfile::tempdir().expect("create temp dir");
1365 let themes = dir.path().join("src").join("themes");
1366 std::fs::create_dir_all(&themes).unwrap();
1367 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1368 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1369
1370 let canonical = dunce::canonicalize(dir.path()).unwrap();
1371 let mut entries = Vec::new();
1372 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1373
1374 assert_eq!(entries.len(), 1, "should only match CSS files");
1375 assert!(
1376 entries[0]
1377 .path
1378 .file_name()
1379 .unwrap()
1380 .to_string_lossy()
1381 .ends_with(".css")
1382 );
1383 }
1384 }
1385}