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