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