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