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