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