1use std::path::{Component, Path, PathBuf};
2
3use super::walk::SOURCE_EXTENSIONS;
4use fallow_config::{
5 EntryPointRole, PackageJson, ResolvedConfig, TsconfigOutputMap, TsconfigOutputResolution,
6};
7use fallow_graph::resolve::OUTPUT_DIRS;
8use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
9use fallow_types::path_util::is_absolute_path_any_platform;
10use rustc_hash::{FxHashMap, FxHashSet};
11
12use crate::scripts::{IgnoredCommandEntries, ScriptCatalog, WorkspacePackages};
13
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_support<I>(&mut self, entries: I)
112 where
113 I: IntoIterator<Item = EntryPoint>,
114 {
115 for entry in entries {
116 self.push_support(entry);
117 }
118 }
119
120 pub(crate) fn extend(&mut self, other: Self) {
121 self.all.extend(other.all);
122 self.runtime.extend(other.runtime);
123 self.test.extend(other.test);
124 }
125
126 #[must_use]
127 pub(crate) fn dedup(mut self) -> Self {
128 dedup_entry_paths(&mut self.all);
129 dedup_entry_paths(&mut self.runtime);
130 dedup_entry_paths(&mut self.test);
131 self
132 }
133}
134
135fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
136 entries.sort_by(|a, b| a.path.cmp(&b.path));
137 entries.dedup_by(|a, b| a.path == b.path);
138}
139
140#[derive(Debug, Default)]
141pub struct EntryPointDiscovery {
142 pub entries: Vec<EntryPoint>,
144 pub support_entries: Vec<EntryPoint>,
146 pub skipped_entries: FxHashMap<String, usize>,
147}
148
149impl EntryPointDiscovery {
150 fn into_all_entries(mut self) -> Vec<EntryPoint> {
151 self.entries.append(&mut self.support_entries);
152 dedup_entry_paths(&mut self.entries);
153 self.entries
154 }
155}
156
157fn resolve_entry_path_with_tracking(
164 base: &Path,
165 entry: &str,
166 canonical_root: &Path,
167 source: EntryPointSource,
168 skipped_entries: Option<&mut FxHashMap<String, usize>>,
169) -> Option<EntryPoint> {
170 let output_map = TsconfigOutputMap::from_project(base);
171 resolve_entry_path_with_output_map(
172 base,
173 entry,
174 canonical_root,
175 source,
176 skipped_entries,
177 &output_map,
178 )
179}
180
181fn resolve_entry_path_with_output_map(
182 base: &Path,
183 entry: &str,
184 canonical_root: &Path,
185 source: EntryPointSource,
186 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
187 output_map: &TsconfigOutputMap,
188) -> Option<EntryPoint> {
189 if entry.contains('*') {
190 return None;
191 }
192
193 if entry_has_parent_dir(entry) {
194 record_or_warn_skipped_entry(
195 skipped_entries.as_deref_mut(),
196 entry,
197 "Skipping entry point containing parent directory traversal",
198 );
199 return None;
200 }
201
202 if let OutputDirEntry::ShortCircuit(result) = resolve_entry_via_output_dir(
203 base,
204 entry,
205 canonical_root,
206 source.clone(),
207 skipped_entries.as_deref_mut(),
208 output_map,
209 ) {
210 return result;
211 }
212
213 resolve_entry_via_filesystem_probe(base, entry, canonical_root, source, skipped_entries)
214}
215
216fn record_or_warn_skipped_entry(
218 skipped_entries: Option<&mut FxHashMap<String, usize>>,
219 entry: &str,
220 warning: &str,
221) {
222 if let Some(skipped_entries) = skipped_entries {
223 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
224 } else {
225 tracing::warn!(path = %entry, "{warning}");
226 }
227}
228
229enum OutputDirEntry {
235 ShortCircuit(Option<EntryPoint>),
236 Continue,
237}
238
239fn resolve_entry_via_output_dir(
241 base: &Path,
242 entry: &str,
243 canonical_root: &Path,
244 source: EntryPointSource,
245 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
246 output_map: &TsconfigOutputMap,
247) -> OutputDirEntry {
248 match output_map.resolve_source_for_entry(entry, SOURCE_EXTENSIONS) {
249 TsconfigOutputResolution::Resolved(source_path) => {
250 return OutputDirEntry::ShortCircuit(validated_entry_point(
251 &source_path,
252 canonical_root,
253 entry,
254 source,
255 skipped_entries.as_deref_mut(),
256 ));
257 }
258 TsconfigOutputResolution::ConfiguredButUnresolved => return OutputDirEntry::Continue,
259 TsconfigOutputResolution::Unconfigured => {}
260 }
261
262 if let Some(source_path) = try_legacy_output_to_source_path(base, entry) {
263 return OutputDirEntry::ShortCircuit(validated_entry_point(
264 &source_path,
265 canonical_root,
266 entry,
267 source,
268 skipped_entries.as_deref_mut(),
269 ));
270 }
271
272 if is_entry_in_output_dir(entry)
273 && let Some(source_path) = try_source_index_fallback(base)
274 {
275 tracing::info!(
276 entry = %entry,
277 fallback = %source_path.display(),
278 "package.json entry resolves to an ignored output directory; falling back to source index"
279 );
280 return OutputDirEntry::ShortCircuit(validated_entry_point(
281 &source_path,
282 canonical_root,
283 entry,
284 source,
285 skipped_entries,
286 ));
287 }
288
289 OutputDirEntry::Continue
290}
291
292fn resolve_entry_via_filesystem_probe(
295 base: &Path,
296 entry: &str,
297 canonical_root: &Path,
298 source: EntryPointSource,
299 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
300) -> Option<EntryPoint> {
301 let resolved = base.join(entry);
302
303 if resolved.is_file() {
304 return validated_entry_point(
305 &resolved,
306 canonical_root,
307 entry,
308 source,
309 skipped_entries.as_deref_mut(),
310 );
311 }
312
313 for ext in SOURCE_EXTENSIONS {
314 let with_ext = resolved.with_extension(ext);
315 if with_ext.is_file() {
316 return validated_entry_point(
317 &with_ext,
318 canonical_root,
319 entry,
320 source,
321 skipped_entries.as_deref_mut(),
322 );
323 }
324 }
325
326 if let Some(index_entry) = try_directory_index_entry(&resolved) {
327 return validated_entry_point(
328 &index_entry,
329 canonical_root,
330 entry,
331 source,
332 skipped_entries.as_deref_mut(),
333 );
334 }
335
336 if is_package_root_index_entry(entry)
337 && let Some(source_path) = try_source_index_fallback(base)
338 {
339 tracing::info!(
340 entry = %entry,
341 fallback = %source_path.display(),
342 "package.json root index entry is missing; falling back to source index"
343 );
344 return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
345 }
346 None
347}
348
349fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
350 for ext in SOURCE_EXTENSIONS {
351 let candidate = resolved.join(format!("index.{ext}"));
352 if candidate.is_file() {
353 return Some(candidate);
354 }
355 }
356 None
357}
358
359fn entry_has_parent_dir(entry: &str) -> bool {
360 Path::new(entry)
361 .components()
362 .any(|component| matches!(component, Component::ParentDir))
363}
364
365fn is_package_root_index_entry(entry: &str) -> bool {
366 let mut components = Path::new(entry)
367 .components()
368 .filter(|component| !matches!(component, Component::CurDir));
369
370 let Some(Component::Normal(file_name)) = components.next() else {
371 return false;
372 };
373 if components.next().is_some() {
374 return false;
375 }
376
377 file_name
378 .to_str()
379 .is_some_and(|name| name == "index" || name.starts_with("index."))
380}
381
382fn validated_entry_point(
383 candidate: &Path,
384 canonical_root: &Path,
385 entry: &str,
386 source: EntryPointSource,
387 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
388) -> Option<EntryPoint> {
389 let canonical_candidate = match dunce::canonicalize(candidate) {
390 Ok(path) => path,
391 Err(err) => {
392 tracing::warn!(
393 path = %candidate.display(),
394 %entry,
395 error = %err,
396 "Skipping entry point that could not be canonicalized"
397 );
398 return None;
399 }
400 };
401
402 if !canonical_candidate.starts_with(canonical_root) {
403 if let Some(skipped_entries) = skipped_entries.as_mut() {
404 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
405 } else {
406 tracing::warn!(
407 path = %candidate.display(),
408 %entry,
409 "Skipping entry point outside project root"
410 );
411 }
412 return None;
413 }
414
415 Some(EntryPoint {
416 path: candidate.to_path_buf(),
417 source,
418 })
419}
420
421pub fn resolve_entry_path(
422 base: &Path,
423 entry: &str,
424 canonical_root: &Path,
425 source: EntryPointSource,
426) -> Option<EntryPoint> {
427 resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
428}
429fn try_legacy_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
441 let entry_path = Path::new(entry);
442 let components: Vec<_> = entry_path.components().collect();
443
444 let output_pos = components.iter().rposition(|c| {
445 if let std::path::Component::Normal(s) = c
446 && let Some(name) = s.to_str()
447 {
448 return OUTPUT_DIRS.contains(&name);
449 }
450 false
451 })?;
452
453 let prefix: PathBuf = components[..output_pos]
454 .iter()
455 .filter(|c| !matches!(c, std::path::Component::CurDir))
456 .collect();
457
458 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
459
460 for ext in SOURCE_EXTENSIONS {
461 let source_candidate = base
462 .join(&prefix)
463 .join("src")
464 .join(suffix.with_extension(ext));
465 if source_candidate.exists() {
466 return Some(source_candidate);
467 }
468 }
469
470 None
471}
472
473const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
476
477fn is_entry_in_output_dir(entry: &str) -> bool {
482 Path::new(entry).components().any(|c| {
483 matches!(
484 c,
485 std::path::Component::Normal(s)
486 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
487 )
488 })
489}
490
491fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
498 for stem in SOURCE_INDEX_FALLBACK_STEMS {
499 for ext in SOURCE_EXTENSIONS {
500 let candidate = base.join(format!("{stem}.{ext}"));
501 if candidate.is_file() {
502 return Some(candidate);
503 }
504 }
505 }
506 None
507}
508
509const DEFAULT_INDEX_PATTERNS: &[&str] = &[
511 "src/index.{ts,tsx,js,jsx}",
512 "src/main.{ts,tsx,js,jsx}",
513 "index.{ts,tsx,js,jsx}",
514 "main.{ts,tsx,js,jsx}",
515];
516
517fn default_index_matchers() -> &'static globset::GlobSet {
519 static MATCHERS: std::sync::OnceLock<globset::GlobSet> = std::sync::OnceLock::new();
520 MATCHERS.get_or_init(|| {
521 let mut builder = globset::GlobSetBuilder::new();
522 for pattern in DEFAULT_INDEX_PATTERNS {
523 if let Ok(glob) = globset::Glob::new(pattern) {
524 builder.add(glob);
525 }
526 }
527 builder.build().unwrap_or_default()
528 })
529}
530
531fn apply_default_fallback(
536 files: &[DiscoveredFile],
537 root: &Path,
538 ws_filter: Option<&Path>,
539) -> Vec<EntryPoint> {
540 let default_matchers = default_index_matchers();
541
542 let mut entries = Vec::new();
543 for file in files {
544 if let Some(ws_root) = ws_filter
545 && file.path.strip_prefix(ws_root).is_err()
546 {
547 continue;
548 }
549 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
550 let relative_str = relative.to_string_lossy();
551 if default_matchers.is_match(relative_str.as_ref()) {
552 entries.push(EntryPoint {
553 path: file.path.clone(),
554 source: EntryPointSource::DefaultIndex,
555 });
556 }
557 }
558 entries
559}
560
561fn relative_paths_for(files: &[DiscoveredFile], root: &Path) -> Vec<String> {
563 files
564 .iter()
565 .map(|f| {
566 f.path
567 .strip_prefix(root)
568 .unwrap_or(&f.path)
569 .to_string_lossy()
570 .into_owned()
571 })
572 .collect()
573}
574
575#[expect(
577 clippy::expect_used,
578 reason = "entry glob patterns are validated before entry point discovery"
579)]
580fn push_manual_entry_matches(
581 entries: &mut Vec<EntryPoint>,
582 config: &ResolvedConfig,
583 relative_paths: &[String],
584 files: &[DiscoveredFile],
585) {
586 let mut builder = globset::GlobSetBuilder::new();
587 for pattern in &config.entry_patterns {
588 builder.add(
589 globset::Glob::new(pattern).expect("entry pattern was validated at config load time"),
590 );
591 }
592 let Ok(glob_set) = builder.build() else {
593 return;
594 };
595 if glob_set.is_empty() {
596 return;
597 }
598 for (idx, rel) in relative_paths.iter().enumerate() {
599 if glob_set.is_match(rel) {
600 entries.push(EntryPoint {
601 path: files[idx].path.clone(),
602 source: EntryPointSource::ManualEntry,
603 });
604 }
605 }
606}
607
608fn push_package_json_entries(
610 discovery: &mut EntryPointDiscovery,
611 root: &Path,
612 pkg: &PackageJson,
613 canonical_root: &Path,
614 ignored: IgnoredCommandEntries<'_>,
615 workspaces: ScriptWorkspaces<'_>,
616) {
617 let output_map = TsconfigOutputMap::from_project(root);
618 for entry_path in pkg.entry_points() {
619 if let Some(ep) = resolve_entry_path_with_output_map(
620 root,
621 &entry_path,
622 canonical_root,
623 EntryPointSource::PackageJsonMain,
624 Some(&mut discovery.skipped_entries),
625 &output_map,
626 ) {
627 discovery.entries.push(ep);
628 }
629 }
630
631 let Some(scripts) = &pkg.scripts else {
632 return;
633 };
634 let package = ScriptPackage {
635 root,
636 canonical_root,
637 output_map: &output_map,
638 workspaces,
639 };
640 push_script_entries(
641 &package,
642 scripts,
643 ignored,
644 &mut PackageEntryBuckets {
645 runtime: &mut discovery.entries,
646 support: &mut discovery.support_entries,
647 },
648 &mut discovery.skipped_entries,
649 );
650}
651
652#[derive(Clone, Copy)]
657pub struct ScriptWorkspaces<'a> {
658 pub packages: &'a std::sync::Arc<WorkspacePackages>,
660 pub project_root: &'a Path,
662 pub runtime_seeds: &'a RuntimeScriptSeeds,
665}
666
667pub type RuntimeScriptSeeds = FxHashMap<String, FxHashSet<String>>;
671
672struct ScriptPackage<'a> {
674 root: &'a Path,
675 canonical_root: &'a Path,
676 output_map: &'a TsconfigOutputMap,
677 workspaces: ScriptWorkspaces<'a>,
678}
679
680impl ScriptPackage<'_> {
681 fn dir(&self) -> Option<String> {
684 self.root
685 .strip_prefix(self.workspaces.project_root)
686 .ok()
687 .map(|dir| dir.to_string_lossy().replace('\\', "/"))
688 }
689
690 fn resolve(
695 &self,
696 file_ref: &str,
697 dir: Option<&str>,
698 skipped_entries: &mut FxHashMap<String, usize>,
699 ) -> Option<EntryPoint> {
700 if entry_has_parent_dir(file_ref)
701 && let Some(project_ref) =
702 dir.and_then(|dir| crate::scripts::normalize_script_entry_pattern(dir, file_ref))
703 {
704 let project_root = self.workspaces.project_root;
705 let canonical_project_root =
706 dunce::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
707 return resolve_entry_path_with_tracking(
708 project_root,
709 &project_ref,
710 &canonical_project_root,
711 EntryPointSource::PackageJsonScript,
712 Some(skipped_entries),
713 );
714 }
715 resolve_entry_path_with_output_map(
716 self.root,
717 file_ref,
718 self.canonical_root,
719 EntryPointSource::PackageJsonScript,
720 Some(skipped_entries),
721 self.output_map,
722 )
723 }
724}
725
726#[expect(
731 clippy::disallowed_types,
732 reason = "API matches serde-deserialized package.json scripts map"
733)]
734fn push_script_entries(
735 package: &ScriptPackage<'_>,
736 scripts: &std::collections::HashMap<String, String>,
737 ignored: IgnoredCommandEntries<'_>,
738 entries: &mut PackageEntryBuckets<'_>,
739 skipped_entries: &mut FxHashMap<String, usize>,
740) {
741 let dir = package.dir();
742 let no_seeds = FxHashSet::default();
743 let seeds = dir
744 .as_deref()
745 .and_then(|dir| package.workspaces.runtime_seeds.get(dir))
746 .unwrap_or(&no_seeds);
747 let runtime_scripts = runtime_package_script_names_with_seeds(scripts, seeds);
748 let catalog = ScriptCatalog::from_scripts(scripts).with_workspaces(
749 std::sync::Arc::clone(package.workspaces.packages),
750 dir.as_deref().unwrap_or_default(),
751 );
752 for (script_name, script_value) in scripts {
753 let refs = package_script_refs(script_value, &catalog, ignored);
754 for file_ref in refs.inheritable {
755 if let Some(ep) = package.resolve(&file_ref, dir.as_deref(), skipped_entries) {
756 if runtime_scripts.contains(script_name) {
757 entries.runtime.push(ep);
758 } else {
759 entries.support.push(ep);
760 }
761 }
762 }
763 for config_ref in refs.support {
764 if let Some(ep) = package.resolve(&config_ref, dir.as_deref(), skipped_entries) {
765 entries.support.push(ep);
766 }
767 }
768 }
769}
770
771struct PackageScriptRefs {
772 inheritable: Vec<String>,
773 support: Vec<String>,
774}
775
776fn package_script_refs(
780 script: &str,
781 catalog: &ScriptCatalog,
782 ignored: IgnoredCommandEntries<'_>,
783) -> PackageScriptRefs {
784 let mut inheritable = Vec::new();
785 let mut support = Vec::new();
786 for command in crate::scripts::parse_script_with_catalog(script, catalog) {
787 inheritable.extend_from_slice(command.entry_files(ignored));
788 support.extend(command.config_args);
789 }
790 inheritable.extend(super::parse_scripts::extract_script_file_refs(
791 script,
792 super::parse_scripts::CommandRefContext {
793 ignored,
794 scripts: catalog,
795 },
796 ));
797 support.sort_unstable();
798 support.dedup();
799 inheritable.retain(|path| support.binary_search(path).is_err());
800 inheritable.sort_unstable();
801 inheritable.dedup();
802 PackageScriptRefs {
803 inheritable,
804 support,
805 }
806}
807
808#[expect(
812 clippy::disallowed_types,
813 reason = "API matches serde-deserialized package.json scripts map"
814)]
815fn runtime_package_script_names(
816 scripts: &std::collections::HashMap<String, String>,
817) -> FxHashSet<String> {
818 runtime_package_script_names_with_seeds(scripts, &FxHashSet::default())
819}
820
821#[expect(
822 clippy::disallowed_types,
823 reason = "API matches serde-deserialized package.json scripts map"
824)]
825fn runtime_package_script_names_with_seeds(
826 scripts: &std::collections::HashMap<String, String>,
827 seeds: &FxHashSet<String>,
828) -> FxHashSet<String> {
829 let catalog = ScriptCatalog::from_scripts(scripts);
830 let mut runtime = FxHashSet::default();
831 let mut pending = Vec::new();
832
833 enqueue_runtime_script("start", scripts, &mut runtime, &mut pending);
834 for seed in seeds {
835 enqueue_runtime_script(seed, scripts, &mut runtime, &mut pending);
836 }
837
838 while let Some(name) = pending.pop() {
839 let Some(body) = scripts.get(&name) else {
840 continue;
841 };
842 for referenced in crate::scripts::referenced_package_scripts(body, &catalog) {
843 enqueue_runtime_script(&referenced, scripts, &mut runtime, &mut pending);
844 }
845 }
846
847 runtime
848}
849
850pub fn collect_workspace_packages(
854 project_root: &Path,
855 root_pkg: Option<&PackageJson>,
856 workspace_pkgs: &[(fallow_config::WorkspaceInfo, PackageJson)],
857) -> std::sync::Arc<WorkspacePackages> {
858 let mut packages = WorkspacePackages::default();
859 if let Some(pkg) = root_pkg {
860 packages.add_root(
861 pkg.name.as_deref().unwrap_or_default(),
862 pkg.scripts.as_ref(),
863 );
864 }
865 for (ws, ws_pkg) in workspace_pkgs {
866 let dir = ws
867 .root
868 .strip_prefix(project_root)
869 .unwrap_or(&ws.root)
870 .to_string_lossy()
871 .replace('\\', "/");
872 packages.add(&ws.name, &dir, ws_pkg.scripts.as_ref());
873 }
874 std::sync::Arc::new(packages)
875}
876
877pub fn workspace_runtime_script_seeds(
882 packages: &std::sync::Arc<WorkspacePackages>,
883) -> RuntimeScriptSeeds {
884 let mut seeds = RuntimeScriptSeeds::default();
885 let mut pending: Vec<(String, String)> = packages
886 .iter()
887 .flat_map(|package| {
888 runtime_package_script_names(package.scripts())
889 .into_iter()
890 .map(|name| (package.dir().to_string(), name))
891 })
892 .collect();
893
894 let mut visited = FxHashSet::default();
895 while let Some((dir, name)) = pending.pop() {
896 if !visited.insert((dir.clone(), name.clone())) {
897 continue;
898 }
899 let Some(package) = packages.find_dir(&dir) else {
900 continue;
901 };
902 let Some(body) = package.scripts().get(&name) else {
903 continue;
904 };
905 let catalog = ScriptCatalog::from_scripts(package.scripts())
906 .with_workspaces(std::sync::Arc::clone(packages), &dir);
907 for (target_dir, script) in crate::scripts::referenced_workspace_scripts(body, &catalog) {
908 let Some(target) = packages.find_dir(&target_dir) else {
909 continue;
910 };
911 let target_seeds = FxHashSet::from_iter([script]);
912 for runtime_name in
913 runtime_package_script_names_with_seeds(target.scripts(), &target_seeds)
914 {
915 if seeds
916 .entry(target_dir.clone())
917 .or_default()
918 .insert(runtime_name.clone())
919 {
920 pending.push((target_dir.clone(), runtime_name));
921 }
922 }
923 }
924 }
925 seeds
926}
927
928#[expect(
929 clippy::disallowed_types,
930 reason = "API matches serde-deserialized package.json scripts map"
931)]
932fn enqueue_runtime_script(
933 name: &str,
934 scripts: &std::collections::HashMap<String, String>,
935 runtime: &mut FxHashSet<String>,
936 pending: &mut Vec<String>,
937) {
938 for candidate in [
939 format!("pre{name}"),
940 name.to_string(),
941 format!("post{name}"),
942 ] {
943 if scripts.contains_key(&candidate) && runtime.insert(candidate.clone()) {
944 pending.push(candidate);
945 }
946 }
947}
948
949pub fn discover_root_entry_points(
953 config: &ResolvedConfig,
954 files: &[DiscoveredFile],
955 root_pkg: Option<&PackageJson>,
956 include_nested_package_entries: bool,
957 workspaces: ScriptWorkspaces<'_>,
958) -> EntryPointDiscovery {
959 let _span = tracing::info_span!("discover_entry_points").entered();
960 let mut discovery = EntryPointDiscovery::default();
961
962 let relative_paths = relative_paths_for(files, &config.root);
963 push_manual_entry_matches(&mut discovery.entries, config, &relative_paths, files);
964
965 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
966 if let Some(pkg) = root_pkg {
967 push_package_json_entries(
968 &mut discovery,
969 &config.root,
970 pkg,
971 &canonical_root,
972 IgnoredCommandEntries::new(&config.ignore_command_entries),
973 workspaces,
974 );
975 }
976
977 if include_nested_package_entries {
978 let exports_dirs = root_pkg
979 .map(PackageJson::exports_subdirectories)
980 .unwrap_or_default();
981 let mut nested_entries = PackageEntryBuckets {
982 runtime: &mut discovery.entries,
983 support: &mut discovery.support_entries,
984 };
985 discover_nested_package_entries(
986 &mut nested_entries,
987 &NestedPackageSearch {
988 canonical_root: &canonical_root,
989 exports_subdirectories: &exports_dirs,
990 ignored: IgnoredCommandEntries::new(&config.ignore_command_entries),
991 workspaces,
992 },
993 &mut discovery.skipped_entries,
994 );
995 }
996
997 if discovery.entries.is_empty() {
998 discovery.entries = apply_default_fallback(files, &config.root, None);
999 }
1000
1001 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
1002 discovery.entries.dedup_by(|a, b| a.path == b.path);
1003 discovery
1004 .support_entries
1005 .sort_by(|a, b| a.path.cmp(&b.path));
1006 discovery.support_entries.dedup_by(|a, b| a.path == b.path);
1007
1008 discovery
1009}
1010
1011#[must_use]
1017pub fn discover_entry_points(
1018 config: &ResolvedConfig,
1019 files: &[DiscoveredFile],
1020 workspaces: &[fallow_config::WorkspaceInfo],
1021) -> Vec<EntryPoint> {
1022 use rayon::prelude::*;
1023
1024 let root_pkg = fallow_config::load_dir_package_json(&config.root);
1025 let workspace_pkgs: Vec<(fallow_config::WorkspaceInfo, PackageJson)> = workspaces
1026 .iter()
1027 .filter_map(|ws| {
1028 fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1029 })
1030 .collect();
1031 let packages = collect_workspace_packages(&config.root, root_pkg.as_ref(), &workspace_pkgs);
1032 let runtime_seeds = workspace_runtime_script_seeds(&packages);
1033 let script_workspaces = ScriptWorkspaces {
1034 packages: &packages,
1035 project_root: &config.root,
1036 runtime_seeds: &runtime_seeds,
1037 };
1038 let mut discovery = discover_root_entry_points(
1039 config,
1040 files,
1041 root_pkg.as_ref(),
1042 workspaces.is_empty(),
1043 script_workspaces,
1044 );
1045 let pkg_by_root: FxHashMap<&Path, &PackageJson> = workspace_pkgs
1046 .iter()
1047 .map(|(ws, pkg)| (ws.root.as_path(), pkg))
1048 .collect();
1049 let workspace_discovery: Vec<EntryPointDiscovery> = workspaces
1050 .par_iter()
1051 .map(|ws| {
1052 discover_workspace_package_entry_points(
1053 &ws.root,
1054 files,
1055 pkg_by_root.get(ws.root.as_path()).copied(),
1056 IgnoredCommandEntries::new(&config.ignore_command_entries),
1057 script_workspaces,
1058 )
1059 })
1060 .collect();
1061 for workspace in workspace_discovery {
1062 discovery.entries.extend(workspace.entries);
1063 discovery.support_entries.extend(workspace.support_entries);
1064 for (path, count) in workspace.skipped_entries {
1065 *discovery.skipped_entries.entry(path).or_insert(0) += count;
1066 }
1067 }
1068 warn_skipped_entry_summary(&discovery.skipped_entries);
1069 discovery.into_all_entries()
1070}
1071
1072struct PackageEntryBuckets<'a> {
1082 runtime: &'a mut Vec<EntryPoint>,
1083 support: &'a mut Vec<EntryPoint>,
1084}
1085
1086struct NestedPackageSearch<'a> {
1088 canonical_root: &'a Path,
1089 exports_subdirectories: &'a [String],
1090 ignored: IgnoredCommandEntries<'a>,
1091 workspaces: ScriptWorkspaces<'a>,
1092}
1093
1094fn discover_nested_package_entries(
1095 entries: &mut PackageEntryBuckets<'_>,
1096 search: &NestedPackageSearch<'_>,
1097 skipped_entries: &mut FxHashMap<String, usize>,
1098) {
1099 let root = search.workspaces.project_root;
1100 let mut visited = rustc_hash::FxHashSet::default();
1101
1102 let search_dirs = [
1103 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
1104 ];
1105 for dir_name in &search_dirs {
1106 let search_dir = root.join(dir_name);
1107 if !search_dir.is_dir() {
1108 continue;
1109 }
1110 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
1111 continue;
1112 };
1113 for entry in read_dir.flatten() {
1114 let pkg_dir = entry.path();
1115 if visited.insert(pkg_dir.clone()) {
1116 collect_nested_package_entries(&pkg_dir, entries, search, skipped_entries);
1117 }
1118 }
1119 }
1120
1121 for dir_name in search.exports_subdirectories {
1122 let pkg_dir = root.join(dir_name);
1123 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
1124 collect_nested_package_entries(&pkg_dir, entries, search, skipped_entries);
1125 }
1126 }
1127}
1128
1129fn collect_nested_package_entries(
1131 pkg_dir: &Path,
1132 entries: &mut PackageEntryBuckets<'_>,
1133 search: &NestedPackageSearch<'_>,
1134 skipped_entries: &mut FxHashMap<String, usize>,
1135) {
1136 let Some(pkg) = fallow_config::load_dir_package_json(pkg_dir) else {
1137 return;
1138 };
1139 let output_map = TsconfigOutputMap::from_project(pkg_dir);
1140 for entry_path in pkg.entry_points() {
1141 if entry_path.contains('*') {
1142 expand_wildcard_entries(pkg_dir, &entry_path, search.canonical_root, entries.runtime);
1143 } else if let Some(ep) = resolve_entry_path_with_output_map(
1144 pkg_dir,
1145 &entry_path,
1146 search.canonical_root,
1147 EntryPointSource::PackageJsonExports,
1148 Some(&mut *skipped_entries),
1149 &output_map,
1150 ) {
1151 entries.runtime.push(ep);
1152 }
1153 }
1154 if let Some(scripts) = &pkg.scripts {
1155 let package = ScriptPackage {
1156 root: pkg_dir,
1157 canonical_root: search.canonical_root,
1158 output_map: &output_map,
1159 workspaces: search.workspaces,
1160 };
1161 push_script_entries(&package, scripts, search.ignored, entries, skipped_entries);
1162 }
1163}
1164
1165fn expand_wildcard_entries(
1171 base: &Path,
1172 pattern: &str,
1173 canonical_root: &Path,
1174 entries: &mut Vec<EntryPoint>,
1175) {
1176 let full_pattern = base.join(pattern).to_string_lossy().to_string();
1177 let Ok(matches) = glob::glob(&full_pattern) else {
1178 return;
1179 };
1180 for path_result in matches {
1181 let Ok(path) = path_result else {
1182 continue;
1183 };
1184 if let Ok(canonical) = dunce::canonicalize(&path)
1185 && canonical.starts_with(canonical_root)
1186 {
1187 entries.push(EntryPoint {
1188 path,
1189 source: EntryPointSource::PackageJsonExports,
1190 });
1191 }
1192 }
1193}
1194
1195#[must_use]
1197pub fn discover_workspace_package_entry_points(
1198 ws_root: &Path,
1199 all_files: &[DiscoveredFile],
1200 pkg: Option<&PackageJson>,
1201 ignored: IgnoredCommandEntries<'_>,
1202 workspaces: ScriptWorkspaces<'_>,
1203) -> EntryPointDiscovery {
1204 let mut discovery = EntryPointDiscovery::default();
1205
1206 if let Some(pkg) = pkg {
1207 let canonical_ws_root =
1208 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
1209 let output_map = TsconfigOutputMap::from_project(ws_root);
1210 for entry_path in pkg.entry_points() {
1211 if entry_path.contains('*') {
1212 expand_wildcard_entries(
1213 ws_root,
1214 &entry_path,
1215 &canonical_ws_root,
1216 &mut discovery.entries,
1217 );
1218 } else if let Some(ep) = resolve_entry_path_with_output_map(
1219 ws_root,
1220 &entry_path,
1221 &canonical_ws_root,
1222 EntryPointSource::PackageJsonMain,
1223 Some(&mut discovery.skipped_entries),
1224 &output_map,
1225 ) {
1226 discovery.entries.push(ep);
1227 }
1228 }
1229
1230 if let Some(scripts) = &pkg.scripts {
1231 let package = ScriptPackage {
1232 root: ws_root,
1233 canonical_root: &canonical_ws_root,
1234 output_map: &output_map,
1235 workspaces,
1236 };
1237 push_script_entries(
1238 &package,
1239 scripts,
1240 ignored,
1241 &mut PackageEntryBuckets {
1242 runtime: &mut discovery.entries,
1243 support: &mut discovery.support_entries,
1244 },
1245 &mut discovery.skipped_entries,
1246 );
1247 }
1248 }
1249
1250 if discovery.entries.is_empty() {
1251 discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
1252 }
1253
1254 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
1255 discovery.entries.dedup_by(|a, b| a.path == b.path);
1256 discovery
1257 .support_entries
1258 .sort_by(|a, b| a.path.cmp(&b.path));
1259 discovery.support_entries.dedup_by(|a, b| a.path == b.path);
1260 discovery
1261}
1262
1263#[must_use]
1268pub fn discover_plugin_entry_points(
1269 plugin_result: &crate::plugins::AggregatedPluginResult,
1270 config: &ResolvedConfig,
1271 files: &[DiscoveredFile],
1272) -> Vec<EntryPoint> {
1273 discover_plugin_entry_point_sets(plugin_result, config, files).all
1274}
1275
1276#[must_use]
1278pub fn discover_plugin_entry_point_sets(
1279 plugin_result: &crate::plugins::AggregatedPluginResult,
1280 config: &ResolvedConfig,
1281 files: &[DiscoveredFile],
1282) -> CategorizedEntryPoints {
1283 discover_plugin_entry_point_sets_timed(plugin_result, config, files).entries
1284}
1285
1286pub struct TimedPluginEntryPoints {
1289 pub entries: CategorizedEntryPoints,
1290 pub build_ms: f64,
1293 pub match_ms: f64,
1296}
1297
1298pub fn discover_plugin_entry_point_sets_timed(
1299 plugin_result: &crate::plugins::AggregatedPluginResult,
1300 config: &ResolvedConfig,
1301 files: &[DiscoveredFile],
1302) -> TimedPluginEntryPoints {
1303 let mut entries = CategorizedEntryPoints::default();
1304
1305 let build_start = std::time::Instant::now();
1306 let relative_paths = relative_paths_for(files, &config.root);
1307 let (glob_set, glob_meta) = build_plugin_glob_meta(plugin_result);
1308 let build_ms = build_start.elapsed().as_secs_f64() * 1000.0;
1309
1310 let match_start = std::time::Instant::now();
1311 if let Some(glob_set) = glob_set.filter(|set| !set.is_empty()) {
1312 match_plugin_entry_files(&mut entries, &glob_set, &glob_meta, &relative_paths, files);
1313 }
1314 let match_ms = match_start.elapsed().as_secs_f64() * 1000.0;
1315
1316 push_plugin_setup_files(&mut entries, &config.root, plugin_result);
1317
1318 TimedPluginEntryPoints {
1319 entries: entries.dedup(),
1320 build_ms,
1321 match_ms,
1322 }
1323}
1324
1325fn build_plugin_glob_meta(
1327 plugin_result: &crate::plugins::AggregatedPluginResult,
1328) -> (Option<globset::GlobSet>, Vec<CompiledEntryRule<'_>>) {
1329 let mut builder = globset::GlobSetBuilder::new();
1330 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
1331 for (rule, pname) in &plugin_result.entry_patterns {
1332 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
1333 builder.add(include);
1334 glob_meta.push(compiled);
1335 }
1336 }
1337 for (pattern, pname) in plugin_result
1338 .discovered_always_used
1339 .iter()
1340 .chain(plugin_result.always_used.iter())
1341 .chain(plugin_result.fixture_patterns.iter())
1342 {
1343 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1344 .literal_separator(true)
1345 .build()
1346 {
1347 builder.add(glob);
1348 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
1349 &crate::plugins::PathRule::new(pattern.clone()),
1350 "support entry pattern",
1351 ) {
1352 glob_meta.push(CompiledEntryRule {
1353 path,
1354 plugin_name: pname,
1355 role: EntryPointRole::Support,
1356 });
1357 }
1358 }
1359 }
1360 (builder.build().ok(), glob_meta)
1361}
1362
1363fn match_plugin_entry_files(
1365 entries: &mut CategorizedEntryPoints,
1366 glob_set: &globset::GlobSet,
1367 glob_meta: &[CompiledEntryRule<'_>],
1368 relative_paths: &[String],
1369 files: &[DiscoveredFile],
1370) {
1371 for (idx, rel) in relative_paths.iter().enumerate() {
1372 let matches: Vec<usize> = glob_set
1373 .matches(rel)
1374 .into_iter()
1375 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
1376 .collect();
1377 if matches.is_empty() {
1378 continue;
1379 }
1380 let name = glob_meta[matches[0]].plugin_name;
1381 let entry = EntryPoint {
1382 path: files[idx].path.clone(),
1383 source: EntryPointSource::Plugin {
1384 name: name.to_string(),
1385 },
1386 };
1387 categorize_plugin_match(entries, entry, glob_meta, &matches);
1388 }
1389}
1390
1391fn categorize_plugin_match(
1393 entries: &mut CategorizedEntryPoints,
1394 entry: EntryPoint,
1395 glob_meta: &[CompiledEntryRule<'_>],
1396 matches: &[usize],
1397) {
1398 let mut has_runtime = false;
1399 let mut has_test = false;
1400 let mut has_support = false;
1401 for &match_idx in matches {
1402 match glob_meta[match_idx].role {
1403 EntryPointRole::Runtime => has_runtime = true,
1404 EntryPointRole::Test => has_test = true,
1405 EntryPointRole::Support => has_support = true,
1406 }
1407 }
1408
1409 if has_runtime {
1410 entries.push_runtime(entry.clone());
1411 }
1412 if has_test {
1413 entries.push_test(entry.clone());
1414 }
1415 if has_support || (!has_runtime && !has_test) {
1416 entries.push_support(entry);
1417 }
1418}
1419
1420fn push_plugin_setup_files(
1422 entries: &mut CategorizedEntryPoints,
1423 root: &Path,
1424 plugin_result: &crate::plugins::AggregatedPluginResult,
1425) {
1426 for (setup_file, pname) in &plugin_result.setup_files {
1427 let resolved = resolve_plugin_setup_file(root, setup_file);
1428 if resolved.exists() {
1429 entries.push_support(EntryPoint {
1430 path: resolved,
1431 source: EntryPointSource::Plugin {
1432 name: pname.clone(),
1433 },
1434 });
1435 continue;
1436 }
1437 for ext in SOURCE_EXTENSIONS {
1438 let with_ext = resolved.with_extension(ext);
1439 if with_ext.exists() {
1440 entries.push_support(EntryPoint {
1441 path: with_ext,
1442 source: EntryPointSource::Plugin {
1443 name: pname.clone(),
1444 },
1445 });
1446 break;
1447 }
1448 }
1449 }
1450}
1451
1452fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
1453 if is_absolute_path_any_platform(setup_file) {
1454 setup_file.to_path_buf()
1455 } else {
1456 root.join(setup_file)
1457 }
1458}
1459
1460#[expect(
1465 clippy::expect_used,
1466 reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
1467)]
1468#[must_use]
1469pub fn discover_dynamically_loaded_entry_points(
1470 config: &ResolvedConfig,
1471 files: &[DiscoveredFile],
1472) -> Vec<EntryPoint> {
1473 if config.dynamically_loaded.is_empty() {
1474 return Vec::new();
1475 }
1476
1477 let mut builder = globset::GlobSetBuilder::new();
1478 for pattern in &config.dynamically_loaded {
1479 builder.add(
1480 globset::Glob::new(pattern)
1481 .expect("dynamicallyLoaded pattern was validated at config load time"),
1482 );
1483 }
1484 let Ok(glob_set) = builder.build() else {
1485 return Vec::new();
1486 };
1487 if glob_set.is_empty() {
1488 return Vec::new();
1489 }
1490
1491 let mut entries = Vec::new();
1492 for file in files {
1493 let rel = file
1494 .path
1495 .strip_prefix(&config.root)
1496 .unwrap_or(&file.path)
1497 .to_string_lossy();
1498 if glob_set.is_match(rel.as_ref()) {
1499 entries.push(EntryPoint {
1500 path: file.path.clone(),
1501 source: EntryPointSource::DynamicallyLoaded,
1502 });
1503 }
1504 }
1505 entries
1506}
1507
1508struct CompiledEntryRule<'a> {
1509 path: crate::plugins::CompiledPathRule,
1510 plugin_name: &'a str,
1511 role: EntryPointRole,
1512}
1513
1514impl CompiledEntryRule<'_> {
1515 fn matches(&self, path: &str) -> bool {
1516 self.path.matches(path)
1517 }
1518}
1519
1520fn compile_entry_rule<'a>(
1521 rule: &'a crate::plugins::PathRule,
1522 plugin_name: &'a str,
1523 plugin_result: &'a crate::plugins::AggregatedPluginResult,
1524) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1525 let include = match globset::GlobBuilder::new(&rule.pattern)
1526 .literal_separator(true)
1527 .build()
1528 {
1529 Ok(glob) => glob,
1530 Err(err) => {
1531 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1532 return None;
1533 }
1534 };
1535 let role = plugin_result
1536 .entry_point_roles
1537 .get(plugin_name)
1538 .copied()
1539 .unwrap_or(EntryPointRole::Support);
1540 Some((
1541 include,
1542 CompiledEntryRule {
1543 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1544 plugin_name,
1545 role,
1546 },
1547 ))
1548}
1549
1550#[must_use]
1552pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1553 if patterns.is_empty() {
1554 return None;
1555 }
1556 let mut builder = globset::GlobSetBuilder::new();
1557 for pattern in patterns {
1558 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1559 .literal_separator(true)
1560 .build()
1561 {
1562 builder.add(glob);
1563 }
1564 }
1565 builder.build().ok()
1566}
1567
1568#[cfg(test)]
1569mod tests {
1570 use super::*;
1571 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1572 use fallow_types::discover::FileId;
1573 use proptest::prelude::*;
1574
1575 proptest! {
1576 #[test]
1578 fn glob_patterns_never_panic_on_compile(
1579 prefix in "[a-zA-Z0-9_]{1,20}",
1580 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1581 ) {
1582 let pattern = format!("**/{prefix}*.{ext}");
1583 let result = globset::Glob::new(&pattern);
1584 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1585 }
1586
1587 #[test]
1589 fn non_source_extensions_not_in_list(
1590 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1591 ) {
1592 prop_assert!(
1593 !SOURCE_EXTENSIONS.contains(&ext),
1594 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1595 );
1596 }
1597
1598 #[test]
1600 fn compile_glob_set_no_panic(
1601 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1602 ) {
1603 let _ = compile_glob_set(&patterns);
1604 }
1605 }
1606
1607 #[test]
1608 fn compile_glob_set_empty_input() {
1609 assert!(
1610 compile_glob_set(&[]).is_none(),
1611 "empty patterns should return None"
1612 );
1613 }
1614
1615 #[test]
1616 fn compile_glob_set_valid_patterns() {
1617 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1618 let set = compile_glob_set(&patterns);
1619 assert!(set.is_some(), "valid patterns should compile");
1620 let set = set.unwrap();
1621 assert!(set.is_match("src/foo.ts"));
1622 assert!(set.is_match("src/bar.js"));
1623 assert!(!set.is_match("src/bar.py"));
1624 }
1625
1626 #[test]
1627 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1628 let patterns = vec!["composables/*.{ts,js}".to_string()];
1629 let set = compile_glob_set(&patterns).expect("pattern should compile");
1630
1631 assert!(set.is_match("composables/useFoo.ts"));
1632 assert!(!set.is_match("composables/nested/useFoo.ts"));
1633 }
1634
1635 #[test]
1636 #[expect(
1637 clippy::too_many_lines,
1638 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
1639 )]
1640 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1641 let dir = tempfile::tempdir().expect("create temp dir");
1642 let root = dir.path();
1643 std::fs::create_dir_all(root.join("src")).unwrap();
1644 std::fs::create_dir_all(root.join("tests")).unwrap();
1645 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1646 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1647 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1648
1649 let config = FallowConfig {
1650 type_aware: fallow_config::TypeAwareConfig::default(),
1651 schema: None,
1652 minimum_version: None,
1653 extends: vec![],
1654 entry: vec![],
1655 ignore_patterns: vec![],
1656 ignore_findings: vec![],
1657 framework: vec![],
1658 workspaces: None,
1659 ignore_dependencies: vec![],
1660 ignore_command_entries: vec![],
1661 ignore_unresolved_imports: vec![],
1662 ignore_exports: vec![],
1663 ignore_catalog_references: vec![],
1664 ignore_dependency_overrides: vec![],
1665 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1666 used_class_members: vec![],
1667 ignore_decorators: vec![],
1668 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1669 circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
1670 duplicates: fallow_config::DuplicatesConfig::default(),
1671 similar_code: fallow_config::SimilarCodeConfig::default(),
1672 health: fallow_config::HealthConfig::default(),
1673 rules: RulesConfig::default(),
1674 boundaries: fallow_config::BoundaryConfig::default(),
1675 production: false.into(),
1676 plugins: vec![],
1677 rule_packs: vec![],
1678 dynamically_loaded: vec![],
1679 overrides: vec![],
1680 regression: None,
1681 audit: fallow_config::AuditConfig::default(),
1682 codeowners: None,
1683 public_packages: vec![],
1684 flags: fallow_config::FlagsConfig::default(),
1685 security: fallow_config::SecurityConfig::default(),
1686 fix: fallow_config::FixConfig::default(),
1687 resolve: fallow_config::ResolveConfig::default(),
1688 sealed: false,
1689 include_entry_exports: false,
1690 auto_imports: false,
1691 fail_on_parse_error: false,
1692 cache: fallow_config::CacheConfig::default(),
1693 }
1694 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1695
1696 let files = vec![
1697 DiscoveredFile {
1698 id: FileId(0),
1699 path: root.join("src/runtime.ts"),
1700 size_bytes: 1,
1701 },
1702 DiscoveredFile {
1703 id: FileId(1),
1704 path: root.join("src/setup.ts"),
1705 size_bytes: 1,
1706 },
1707 DiscoveredFile {
1708 id: FileId(2),
1709 path: root.join("tests/app.test.ts"),
1710 size_bytes: 1,
1711 },
1712 ];
1713
1714 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1715 plugin_result.entry_patterns.push((
1716 crate::plugins::PathRule::new("src/runtime.ts"),
1717 "runtime-plugin".to_string(),
1718 ));
1719 plugin_result.entry_patterns.push((
1720 crate::plugins::PathRule::new("tests/app.test.ts"),
1721 "test-plugin".to_string(),
1722 ));
1723 plugin_result
1724 .always_used
1725 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1726 plugin_result
1727 .entry_point_roles
1728 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1729 plugin_result
1730 .entry_point_roles
1731 .insert("test-plugin".to_string(), EntryPointRole::Test);
1732 plugin_result
1733 .entry_point_roles
1734 .insert("support-plugin".to_string(), EntryPointRole::Support);
1735
1736 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1737
1738 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1739 assert!(
1740 entries.runtime[0].path.ends_with("src/runtime.ts"),
1741 "runtime entry should stay runtime-only"
1742 );
1743 assert_eq!(entries.test.len(), 1, "expected one test entry");
1744 assert!(
1745 entries.test[0].path.ends_with("tests/app.test.ts"),
1746 "test entry should stay test-only"
1747 );
1748 assert_eq!(
1749 entries.all.len(),
1750 3,
1751 "support entries should stay in all entries"
1752 );
1753 assert!(
1754 entries
1755 .all
1756 .iter()
1757 .any(|entry| entry.path.ends_with("src/setup.ts")),
1758 "support entries should remain in the overall entry-point set"
1759 );
1760 assert!(
1761 !entries
1762 .runtime
1763 .iter()
1764 .any(|entry| entry.path.ends_with("src/setup.ts")),
1765 "support entries should not bleed into runtime reachability"
1766 );
1767 assert!(
1768 !entries
1769 .test
1770 .iter()
1771 .any(|entry| entry.path.ends_with("src/setup.ts")),
1772 "support entries should not bleed into test reachability"
1773 );
1774 }
1775
1776 #[test]
1777 fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1778 let root = Path::new("/workspace/project");
1779 let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1780
1781 assert_eq!(
1782 resolve_plugin_setup_file(root, setup_file),
1783 setup_file.to_path_buf()
1784 );
1785 }
1786
1787 #[cfg(windows)]
1788 #[test]
1789 fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1790 let root = Path::new(r"C:\workspace\project");
1791 let setup_file = Path::new(r"/workspace/project/setup.ts");
1792
1793 assert_eq!(
1794 resolve_plugin_setup_file(root, setup_file),
1795 setup_file.to_path_buf()
1796 );
1797 }
1798
1799 #[test]
1800 fn plugin_entry_point_rules_respect_exclusions() {
1801 let dir = tempfile::tempdir().expect("create temp dir");
1802 let root = dir.path();
1803 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1804 std::fs::write(
1805 root.join("app/pages/index.tsx"),
1806 "export default function Page() { return null; }",
1807 )
1808 .unwrap();
1809 std::fs::write(
1810 root.join("app/pages/-helper.ts"),
1811 "export const helper = 1;",
1812 )
1813 .unwrap();
1814
1815 let config = FallowConfig {
1816 type_aware: fallow_config::TypeAwareConfig::default(),
1817 schema: None,
1818 minimum_version: None,
1819 extends: vec![],
1820 entry: vec![],
1821 ignore_patterns: vec![],
1822 ignore_findings: vec![],
1823 framework: vec![],
1824 workspaces: None,
1825 ignore_dependencies: vec![],
1826 ignore_command_entries: vec![],
1827 ignore_unresolved_imports: vec![],
1828 ignore_exports: vec![],
1829 ignore_catalog_references: vec![],
1830 ignore_dependency_overrides: vec![],
1831 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1832 used_class_members: vec![],
1833 ignore_decorators: vec![],
1834 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1835 circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
1836 duplicates: fallow_config::DuplicatesConfig::default(),
1837 similar_code: fallow_config::SimilarCodeConfig::default(),
1838 health: fallow_config::HealthConfig::default(),
1839 rules: RulesConfig::default(),
1840 boundaries: fallow_config::BoundaryConfig::default(),
1841 production: false.into(),
1842 plugins: vec![],
1843 rule_packs: vec![],
1844 dynamically_loaded: vec![],
1845 overrides: vec![],
1846 regression: None,
1847 audit: fallow_config::AuditConfig::default(),
1848 codeowners: None,
1849 public_packages: vec![],
1850 flags: fallow_config::FlagsConfig::default(),
1851 security: fallow_config::SecurityConfig::default(),
1852 fix: fallow_config::FixConfig::default(),
1853 resolve: fallow_config::ResolveConfig::default(),
1854 sealed: false,
1855 include_entry_exports: false,
1856 auto_imports: false,
1857 fail_on_parse_error: false,
1858 cache: fallow_config::CacheConfig::default(),
1859 }
1860 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1861
1862 let files = vec![
1863 DiscoveredFile {
1864 id: FileId(0),
1865 path: root.join("app/pages/index.tsx"),
1866 size_bytes: 1,
1867 },
1868 DiscoveredFile {
1869 id: FileId(1),
1870 path: root.join("app/pages/-helper.ts"),
1871 size_bytes: 1,
1872 },
1873 ];
1874
1875 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1876 plugin_result.entry_patterns.push((
1877 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1878 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1879 "tanstack-router".to_string(),
1880 ));
1881 plugin_result
1882 .entry_point_roles
1883 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1884
1885 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1886 let entry_paths: Vec<_> = entries
1887 .all
1888 .iter()
1889 .map(|entry| {
1890 entry
1891 .path
1892 .strip_prefix(root)
1893 .unwrap()
1894 .to_string_lossy()
1895 .into_owned()
1896 })
1897 .collect();
1898
1899 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1900 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1901 }
1902
1903 mod resolve_entry_path_tests {
1904 use super::*;
1905
1906 #[test]
1907 fn resolves_existing_file() {
1908 let dir = tempfile::tempdir().expect("create temp dir");
1909 let src = dir.path().join("src");
1910 std::fs::create_dir_all(&src).unwrap();
1911 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1912
1913 let canonical = dunce::canonicalize(dir.path()).unwrap();
1914 let result = resolve_entry_path(
1915 dir.path(),
1916 "src/index.ts",
1917 &canonical,
1918 EntryPointSource::PackageJsonMain,
1919 );
1920 assert!(result.is_some(), "should resolve an existing file");
1921 assert!(result.unwrap().path.ends_with("src/index.ts"));
1922 }
1923
1924 #[test]
1925 fn resolves_with_extension_fallback() {
1926 let dir = tempfile::tempdir().expect("create temp dir");
1927 let canonical = dunce::canonicalize(dir.path()).unwrap();
1928 let src = canonical.join("src");
1929 std::fs::create_dir_all(&src).unwrap();
1930 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1931
1932 let result = resolve_entry_path(
1933 &canonical,
1934 "src/index",
1935 &canonical,
1936 EntryPointSource::PackageJsonMain,
1937 );
1938 assert!(
1939 result.is_some(),
1940 "should resolve via extension fallback when exact path doesn't exist"
1941 );
1942 let ep = result.unwrap();
1943 assert!(
1944 ep.path.to_string_lossy().contains("index.ts"),
1945 "should find index.ts via extension fallback"
1946 );
1947 }
1948
1949 #[test]
1950 fn exact_file_wins_before_directory_index_fallback() {
1951 let dir = tempfile::tempdir().expect("create temp dir");
1952 let canonical = dunce::canonicalize(dir.path()).unwrap();
1953 let scripts = canonical.join("scripts");
1954 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1955 std::fs::write(
1956 scripts.join("process-messages.js"),
1957 "export const direct = true;",
1958 )
1959 .unwrap();
1960 std::fs::write(
1961 scripts.join("process-messages").join("index.js"),
1962 "export const index = true;",
1963 )
1964 .unwrap();
1965
1966 let result = resolve_entry_path(
1967 &canonical,
1968 "scripts/process-messages.js",
1969 &canonical,
1970 EntryPointSource::PackageJsonScript,
1971 )
1972 .expect("exact file should resolve");
1973
1974 assert!(result.path.ends_with("scripts/process-messages.js"));
1975 }
1976
1977 #[test]
1978 fn extension_fallback_wins_before_directory_index_fallback() {
1979 let dir = tempfile::tempdir().expect("create temp dir");
1980 let canonical = dunce::canonicalize(dir.path()).unwrap();
1981 let scripts = canonical.join("scripts");
1982 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1983 std::fs::write(
1984 scripts.join("process-messages.ts"),
1985 "export const withExt = true;",
1986 )
1987 .unwrap();
1988 std::fs::write(
1989 scripts.join("process-messages").join("index.js"),
1990 "export const index = true;",
1991 )
1992 .unwrap();
1993
1994 let result = resolve_entry_path(
1995 &canonical,
1996 "scripts/process-messages",
1997 &canonical,
1998 EntryPointSource::PackageJsonScript,
1999 )
2000 .expect("extension fallback should resolve");
2001
2002 assert!(result.path.ends_with("scripts/process-messages.ts"));
2003 }
2004
2005 #[test]
2006 fn resolves_directory_index_after_exact_and_extension_fallbacks() {
2007 let dir = tempfile::tempdir().expect("create temp dir");
2008 let canonical = dunce::canonicalize(dir.path()).unwrap();
2009 let scripts = canonical.join("scripts/process-messages");
2010 std::fs::create_dir_all(&scripts).unwrap();
2011 std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
2012
2013 let result = resolve_entry_path(
2014 &canonical,
2015 "scripts/process-messages",
2016 &canonical,
2017 EntryPointSource::PackageJsonScript,
2018 )
2019 .expect("directory index should resolve");
2020
2021 assert!(result.path.ends_with("scripts/process-messages/index.js"));
2022 }
2023
2024 #[test]
2025 fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
2026 let dir = tempfile::tempdir().expect("create temp dir");
2027 let canonical = dunce::canonicalize(dir.path()).unwrap();
2028 std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
2029 std::fs::write(
2030 canonical.join("scripts/process-messages/index.js"),
2031 "export const index = true;",
2032 )
2033 .unwrap();
2034
2035 for entry in [
2036 "scripts/*",
2037 "https://example.com/scripts/process-messages",
2038 "@scope/package/scripts/process-messages",
2039 ] {
2040 let result = resolve_entry_path(
2041 &canonical,
2042 entry,
2043 &canonical,
2044 EntryPointSource::PackageJsonScript,
2045 );
2046 assert!(result.is_none(), "{entry} should not resolve");
2047 }
2048 }
2049
2050 #[test]
2051 fn returns_none_for_nonexistent_file() {
2052 let dir = tempfile::tempdir().expect("create temp dir");
2053 let canonical = dunce::canonicalize(dir.path()).unwrap();
2054 let result = resolve_entry_path(
2055 dir.path(),
2056 "does/not/exist.ts",
2057 &canonical,
2058 EntryPointSource::PackageJsonMain,
2059 );
2060 assert!(result.is_none(), "should return None for nonexistent files");
2061 }
2062
2063 #[test]
2064 fn maps_dist_output_to_src() {
2065 let dir = tempfile::tempdir().expect("create temp dir");
2066 let src = dir.path().join("src");
2067 std::fs::create_dir_all(&src).unwrap();
2068 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2069
2070 let dist = dir.path().join("dist");
2071 std::fs::create_dir_all(&dist).unwrap();
2072 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
2073
2074 let canonical = dunce::canonicalize(dir.path()).unwrap();
2075 let result = resolve_entry_path(
2076 dir.path(),
2077 "./dist/utils.js",
2078 &canonical,
2079 EntryPointSource::PackageJsonExports,
2080 );
2081 assert!(result.is_some(), "should resolve dist/ path to src/");
2082 let ep = result.unwrap();
2083 assert!(
2084 ep.path
2085 .to_string_lossy()
2086 .replace('\\', "/")
2087 .contains("src/utils.ts"),
2088 "should map ./dist/utils.js to src/utils.ts"
2089 );
2090 }
2091
2092 #[test]
2093 fn maps_build_output_to_src() {
2094 let dir = tempfile::tempdir().expect("create temp dir");
2095 let canonical = dunce::canonicalize(dir.path()).unwrap();
2096 let src = canonical.join("src");
2097 std::fs::create_dir_all(&src).unwrap();
2098 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
2099
2100 let result = resolve_entry_path(
2101 &canonical,
2102 "./build/index.js",
2103 &canonical,
2104 EntryPointSource::PackageJsonExports,
2105 );
2106 assert!(result.is_some(), "should map build/ output to src/");
2107 let ep = result.unwrap();
2108 assert!(
2109 ep.path
2110 .to_string_lossy()
2111 .replace('\\', "/")
2112 .contains("src/index.tsx"),
2113 "should map ./build/index.js to src/index.tsx"
2114 );
2115 }
2116
2117 #[test]
2118 fn preserves_entry_point_source() {
2119 let dir = tempfile::tempdir().expect("create temp dir");
2120 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2121
2122 let canonical = dunce::canonicalize(dir.path()).unwrap();
2123 let result = resolve_entry_path(
2124 dir.path(),
2125 "index.ts",
2126 &canonical,
2127 EntryPointSource::PackageJsonScript,
2128 );
2129 assert!(result.is_some());
2130 assert!(
2131 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
2132 "should preserve the source kind"
2133 );
2134 }
2135 #[test]
2136 fn tracks_skipped_entries_without_logging_each_repeat() {
2137 let dir = tempfile::tempdir().expect("create temp dir");
2138 let canonical = dunce::canonicalize(dir.path()).unwrap();
2139 let mut skipped_entries = FxHashMap::default();
2140
2141 let result = resolve_entry_path_with_tracking(
2142 dir.path(),
2143 "../scripts/build.js",
2144 &canonical,
2145 EntryPointSource::PackageJsonScript,
2146 Some(&mut skipped_entries),
2147 );
2148
2149 assert!(result.is_none(), "unsafe entry should be skipped");
2150 assert_eq!(
2151 skipped_entries.get("../scripts/build.js"),
2152 Some(&1),
2153 "warning tracker should count the skipped path"
2154 );
2155 }
2156
2157 #[test]
2158 fn formats_skipped_entry_warning_with_counts() {
2159 let mut skipped_entries = FxHashMap::default();
2160 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
2161 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
2162
2163 let warning =
2164 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
2165
2166 assert_eq!(
2167 warning,
2168 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
2169 );
2170 }
2171
2172 #[test]
2173 fn skipped_entry_summary_dedupes_identical_messages() {
2174 let message = format!(
2175 "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
2176 std::process::id()
2177 );
2178 assert!(
2179 should_warn_skipped_entry(&message),
2180 "first occurrence of a message emits"
2181 );
2182 assert!(
2183 !should_warn_skipped_entry(&message),
2184 "identical repeat is suppressed"
2185 );
2186 }
2187
2188 #[test]
2189 fn rejects_parent_dir_escape_for_exact_file() {
2190 let sandbox = tempfile::tempdir().expect("create sandbox");
2191 let root = sandbox.path().join("project");
2192 std::fs::create_dir_all(&root).unwrap();
2193 std::fs::write(
2194 sandbox.path().join("escape.ts"),
2195 "export const escape = true;",
2196 )
2197 .unwrap();
2198
2199 let canonical = dunce::canonicalize(&root).unwrap();
2200 let result = resolve_entry_path(
2201 &root,
2202 "../escape.ts",
2203 &canonical,
2204 EntryPointSource::PackageJsonMain,
2205 );
2206
2207 assert!(
2208 result.is_none(),
2209 "should reject exact paths that escape the root"
2210 );
2211 }
2212
2213 #[test]
2214 fn rejects_parent_dir_escape_via_extension_fallback() {
2215 let sandbox = tempfile::tempdir().expect("create sandbox");
2216 let root = sandbox.path().join("project");
2217 std::fs::create_dir_all(&root).unwrap();
2218 std::fs::write(
2219 sandbox.path().join("escape.ts"),
2220 "export const escape = true;",
2221 )
2222 .unwrap();
2223
2224 let canonical = dunce::canonicalize(&root).unwrap();
2225 let result = resolve_entry_path(
2226 &root,
2227 "../escape",
2228 &canonical,
2229 EntryPointSource::PackageJsonMain,
2230 );
2231
2232 assert!(
2233 result.is_none(),
2234 "should reject extension fallback paths that escape the root"
2235 );
2236 }
2237 }
2238
2239 mod output_to_source_tests {
2240 use super::*;
2241
2242 #[test]
2243 fn maps_dist_to_src_with_ts_extension() {
2244 let dir = tempfile::tempdir().expect("create temp dir");
2245 let src = dir.path().join("src");
2246 std::fs::create_dir_all(&src).unwrap();
2247 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2248
2249 let result = try_legacy_output_to_source_path(dir.path(), "./dist/utils.js");
2250 assert!(result.is_some());
2251 assert!(
2252 result
2253 .unwrap()
2254 .to_string_lossy()
2255 .replace('\\', "/")
2256 .contains("src/utils.ts")
2257 );
2258 }
2259
2260 #[test]
2261 fn returns_none_when_no_source_file_exists() {
2262 let dir = tempfile::tempdir().expect("create temp dir");
2263 let result = try_legacy_output_to_source_path(dir.path(), "./dist/missing.js");
2264 assert!(result.is_none());
2265 }
2266
2267 #[test]
2268 fn ignores_non_output_directories() {
2269 let dir = tempfile::tempdir().expect("create temp dir");
2270 let src = dir.path().join("src");
2271 std::fs::create_dir_all(&src).unwrap();
2272 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2273
2274 let result = try_legacy_output_to_source_path(dir.path(), "./lib/foo.js");
2275 assert!(result.is_none());
2276 }
2277
2278 #[test]
2279 fn maps_nested_output_path_preserving_prefix() {
2280 let dir = tempfile::tempdir().expect("create temp dir");
2281 let modules_src = dir.path().join("modules").join("src");
2282 std::fs::create_dir_all(&modules_src).unwrap();
2283 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
2284
2285 let result = try_legacy_output_to_source_path(dir.path(), "./modules/dist/helper.js");
2286 assert!(result.is_some());
2287 assert!(
2288 result
2289 .unwrap()
2290 .to_string_lossy()
2291 .replace('\\', "/")
2292 .contains("modules/src/helper.ts")
2293 );
2294 }
2295 }
2296
2297 mod source_index_fallback_tests {
2298 use super::*;
2299
2300 #[test]
2301 fn detects_dist_entry_in_output_dir() {
2302 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
2303 assert!(is_entry_in_output_dir("dist/index.js"));
2304 assert!(is_entry_in_output_dir("./build/index.js"));
2305 assert!(is_entry_in_output_dir("./out/main.js"));
2306 assert!(is_entry_in_output_dir("./esm/index.js"));
2307 assert!(is_entry_in_output_dir("./cjs/index.js"));
2308 }
2309
2310 #[test]
2311 fn rejects_non_output_entry_paths() {
2312 assert!(!is_entry_in_output_dir("./src/index.ts"));
2313 assert!(!is_entry_in_output_dir("src/main.ts"));
2314 assert!(!is_entry_in_output_dir("./index.js"));
2315 assert!(!is_entry_in_output_dir(""));
2316 }
2317
2318 #[test]
2319 fn root_index_entries_are_recognized_for_source_fallback() {
2320 assert!(is_package_root_index_entry("./index.js"));
2321 assert!(is_package_root_index_entry("index.cjs"));
2322 assert!(is_package_root_index_entry("./index.d.ts"));
2323 assert!(!is_package_root_index_entry("./src/index.js"));
2324 assert!(!is_package_root_index_entry("./main.js"));
2325 assert!(!is_package_root_index_entry(""));
2326 }
2327
2328 #[test]
2329 fn rejects_substring_match_for_output_dir() {
2330 assert!(!is_entry_in_output_dir("./distro/index.js"));
2331 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
2332 }
2333
2334 #[test]
2335 fn finds_src_index_ts() {
2336 let dir = tempfile::tempdir().expect("create temp dir");
2337 let src = dir.path().join("src");
2338 std::fs::create_dir_all(&src).unwrap();
2339 let index_path = src.join("index.ts");
2340 std::fs::write(&index_path, "export const a = 1;").unwrap();
2341
2342 let result = try_source_index_fallback(dir.path());
2343 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2344 }
2345
2346 #[test]
2347 fn finds_src_index_tsx_when_ts_missing() {
2348 let dir = tempfile::tempdir().expect("create temp dir");
2349 let src = dir.path().join("src");
2350 std::fs::create_dir_all(&src).unwrap();
2351 let index_path = src.join("index.tsx");
2352 std::fs::write(&index_path, "export default 1;").unwrap();
2353
2354 let result = try_source_index_fallback(dir.path());
2355 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2356 }
2357
2358 #[test]
2359 fn prefers_src_index_over_root_index() {
2360 let dir = tempfile::tempdir().expect("create temp dir");
2361 let src = dir.path().join("src");
2362 std::fs::create_dir_all(&src).unwrap();
2363 let src_index = src.join("index.ts");
2364 std::fs::write(&src_index, "export const a = 1;").unwrap();
2365 let root_index = dir.path().join("index.ts");
2366 std::fs::write(&root_index, "export const b = 2;").unwrap();
2367
2368 let result = try_source_index_fallback(dir.path());
2369 assert_eq!(result.as_deref(), Some(src_index.as_path()));
2370 }
2371
2372 #[test]
2373 fn falls_back_to_src_main() {
2374 let dir = tempfile::tempdir().expect("create temp dir");
2375 let src = dir.path().join("src");
2376 std::fs::create_dir_all(&src).unwrap();
2377 let main_path = src.join("main.ts");
2378 std::fs::write(&main_path, "export const a = 1;").unwrap();
2379
2380 let result = try_source_index_fallback(dir.path());
2381 assert_eq!(result.as_deref(), Some(main_path.as_path()));
2382 }
2383
2384 #[test]
2385 fn falls_back_to_root_index_when_no_src() {
2386 let dir = tempfile::tempdir().expect("create temp dir");
2387 let index_path = dir.path().join("index.js");
2388 std::fs::write(&index_path, "module.exports = {};").unwrap();
2389
2390 let result = try_source_index_fallback(dir.path());
2391 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2392 }
2393
2394 #[test]
2395 fn returns_none_when_nothing_matches() {
2396 let dir = tempfile::tempdir().expect("create temp dir");
2397 let result = try_source_index_fallback(dir.path());
2398 assert!(result.is_none());
2399 }
2400
2401 #[test]
2402 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
2403 let dir = tempfile::tempdir().expect("create temp dir");
2404 let canonical = dunce::canonicalize(dir.path()).unwrap();
2405
2406 let dist_dir = canonical.join("dist").join("esm2022");
2407 std::fs::create_dir_all(&dist_dir).unwrap();
2408 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
2409
2410 let src = canonical.join("src");
2411 std::fs::create_dir_all(&src).unwrap();
2412 let src_index = src.join("index.ts");
2413 std::fs::write(&src_index, "export const x = 1;").unwrap();
2414
2415 let result = resolve_entry_path(
2416 &canonical,
2417 "./dist/esm2022/index.js",
2418 &canonical,
2419 EntryPointSource::PackageJsonMain,
2420 );
2421 assert!(result.is_some());
2422 let entry = result.unwrap();
2423 assert_eq!(entry.path, src_index);
2424 }
2425
2426 #[test]
2427 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2428 let dir = tempfile::tempdir().expect("create temp dir");
2429 let canonical = dunce::canonicalize(dir.path()).unwrap();
2430
2431 let src_mirror = canonical.join("src").join("esm2022");
2432 std::fs::create_dir_all(&src_mirror).unwrap();
2433 let mirror_index = src_mirror.join("index.ts");
2434 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2435
2436 let src_index = canonical.join("src").join("index.ts");
2437 std::fs::write(&src_index, "export const y = 2;").unwrap();
2438
2439 let result = resolve_entry_path(
2440 &canonical,
2441 "./dist/esm2022/index.js",
2442 &canonical,
2443 EntryPointSource::PackageJsonMain,
2444 );
2445 assert_eq!(result.map(|e| e.path), Some(mirror_index));
2446 }
2447
2448 #[test]
2449 fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2450 let dir = tempfile::tempdir().expect("create temp dir");
2451 let canonical = dunce::canonicalize(dir.path()).unwrap();
2452
2453 let src = canonical.join("src");
2454 std::fs::create_dir_all(&src).unwrap();
2455 let src_index = src.join("index.ts");
2456 std::fs::write(&src_index, "export const x = 1;").unwrap();
2457
2458 let result = resolve_entry_path(
2459 &canonical,
2460 "./index.js",
2461 &canonical,
2462 EntryPointSource::PackageJsonMain,
2463 );
2464 assert_eq!(result.map(|entry| entry.path), Some(src_index));
2465 }
2466 }
2467
2468 mod default_fallback_tests {
2469 use super::*;
2470
2471 #[test]
2472 fn finds_src_index_ts_as_fallback() {
2473 let dir = tempfile::tempdir().expect("create temp dir");
2474 let src = dir.path().join("src");
2475 std::fs::create_dir_all(&src).unwrap();
2476 let index_path = src.join("index.ts");
2477 std::fs::write(&index_path, "export const a = 1;").unwrap();
2478
2479 let files = vec![DiscoveredFile {
2480 id: FileId(0),
2481 path: index_path.clone(),
2482 size_bytes: 20,
2483 }];
2484
2485 let entries = apply_default_fallback(&files, dir.path(), None);
2486 assert_eq!(entries.len(), 1);
2487 assert_eq!(entries[0].path, index_path);
2488 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2489 }
2490
2491 #[test]
2492 fn finds_root_index_js_as_fallback() {
2493 let dir = tempfile::tempdir().expect("create temp dir");
2494 let index_path = dir.path().join("index.js");
2495 std::fs::write(&index_path, "module.exports = {};").unwrap();
2496
2497 let files = vec![DiscoveredFile {
2498 id: FileId(0),
2499 path: index_path.clone(),
2500 size_bytes: 21,
2501 }];
2502
2503 let entries = apply_default_fallback(&files, dir.path(), None);
2504 assert_eq!(entries.len(), 1);
2505 assert_eq!(entries[0].path, index_path);
2506 }
2507
2508 #[test]
2509 fn returns_empty_when_no_index_file() {
2510 let dir = tempfile::tempdir().expect("create temp dir");
2511 let other_path = dir.path().join("src").join("utils.ts");
2512
2513 let files = vec![DiscoveredFile {
2514 id: FileId(0),
2515 path: other_path,
2516 size_bytes: 10,
2517 }];
2518
2519 let entries = apply_default_fallback(&files, dir.path(), None);
2520 assert!(
2521 entries.is_empty(),
2522 "non-index files should not match default fallback"
2523 );
2524 }
2525
2526 #[test]
2527 fn workspace_filter_restricts_scope() {
2528 let dir = tempfile::tempdir().expect("create temp dir");
2529 let ws_a = dir.path().join("packages").join("a").join("src");
2530 std::fs::create_dir_all(&ws_a).unwrap();
2531 let ws_b = dir.path().join("packages").join("b").join("src");
2532 std::fs::create_dir_all(&ws_b).unwrap();
2533
2534 let index_a = ws_a.join("index.ts");
2535 let index_b = ws_b.join("index.ts");
2536
2537 let files = vec![
2538 DiscoveredFile {
2539 id: FileId(0),
2540 path: index_a.clone(),
2541 size_bytes: 10,
2542 },
2543 DiscoveredFile {
2544 id: FileId(1),
2545 path: index_b,
2546 size_bytes: 10,
2547 },
2548 ];
2549
2550 let ws_root = dir.path().join("packages").join("a");
2551 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2552 assert_eq!(entries.len(), 1);
2553 assert_eq!(entries[0].path, index_a);
2554 }
2555 }
2556
2557 mod wildcard_entry_tests {
2558 use super::*;
2559
2560 #[test]
2561 fn expands_wildcard_css_entries() {
2562 let dir = tempfile::tempdir().expect("create temp dir");
2563 let themes = dir.path().join("src").join("themes");
2564 std::fs::create_dir_all(&themes).unwrap();
2565 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2566 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2567
2568 let canonical = dunce::canonicalize(dir.path()).unwrap();
2569 let mut entries = Vec::new();
2570 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2571
2572 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2573 let paths: Vec<String> = entries
2574 .iter()
2575 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2576 .collect();
2577 assert!(paths.contains(&"dark.css".to_string()));
2578 assert!(paths.contains(&"light.css".to_string()));
2579 assert!(
2580 entries
2581 .iter()
2582 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2583 );
2584 }
2585
2586 #[test]
2587 fn wildcard_does_not_match_nonexistent_files() {
2588 let dir = tempfile::tempdir().expect("create temp dir");
2589 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2590
2591 let canonical = dunce::canonicalize(dir.path()).unwrap();
2592 let mut entries = Vec::new();
2593 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2594
2595 assert!(
2596 entries.is_empty(),
2597 "should return empty when no files match the wildcard"
2598 );
2599 }
2600
2601 #[test]
2602 fn wildcard_only_matches_specified_extension() {
2603 let dir = tempfile::tempdir().expect("create temp dir");
2604 let themes = dir.path().join("src").join("themes");
2605 std::fs::create_dir_all(&themes).unwrap();
2606 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2607 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2608
2609 let canonical = dunce::canonicalize(dir.path()).unwrap();
2610 let mut entries = Vec::new();
2611 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2612
2613 assert_eq!(entries.len(), 1, "should only match CSS files");
2614 assert!(
2615 entries[0]
2616 .path
2617 .file_name()
2618 .unwrap()
2619 .to_string_lossy()
2620 .ends_with(".css")
2621 );
2622 }
2623 }
2624
2625 fn config_for(root: &Path) -> ResolvedConfig {
2626 FallowConfig::default().resolve(
2627 root.to_path_buf(),
2628 OutputFormat::Human,
2629 1,
2630 true,
2631 true,
2632 None,
2633 )
2634 }
2635
2636 fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
2637 DiscoveredFile {
2638 id: FileId(id),
2639 path: root.join(rel),
2640 size_bytes: 1,
2641 }
2642 }
2643
2644 fn discover_entry_points_with_warnings(
2646 config: &ResolvedConfig,
2647 files: &[DiscoveredFile],
2648 ) -> EntryPointDiscovery {
2649 let root_pkg = fallow_config::load_dir_package_json(&config.root);
2650 let packages = collect_workspace_packages(&config.root, root_pkg.as_ref(), &[]);
2651 let runtime_seeds = workspace_runtime_script_seeds(&packages);
2652 discover_root_entry_points(
2653 config,
2654 files,
2655 root_pkg.as_ref(),
2656 true,
2657 ScriptWorkspaces {
2658 packages: &packages,
2659 project_root: &config.root,
2660 runtime_seeds: &runtime_seeds,
2661 },
2662 )
2663 }
2664
2665 fn discover_workspace_entry_points_with_warnings(
2667 ws_root: &Path,
2668 config: &ResolvedConfig,
2669 files: &[DiscoveredFile],
2670 ) -> EntryPointDiscovery {
2671 let pkg = fallow_config::load_dir_package_json(ws_root);
2672 let packages = std::sync::Arc::default();
2673 discover_workspace_package_entry_points(
2674 ws_root,
2675 files,
2676 pkg.as_ref(),
2677 IgnoredCommandEntries::new(&config.ignore_command_entries),
2678 ScriptWorkspaces {
2679 packages: &packages,
2680 project_root: &config.root,
2681 runtime_seeds: &RuntimeScriptSeeds::default(),
2682 },
2683 )
2684 }
2685
2686 fn workspace(root: &Path, dir: &str, name: &str) -> fallow_config::WorkspaceInfo {
2687 fallow_config::WorkspaceInfo {
2688 root: root.join(dir),
2689 name: name.to_string(),
2690 is_internal_dependency: false,
2691 }
2692 }
2693
2694 #[test]
2695 fn root_entry_points_use_package_json_scripts() {
2696 let dir = tempfile::tempdir().expect("tempdir");
2697 let root = dir.path();
2698 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2699 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2700 .expect("script file");
2701 std::fs::write(
2702 root.join("package.json"),
2703 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2704 )
2705 .expect("package file");
2706 let config = config_for(root);
2707 let files = [discovered(root, "scripts/build.ts", 0)];
2708
2709 let entries = discover_entry_points(&config, &files, &[]);
2710
2711 assert_eq!(entries.len(), 1);
2712 assert!(entries[0].path.ends_with("scripts/build.ts"));
2713 assert!(matches!(
2714 entries[0].source,
2715 EntryPointSource::PackageJsonScript
2716 ));
2717 }
2718
2719 #[test]
2720 fn build_script_is_support_while_default_index_stays_runtime() {
2721 let dir = tempfile::tempdir().expect("tempdir");
2722 let root = dir.path();
2723 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2724 std::fs::create_dir_all(root.join("src")).expect("src dir");
2725 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2726 .expect("script file");
2727 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2728 std::fs::write(
2729 root.join("package.json"),
2730 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2731 )
2732 .expect("package file");
2733 let config = config_for(root);
2734 let files = [
2735 discovered(root, "scripts/build.ts", 0),
2736 discovered(root, "src/index.ts", 1),
2737 ];
2738
2739 let discovery = discover_entry_points_with_warnings(&config, &files);
2740
2741 assert_eq!(discovery.entries.len(), 1);
2742 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2743 assert_eq!(discovery.support_entries.len(), 1);
2744 assert!(
2745 discovery.support_entries[0]
2746 .path
2747 .ends_with("scripts/build.ts")
2748 );
2749 }
2750
2751 #[test]
2752 fn indirect_quoted_start_script_stays_runtime() {
2753 let dir = tempfile::tempdir().expect("tempdir");
2754 let root = dir.path();
2755 std::fs::create_dir_all(root.join("src")).expect("src dir");
2756 std::fs::write(root.join("src/server.ts"), "export const server = true;")
2757 .expect("server file");
2758 std::fs::write(
2759 root.join("package.json"),
2760 r#"{"scripts":{"start":"npm run serve","serve":"node 'src/server.ts'"}}"#,
2761 )
2762 .expect("package file");
2763 let config = config_for(root);
2764 let files = [discovered(root, "src/server.ts", 0)];
2765
2766 let discovery = discover_entry_points_with_warnings(&config, &files);
2767
2768 assert_eq!(discovery.entries.len(), 1);
2769 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2770 assert!(discovery.support_entries.is_empty());
2771 }
2772
2773 #[test]
2774 fn indirect_start_script_includes_its_lifecycle_hooks() {
2775 let dir = tempfile::tempdir().expect("tempdir");
2776 let root = dir.path();
2777 std::fs::create_dir_all(root.join("src")).expect("src dir");
2778 for file in ["pre.ts", "server.ts", "post.ts"] {
2779 std::fs::write(root.join("src").join(file), "export const value = true;")
2780 .expect("runtime file");
2781 }
2782 std::fs::write(
2783 root.join("package.json"),
2784 r#"{"scripts":{"start":"npm run serve","preserve":"node src/pre.ts","serve":"node src/server.ts","postserve":"node src/post.ts"}}"#,
2785 )
2786 .expect("package file");
2787 let config = config_for(root);
2788 let files = [
2789 discovered(root, "src/pre.ts", 0),
2790 discovered(root, "src/server.ts", 1),
2791 discovered(root, "src/post.ts", 2),
2792 ];
2793
2794 let discovery = discover_entry_points_with_warnings(&config, &files);
2795
2796 assert_eq!(discovery.entries.len(), 3);
2797 assert!(discovery.support_entries.is_empty());
2798 }
2799
2800 #[test]
2801 #[expect(
2802 clippy::disallowed_types,
2803 reason = "test input matches the serde-deserialized package.json scripts map"
2804 )]
2805 fn start_does_not_recursively_invent_hooks_for_lifecycle_hooks() {
2806 let scripts = std::collections::HashMap::from([
2807 ("preprestart".to_string(), "node src/tool.ts".to_string()),
2808 ("prestart".to_string(), "node src/pre.ts".to_string()),
2809 ("start".to_string(), "node src/server.ts".to_string()),
2810 ("poststart".to_string(), "node src/post.ts".to_string()),
2811 ("postpoststart".to_string(), "node src/tool.ts".to_string()),
2812 ]);
2813
2814 let runtime = runtime_package_script_names(&scripts);
2815
2816 assert_eq!(
2817 runtime,
2818 FxHashSet::from_iter([
2819 "prestart".to_string(),
2820 "start".to_string(),
2821 "poststart".to_string(),
2822 ])
2823 );
2824 }
2825
2826 #[test]
2827 fn bun_build_script_remains_support_entry() {
2828 let dir = tempfile::tempdir().expect("tempdir");
2829 let root = dir.path();
2830 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2831 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2832 .expect("build file");
2833 std::fs::write(
2834 root.join("package.json"),
2835 r#"{"scripts":{"build":"bun scripts/build.ts"}}"#,
2836 )
2837 .expect("package file");
2838 let config = config_for(root);
2839 let files = [discovered(root, "scripts/build.ts", 0)];
2840
2841 let discovery = discover_entry_points_with_warnings(&config, &files);
2842
2843 assert!(discovery.entries.is_empty());
2844 assert_eq!(discovery.support_entries.len(), 1);
2845 }
2846
2847 #[test]
2848 fn start_script_config_remains_support_only() {
2849 let dir = tempfile::tempdir().expect("tempdir");
2850 let root = dir.path();
2851 std::fs::write(root.join("vite.config.ts"), "export default {};").expect("config file");
2852 std::fs::write(
2853 root.join("package.json"),
2854 r#"{"scripts":{"start":"vite --config vite.config.ts"}}"#,
2855 )
2856 .expect("package file");
2857 let config = config_for(root);
2858 let files = [discovered(root, "vite.config.ts", 0)];
2859
2860 let discovery = discover_entry_points_with_warnings(&config, &files);
2861
2862 assert!(discovery.entries.is_empty());
2863 assert_eq!(discovery.support_entries.len(), 1);
2864 assert!(
2865 discovery.support_entries[0]
2866 .path
2867 .ends_with("vite.config.ts")
2868 );
2869 }
2870
2871 #[test]
2872 fn manifest_entry_remains_runtime_when_build_script_references_same_path() {
2873 let dir = tempfile::tempdir().expect("tempdir");
2874 let root = dir.path();
2875 std::fs::create_dir_all(root.join("src")).expect("src dir");
2876 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2877 std::fs::write(
2878 root.join("package.json"),
2879 r#"{"main":"src/index.ts","scripts":{"build":"tsx src/index.ts"}}"#,
2880 )
2881 .expect("package file");
2882 let config = config_for(root);
2883 let files = [discovered(root, "src/index.ts", 0)];
2884
2885 let discovery = discover_entry_points_with_warnings(&config, &files);
2886
2887 assert_eq!(discovery.entries.len(), 1);
2888 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2889 }
2890
2891 #[test]
2892 fn workspace_build_script_is_support_with_runtime_fallback() {
2893 let dir = tempfile::tempdir().expect("tempdir");
2894 let root = dir.path();
2895 let workspace = root.join("packages/app");
2896 std::fs::create_dir_all(workspace.join("scripts")).expect("scripts dir");
2897 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2898 std::fs::write(
2899 workspace.join("scripts/build.ts"),
2900 "export const build = true;",
2901 )
2902 .expect("script file");
2903 std::fs::write(workspace.join("src/index.ts"), "export const app = true;")
2904 .expect("index file");
2905 std::fs::write(
2906 workspace.join("package.json"),
2907 r#"{"name":"app","scripts":{"build":"tsx scripts/build.ts"}}"#,
2908 )
2909 .expect("package file");
2910 let files = [
2911 discovered(root, "packages/app/scripts/build.ts", 0),
2912 discovered(root, "packages/app/src/index.ts", 1),
2913 ];
2914
2915 let discovery =
2916 discover_workspace_entry_points_with_warnings(&workspace, &config_for(root), &files);
2917
2918 assert_eq!(discovery.entries.len(), 1);
2919 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2920 assert_eq!(discovery.support_entries.len(), 1);
2921 assert!(
2922 discovery.support_entries[0]
2923 .path
2924 .ends_with("scripts/build.ts")
2925 );
2926 }
2927
2928 #[test]
2929 fn workspace_script_selected_by_root_start_is_runtime() {
2930 let dir = tempfile::tempdir().expect("tempdir");
2931 let root = dir.path();
2932 let workspace = root.join("packages/api");
2933 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2934 std::fs::write(workspace.join("src/server.ts"), "export const app = true;")
2935 .expect("server file");
2936 let workspace_pkg: PackageJson = serde_json::from_str(
2937 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2938 )
2939 .expect("workspace package");
2940 let files = [discovered(root, "packages/api/src/server.ts", 0)];
2941 let runtime_seeds = RuntimeScriptSeeds::from_iter([(
2942 "packages/api".to_string(),
2943 FxHashSet::from_iter(["serve".to_string()]),
2944 )]);
2945
2946 let packages = std::sync::Arc::default();
2947 let discovery = discover_workspace_package_entry_points(
2948 &workspace,
2949 &files,
2950 Some(&workspace_pkg),
2951 IgnoredCommandEntries::NONE,
2952 ScriptWorkspaces {
2953 packages: &packages,
2954 project_root: root,
2955 runtime_seeds: &runtime_seeds,
2956 },
2957 );
2958
2959 assert_eq!(discovery.entries.len(), 1);
2960 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2961 assert!(discovery.support_entries.is_empty());
2962 }
2963
2964 #[test]
2965 fn scripts_that_select_workspace_packages_add_entries_in_each_pass() {
2966 let dir = tempfile::tempdir().expect("tempdir");
2967 let root = dir.path();
2968 for file in [
2969 "packages/web/scripts/root-start.ts",
2970 "packages/web/scripts/api-start.ts",
2971 "packages/web/scripts/gen.ts",
2972 ] {
2973 let path = root.join(file);
2974 std::fs::create_dir_all(path.parent().expect("parent")).expect("dir");
2975 std::fs::write(path, "export const run = true;").expect("script file");
2976 }
2977 let root_pkg: PackageJson = serde_json::from_str(
2978 r#"{"scripts":{"start":"yarn workspace web node scripts/root-start.ts"}}"#,
2979 )
2980 .expect("root package");
2981 let api_pkg: PackageJson = serde_json::from_str(
2982 r#"{
2983 "name": "api",
2984 "scripts": {
2985 "start": "yarn workspace web node scripts/api-start.ts",
2986 "gen": "pnpm -r exec tsx scripts/gen.ts"
2987 }
2988 }"#,
2989 )
2990 .expect("api package");
2991 let mut packages = crate::scripts::WorkspacePackages::default();
2992 packages.add("web", "packages/web", None);
2993 packages.add("api", "packages/api", api_pkg.scripts.as_ref());
2994 let packages = std::sync::Arc::new(packages);
2995 let runtime_seeds = RuntimeScriptSeeds::default();
2996 let workspaces = ScriptWorkspaces {
2997 packages: &packages,
2998 project_root: root,
2999 runtime_seeds: &runtime_seeds,
3000 };
3001 let config = config_for(root);
3002
3003 let root_discovery =
3004 discover_root_entry_points(&config, &[], Some(&root_pkg), false, workspaces);
3005 assert!(
3006 root_discovery
3007 .entries
3008 .iter()
3009 .any(|entry| entry.path.ends_with("packages/web/scripts/root-start.ts")),
3010 "a root start script that selects a package adds a runtime entry: {:?}",
3011 root_discovery.entries
3012 );
3013
3014 let api_discovery = discover_workspace_package_entry_points(
3015 &root.join("packages/api"),
3016 &[],
3017 Some(&api_pkg),
3018 IgnoredCommandEntries::NONE,
3019 workspaces,
3020 );
3021 assert!(
3022 api_discovery
3023 .entries
3024 .iter()
3025 .any(|entry| entry.path.ends_with("packages/web/scripts/api-start.ts")),
3026 "a package start script that selects another package adds a runtime entry: {:?}",
3027 api_discovery.entries
3028 );
3029 assert!(
3030 api_discovery
3031 .support_entries
3032 .iter()
3033 .any(|entry| entry.path.ends_with("packages/web/scripts/gen.ts")),
3034 "`pnpm -r exec` adds the file of each package where it exists: {:?}",
3035 api_discovery.support_entries
3036 );
3037 assert!(
3038 api_discovery.skipped_entries.is_empty(),
3039 "a file outside the calling package resolves from the project root: {:?}",
3040 api_discovery.skipped_entries
3041 );
3042 }
3043
3044 #[test]
3045 fn workspace_script_selection_does_not_promote_same_named_root_script() {
3046 let dir = tempfile::tempdir().expect("tempdir");
3047 let root = dir.path();
3048 let root_pkg: PackageJson = serde_json::from_str(
3049 r#"{
3050 "scripts": {
3051 "start": "pnpm --filter=@scope/api run serve",
3052 "serve": "node src/unrelated.ts"
3053 }
3054 }"#,
3055 )
3056 .expect("root package");
3057 let api_pkg: PackageJson = serde_json::from_str(
3058 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
3059 )
3060 .expect("api package");
3061 let workspace_pkgs = vec![(workspace(root, "packages/api", "@scope/api"), api_pkg)];
3062 let scripts = root_pkg.scripts.as_ref().expect("root scripts");
3063
3064 let runtime = runtime_package_script_names(scripts);
3065 let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3066 let workspace_seeds = workspace_runtime_script_seeds(&packages);
3067
3068 assert_eq!(runtime, FxHashSet::from_iter(["start".to_string()]));
3069 assert_eq!(
3070 workspace_seeds,
3071 RuntimeScriptSeeds::from_iter([(
3072 "packages/api".to_string(),
3073 FxHashSet::from_iter(["serve".to_string()])
3074 )])
3075 );
3076 }
3077
3078 #[test]
3079 fn workspace_runtime_script_seeds_follow_transitive_workspace_calls() {
3080 let dir = tempfile::tempdir().expect("tempdir");
3081 let root = dir.path();
3082 let root_pkg: PackageJson =
3083 serde_json::from_str(r#"{"scripts":{"start":"pnpm --filter @scope/api run serve"}}"#)
3084 .expect("root package");
3085 let api_pkg: PackageJson = serde_json::from_str(
3086 r#"{"name":"@scope/api","scripts":{"serve":"npm --workspace ../worker run work"}}"#,
3087 )
3088 .expect("api package");
3089 let worker_pkg: PackageJson = serde_json::from_str(
3090 r#"{"name":"@scope/worker","scripts":{"prework":"node pre.ts","work":"node worker.ts"}}"#,
3091 )
3092 .expect("worker package");
3093 let workspace_pkgs = vec![
3094 (workspace(root, "packages/api", "@scope/api"), api_pkg),
3095 (
3096 workspace(root, "packages/worker", "@scope/worker"),
3097 worker_pkg,
3098 ),
3099 ];
3100
3101 let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3102 let seeds = workspace_runtime_script_seeds(&packages);
3103
3104 assert_eq!(
3105 seeds.get("packages/api"),
3106 Some(&FxHashSet::from_iter(["serve".to_string()]))
3107 );
3108 assert_eq!(
3109 seeds.get("packages/worker"),
3110 Some(&FxHashSet::from_iter([
3111 "prework".to_string(),
3112 "work".to_string(),
3113 ]))
3114 );
3115 }
3116
3117 #[test]
3118 fn every_selection_form_seeds_the_runtime_scripts_of_the_selected_packages() {
3119 let dir = tempfile::tempdir().expect("tempdir");
3120 let root = dir.path();
3121 let web_pkg: PackageJson = serde_json::from_str(
3122 r#"{"name":"web","scripts":{"preserve":"node pre.ts","serve":"node web.ts"}}"#,
3123 )
3124 .expect("web package");
3125 let api_pkg: PackageJson =
3126 serde_json::from_str(r#"{"name":"api","scripts":{"serve":"node api.ts"}}"#)
3127 .expect("api package");
3128 let workspace_pkgs = vec![
3129 (workspace(root, "packages/web", "web"), web_pkg),
3130 (workspace(root, "packages/api", "api"), api_pkg),
3131 ];
3132 let web_serve = FxHashSet::from_iter(["preserve".to_string(), "serve".to_string()]);
3133 let api_serve = FxHashSet::from_iter(["serve".to_string()]);
3134 for (start, expected) in [
3135 (
3136 "pnpm -r run serve",
3137 vec![("packages/api", &api_serve), ("packages/web", &web_serve)],
3138 ),
3139 (
3140 "pnpm -C packages/web run serve",
3141 vec![("packages/web", &web_serve)],
3142 ),
3143 (
3144 "pnpm --dir=packages/web serve",
3145 vec![("packages/web", &web_serve)],
3146 ),
3147 (
3148 "npm --prefix packages/web run serve",
3149 vec![("packages/web", &web_serve)],
3150 ),
3151 (
3152 "yarn --cwd packages/web serve",
3153 vec![("packages/web", &web_serve)],
3154 ),
3155 (
3156 "yarn workspaces foreach -A run serve",
3157 vec![("packages/api", &api_serve), ("packages/web", &web_serve)],
3158 ),
3159 (
3160 "pnpm --filter './packages/*' --filter '!api' run serve",
3161 vec![("packages/web", &web_serve)],
3162 ),
3163 ] {
3164 let root_pkg: PackageJson =
3165 serde_json::from_str(&format!(r#"{{"scripts":{{"start":"{start}"}}}}"#))
3166 .expect("root package");
3167 let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3168 let seeds = workspace_runtime_script_seeds(&packages);
3169 let expected: RuntimeScriptSeeds = expected
3170 .into_iter()
3171 .map(|(dir, names)| (dir.to_string(), names.clone()))
3172 .collect();
3173 assert_eq!(seeds, expected, "`{start}`");
3174 }
3175 }
3176
3177 #[test]
3178 fn a_workspace_start_script_seeds_a_script_of_the_root_package() {
3179 let dir = tempfile::tempdir().expect("tempdir");
3180 let root = dir.path();
3181 let root_pkg: PackageJson =
3182 serde_json::from_str(r#"{"name":"monorepo","scripts":{"serve":"node server.ts"}}"#)
3183 .expect("root package");
3184 let web_pkg: PackageJson =
3185 serde_json::from_str(r#"{"name":"web","scripts":{"start":"pnpm -w run serve"}}"#)
3186 .expect("web package");
3187 let workspace_pkgs = vec![(workspace(root, "packages/web", "web"), web_pkg)];
3188
3189 let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3190 let seeds = workspace_runtime_script_seeds(&packages);
3191
3192 assert_eq!(
3193 seeds,
3194 RuntimeScriptSeeds::from_iter([(
3195 String::new(),
3196 FxHashSet::from_iter(["serve".to_string()])
3197 )])
3198 );
3199 }
3200
3201 #[test]
3202 fn root_entry_points_fall_back_to_default_index() {
3203 let dir = tempfile::tempdir().expect("tempdir");
3204 let root = dir.path();
3205 std::fs::create_dir_all(root.join("src")).expect("src dir");
3206 std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
3207 let config = config_for(root);
3208 let files = [discovered(root, "src/index.ts", 0)];
3209
3210 let entries = discover_entry_points(&config, &files, &[]);
3211
3212 assert_eq!(entries.len(), 1);
3213 assert!(entries[0].path.ends_with("src/index.ts"));
3214 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
3215 }
3216
3217 #[test]
3218 fn default_index_matchers_preserve_patterns_and_are_shared() {
3219 let matchers = default_index_matchers();
3220 for path in [
3221 "src/index.ts",
3222 "src/index.tsx",
3223 "src/index.js",
3224 "src/index.jsx",
3225 "src/main.ts",
3226 "src/main.tsx",
3227 "src/main.js",
3228 "src/main.jsx",
3229 "index.ts",
3230 "index.tsx",
3231 "index.js",
3232 "index.jsx",
3233 "main.ts",
3234 "main.tsx",
3235 "main.js",
3236 "main.jsx",
3237 ] {
3238 assert!(
3239 matchers.is_match(path),
3240 "default index did not match {path}"
3241 );
3242 }
3243 for path in [
3244 "src/index.mts",
3245 "src/nested/index.ts",
3246 "packages/app/src/index.ts",
3247 "custom-entry.ts",
3248 ] {
3249 assert!(!matchers.is_match(path), "non-default index matched {path}");
3250 }
3251
3252 std::thread::scope(|scope| {
3253 let handles = std::iter::repeat_with(|| scope.spawn(default_index_matchers))
3254 .take(4)
3255 .collect::<Vec<_>>();
3256 for handle in handles {
3257 let from_thread = handle.join().expect("fallback matcher thread");
3258 assert!(std::ptr::eq(matchers, from_thread));
3259 }
3260 });
3261 }
3262
3263 #[test]
3264 fn workspace_entry_points_use_workspace_package_json() {
3265 let dir = tempfile::tempdir().expect("tempdir");
3266 let root = dir.path();
3267 let ws_root = root.join("packages/app");
3268 std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
3269 std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
3270 .expect("workspace main");
3271 std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
3272 .expect("workspace package file");
3273 let config = config_for(root);
3274 let files = [discovered(root, "packages/app/src/main.ts", 0)];
3275
3276 let entries =
3277 discover_entry_points(&config, &files, &[workspace(root, "packages/app", "app")]);
3278
3279 assert_eq!(entries.len(), 1);
3280 assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
3281 assert!(matches!(
3282 entries[0].source,
3283 EntryPointSource::PackageJsonMain
3284 ));
3285 }
3286}