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