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 let mut entries = CategorizedEntryPoints::default();
1160
1161 let relative_paths = relative_paths_for(files, &config.root);
1162 let (glob_set, glob_meta) = build_plugin_glob_meta(plugin_result);
1163 if let Some(glob_set) = glob_set.filter(|set| !set.is_empty()) {
1164 match_plugin_entry_files(&mut entries, &glob_set, &glob_meta, &relative_paths, files);
1165 }
1166
1167 push_plugin_setup_files(&mut entries, &config.root, plugin_result);
1168
1169 entries.dedup()
1170}
1171
1172fn build_plugin_glob_meta(
1174 plugin_result: &crate::plugins::AggregatedPluginResult,
1175) -> (Option<globset::GlobSet>, Vec<CompiledEntryRule<'_>>) {
1176 let mut builder = globset::GlobSetBuilder::new();
1177 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
1178 for (rule, pname) in &plugin_result.entry_patterns {
1179 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
1180 builder.add(include);
1181 glob_meta.push(compiled);
1182 }
1183 }
1184 for (pattern, pname) in plugin_result
1185 .discovered_always_used
1186 .iter()
1187 .chain(plugin_result.always_used.iter())
1188 .chain(plugin_result.fixture_patterns.iter())
1189 {
1190 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1191 .literal_separator(true)
1192 .build()
1193 {
1194 builder.add(glob);
1195 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
1196 &crate::plugins::PathRule::new(pattern.clone()),
1197 "support entry pattern",
1198 ) {
1199 glob_meta.push(CompiledEntryRule {
1200 path,
1201 plugin_name: pname,
1202 role: EntryPointRole::Support,
1203 });
1204 }
1205 }
1206 }
1207 (builder.build().ok(), glob_meta)
1208}
1209
1210fn match_plugin_entry_files(
1212 entries: &mut CategorizedEntryPoints,
1213 glob_set: &globset::GlobSet,
1214 glob_meta: &[CompiledEntryRule<'_>],
1215 relative_paths: &[String],
1216 files: &[DiscoveredFile],
1217) {
1218 for (idx, rel) in relative_paths.iter().enumerate() {
1219 let matches: Vec<usize> = glob_set
1220 .matches(rel)
1221 .into_iter()
1222 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
1223 .collect();
1224 if matches.is_empty() {
1225 continue;
1226 }
1227 let name = glob_meta[matches[0]].plugin_name;
1228 let entry = EntryPoint {
1229 path: files[idx].path.clone(),
1230 source: EntryPointSource::Plugin {
1231 name: name.to_string(),
1232 },
1233 };
1234 categorize_plugin_match(entries, entry, glob_meta, &matches);
1235 }
1236}
1237
1238fn categorize_plugin_match(
1240 entries: &mut CategorizedEntryPoints,
1241 entry: EntryPoint,
1242 glob_meta: &[CompiledEntryRule<'_>],
1243 matches: &[usize],
1244) {
1245 let mut has_runtime = false;
1246 let mut has_test = false;
1247 let mut has_support = false;
1248 for &match_idx in matches {
1249 match glob_meta[match_idx].role {
1250 EntryPointRole::Runtime => has_runtime = true,
1251 EntryPointRole::Test => has_test = true,
1252 EntryPointRole::Support => has_support = true,
1253 }
1254 }
1255
1256 if has_runtime {
1257 entries.push_runtime(entry.clone());
1258 }
1259 if has_test {
1260 entries.push_test(entry.clone());
1261 }
1262 if has_support || (!has_runtime && !has_test) {
1263 entries.push_support(entry);
1264 }
1265}
1266
1267fn push_plugin_setup_files(
1269 entries: &mut CategorizedEntryPoints,
1270 root: &Path,
1271 plugin_result: &crate::plugins::AggregatedPluginResult,
1272) {
1273 for (setup_file, pname) in &plugin_result.setup_files {
1274 let resolved = resolve_plugin_setup_file(root, setup_file);
1275 if resolved.exists() {
1276 entries.push_support(EntryPoint {
1277 path: resolved,
1278 source: EntryPointSource::Plugin {
1279 name: pname.clone(),
1280 },
1281 });
1282 continue;
1283 }
1284 for ext in SOURCE_EXTENSIONS {
1285 let with_ext = resolved.with_extension(ext);
1286 if with_ext.exists() {
1287 entries.push_support(EntryPoint {
1288 path: with_ext,
1289 source: EntryPointSource::Plugin {
1290 name: pname.clone(),
1291 },
1292 });
1293 break;
1294 }
1295 }
1296 }
1297}
1298
1299fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
1300 if is_absolute_path_any_platform(setup_file) {
1301 setup_file.to_path_buf()
1302 } else {
1303 root.join(setup_file)
1304 }
1305}
1306
1307#[expect(
1312 clippy::expect_used,
1313 reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
1314)]
1315#[must_use]
1316pub fn discover_dynamically_loaded_entry_points(
1317 config: &ResolvedConfig,
1318 files: &[DiscoveredFile],
1319) -> Vec<EntryPoint> {
1320 if config.dynamically_loaded.is_empty() {
1321 return Vec::new();
1322 }
1323
1324 let mut builder = globset::GlobSetBuilder::new();
1325 for pattern in &config.dynamically_loaded {
1326 builder.add(
1327 globset::Glob::new(pattern)
1328 .expect("dynamicallyLoaded pattern was validated at config load time"),
1329 );
1330 }
1331 let Ok(glob_set) = builder.build() else {
1332 return Vec::new();
1333 };
1334 if glob_set.is_empty() {
1335 return Vec::new();
1336 }
1337
1338 let mut entries = Vec::new();
1339 for file in files {
1340 let rel = file
1341 .path
1342 .strip_prefix(&config.root)
1343 .unwrap_or(&file.path)
1344 .to_string_lossy();
1345 if glob_set.is_match(rel.as_ref()) {
1346 entries.push(EntryPoint {
1347 path: file.path.clone(),
1348 source: EntryPointSource::DynamicallyLoaded,
1349 });
1350 }
1351 }
1352 entries
1353}
1354
1355struct CompiledEntryRule<'a> {
1356 path: crate::plugins::CompiledPathRule,
1357 plugin_name: &'a str,
1358 role: EntryPointRole,
1359}
1360
1361impl CompiledEntryRule<'_> {
1362 fn matches(&self, path: &str) -> bool {
1363 self.path.matches(path)
1364 }
1365}
1366
1367fn compile_entry_rule<'a>(
1368 rule: &'a crate::plugins::PathRule,
1369 plugin_name: &'a str,
1370 plugin_result: &'a crate::plugins::AggregatedPluginResult,
1371) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1372 let include = match globset::GlobBuilder::new(&rule.pattern)
1373 .literal_separator(true)
1374 .build()
1375 {
1376 Ok(glob) => glob,
1377 Err(err) => {
1378 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1379 return None;
1380 }
1381 };
1382 let role = plugin_result
1383 .entry_point_roles
1384 .get(plugin_name)
1385 .copied()
1386 .unwrap_or(EntryPointRole::Support);
1387 Some((
1388 include,
1389 CompiledEntryRule {
1390 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1391 plugin_name,
1392 role,
1393 },
1394 ))
1395}
1396
1397#[must_use]
1399pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1400 if patterns.is_empty() {
1401 return None;
1402 }
1403 let mut builder = globset::GlobSetBuilder::new();
1404 for pattern in patterns {
1405 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1406 .literal_separator(true)
1407 .build()
1408 {
1409 builder.add(glob);
1410 }
1411 }
1412 builder.build().ok()
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1419 use fallow_types::discover::FileId;
1420 use proptest::prelude::*;
1421
1422 proptest! {
1423 #[test]
1425 fn glob_patterns_never_panic_on_compile(
1426 prefix in "[a-zA-Z0-9_]{1,20}",
1427 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1428 ) {
1429 let pattern = format!("**/{prefix}*.{ext}");
1430 let result = globset::Glob::new(&pattern);
1431 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1432 }
1433
1434 #[test]
1436 fn non_source_extensions_not_in_list(
1437 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1438 ) {
1439 prop_assert!(
1440 !SOURCE_EXTENSIONS.contains(&ext),
1441 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1442 );
1443 }
1444
1445 #[test]
1447 fn compile_glob_set_no_panic(
1448 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1449 ) {
1450 let _ = compile_glob_set(&patterns);
1451 }
1452 }
1453
1454 #[test]
1455 fn compile_glob_set_empty_input() {
1456 assert!(
1457 compile_glob_set(&[]).is_none(),
1458 "empty patterns should return None"
1459 );
1460 }
1461
1462 #[test]
1463 fn compile_glob_set_valid_patterns() {
1464 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1465 let set = compile_glob_set(&patterns);
1466 assert!(set.is_some(), "valid patterns should compile");
1467 let set = set.unwrap();
1468 assert!(set.is_match("src/foo.ts"));
1469 assert!(set.is_match("src/bar.js"));
1470 assert!(!set.is_match("src/bar.py"));
1471 }
1472
1473 #[test]
1474 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1475 let patterns = vec!["composables/*.{ts,js}".to_string()];
1476 let set = compile_glob_set(&patterns).expect("pattern should compile");
1477
1478 assert!(set.is_match("composables/useFoo.ts"));
1479 assert!(!set.is_match("composables/nested/useFoo.ts"));
1480 }
1481
1482 #[test]
1483 #[expect(
1484 clippy::too_many_lines,
1485 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
1486 )]
1487 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1488 let dir = tempfile::tempdir().expect("create temp dir");
1489 let root = dir.path();
1490 std::fs::create_dir_all(root.join("src")).unwrap();
1491 std::fs::create_dir_all(root.join("tests")).unwrap();
1492 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1493 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1494 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1495
1496 let config = FallowConfig {
1497 type_aware: fallow_config::TypeAwareConfig::default(),
1498 schema: None,
1499 minimum_version: None,
1500 extends: vec![],
1501 entry: vec![],
1502 ignore_patterns: vec![],
1503 ignore_findings: vec![],
1504 framework: vec![],
1505 workspaces: None,
1506 ignore_dependencies: vec![],
1507 ignore_unresolved_imports: vec![],
1508 ignore_exports: vec![],
1509 ignore_catalog_references: vec![],
1510 ignore_dependency_overrides: vec![],
1511 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1512 used_class_members: vec![],
1513 ignore_decorators: vec![],
1514 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1515 duplicates: fallow_config::DuplicatesConfig::default(),
1516 similar_code: fallow_config::SimilarCodeConfig::default(),
1517 health: fallow_config::HealthConfig::default(),
1518 rules: RulesConfig::default(),
1519 boundaries: fallow_config::BoundaryConfig::default(),
1520 production: false.into(),
1521 plugins: vec![],
1522 rule_packs: vec![],
1523 dynamically_loaded: vec![],
1524 overrides: vec![],
1525 regression: None,
1526 audit: fallow_config::AuditConfig::default(),
1527 codeowners: None,
1528 public_packages: vec![],
1529 flags: fallow_config::FlagsConfig::default(),
1530 security: fallow_config::SecurityConfig::default(),
1531 fix: fallow_config::FixConfig::default(),
1532 resolve: fallow_config::ResolveConfig::default(),
1533 sealed: false,
1534 include_entry_exports: false,
1535 auto_imports: false,
1536 cache: fallow_config::CacheConfig::default(),
1537 }
1538 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1539
1540 let files = vec![
1541 DiscoveredFile {
1542 id: FileId(0),
1543 path: root.join("src/runtime.ts"),
1544 size_bytes: 1,
1545 },
1546 DiscoveredFile {
1547 id: FileId(1),
1548 path: root.join("src/setup.ts"),
1549 size_bytes: 1,
1550 },
1551 DiscoveredFile {
1552 id: FileId(2),
1553 path: root.join("tests/app.test.ts"),
1554 size_bytes: 1,
1555 },
1556 ];
1557
1558 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1559 plugin_result.entry_patterns.push((
1560 crate::plugins::PathRule::new("src/runtime.ts"),
1561 "runtime-plugin".to_string(),
1562 ));
1563 plugin_result.entry_patterns.push((
1564 crate::plugins::PathRule::new("tests/app.test.ts"),
1565 "test-plugin".to_string(),
1566 ));
1567 plugin_result
1568 .always_used
1569 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1570 plugin_result
1571 .entry_point_roles
1572 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1573 plugin_result
1574 .entry_point_roles
1575 .insert("test-plugin".to_string(), EntryPointRole::Test);
1576 plugin_result
1577 .entry_point_roles
1578 .insert("support-plugin".to_string(), EntryPointRole::Support);
1579
1580 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1581
1582 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1583 assert!(
1584 entries.runtime[0].path.ends_with("src/runtime.ts"),
1585 "runtime entry should stay runtime-only"
1586 );
1587 assert_eq!(entries.test.len(), 1, "expected one test entry");
1588 assert!(
1589 entries.test[0].path.ends_with("tests/app.test.ts"),
1590 "test entry should stay test-only"
1591 );
1592 assert_eq!(
1593 entries.all.len(),
1594 3,
1595 "support entries should stay in all entries"
1596 );
1597 assert!(
1598 entries
1599 .all
1600 .iter()
1601 .any(|entry| entry.path.ends_with("src/setup.ts")),
1602 "support entries should remain in the overall entry-point set"
1603 );
1604 assert!(
1605 !entries
1606 .runtime
1607 .iter()
1608 .any(|entry| entry.path.ends_with("src/setup.ts")),
1609 "support entries should not bleed into runtime reachability"
1610 );
1611 assert!(
1612 !entries
1613 .test
1614 .iter()
1615 .any(|entry| entry.path.ends_with("src/setup.ts")),
1616 "support entries should not bleed into test reachability"
1617 );
1618 }
1619
1620 #[test]
1621 fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1622 let root = Path::new("/workspace/project");
1623 let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1624
1625 assert_eq!(
1626 resolve_plugin_setup_file(root, setup_file),
1627 setup_file.to_path_buf()
1628 );
1629 }
1630
1631 #[cfg(windows)]
1632 #[test]
1633 fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1634 let root = Path::new(r"C:\workspace\project");
1635 let setup_file = Path::new(r"/workspace/project/setup.ts");
1636
1637 assert_eq!(
1638 resolve_plugin_setup_file(root, setup_file),
1639 setup_file.to_path_buf()
1640 );
1641 }
1642
1643 #[test]
1644 fn plugin_entry_point_rules_respect_exclusions() {
1645 let dir = tempfile::tempdir().expect("create temp dir");
1646 let root = dir.path();
1647 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1648 std::fs::write(
1649 root.join("app/pages/index.tsx"),
1650 "export default function Page() { return null; }",
1651 )
1652 .unwrap();
1653 std::fs::write(
1654 root.join("app/pages/-helper.ts"),
1655 "export const helper = 1;",
1656 )
1657 .unwrap();
1658
1659 let config = FallowConfig {
1660 type_aware: fallow_config::TypeAwareConfig::default(),
1661 schema: None,
1662 minimum_version: None,
1663 extends: vec![],
1664 entry: vec![],
1665 ignore_patterns: vec![],
1666 ignore_findings: vec![],
1667 framework: vec![],
1668 workspaces: None,
1669 ignore_dependencies: vec![],
1670 ignore_unresolved_imports: vec![],
1671 ignore_exports: vec![],
1672 ignore_catalog_references: vec![],
1673 ignore_dependency_overrides: vec![],
1674 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1675 used_class_members: vec![],
1676 ignore_decorators: vec![],
1677 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1678 duplicates: fallow_config::DuplicatesConfig::default(),
1679 similar_code: fallow_config::SimilarCodeConfig::default(),
1680 health: fallow_config::HealthConfig::default(),
1681 rules: RulesConfig::default(),
1682 boundaries: fallow_config::BoundaryConfig::default(),
1683 production: false.into(),
1684 plugins: vec![],
1685 rule_packs: vec![],
1686 dynamically_loaded: vec![],
1687 overrides: vec![],
1688 regression: None,
1689 audit: fallow_config::AuditConfig::default(),
1690 codeowners: None,
1691 public_packages: vec![],
1692 flags: fallow_config::FlagsConfig::default(),
1693 security: fallow_config::SecurityConfig::default(),
1694 fix: fallow_config::FixConfig::default(),
1695 resolve: fallow_config::ResolveConfig::default(),
1696 sealed: false,
1697 include_entry_exports: false,
1698 auto_imports: false,
1699 cache: fallow_config::CacheConfig::default(),
1700 }
1701 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1702
1703 let files = vec![
1704 DiscoveredFile {
1705 id: FileId(0),
1706 path: root.join("app/pages/index.tsx"),
1707 size_bytes: 1,
1708 },
1709 DiscoveredFile {
1710 id: FileId(1),
1711 path: root.join("app/pages/-helper.ts"),
1712 size_bytes: 1,
1713 },
1714 ];
1715
1716 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1717 plugin_result.entry_patterns.push((
1718 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1719 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1720 "tanstack-router".to_string(),
1721 ));
1722 plugin_result
1723 .entry_point_roles
1724 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1725
1726 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1727 let entry_paths: Vec<_> = entries
1728 .all
1729 .iter()
1730 .map(|entry| {
1731 entry
1732 .path
1733 .strip_prefix(root)
1734 .unwrap()
1735 .to_string_lossy()
1736 .into_owned()
1737 })
1738 .collect();
1739
1740 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1741 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1742 }
1743
1744 mod resolve_entry_path_tests {
1745 use super::*;
1746
1747 #[test]
1748 fn resolves_existing_file() {
1749 let dir = tempfile::tempdir().expect("create temp dir");
1750 let src = dir.path().join("src");
1751 std::fs::create_dir_all(&src).unwrap();
1752 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1753
1754 let canonical = dunce::canonicalize(dir.path()).unwrap();
1755 let result = resolve_entry_path(
1756 dir.path(),
1757 "src/index.ts",
1758 &canonical,
1759 EntryPointSource::PackageJsonMain,
1760 );
1761 assert!(result.is_some(), "should resolve an existing file");
1762 assert!(result.unwrap().path.ends_with("src/index.ts"));
1763 }
1764
1765 #[test]
1766 fn resolves_with_extension_fallback() {
1767 let dir = tempfile::tempdir().expect("create temp dir");
1768 let canonical = dunce::canonicalize(dir.path()).unwrap();
1769 let src = canonical.join("src");
1770 std::fs::create_dir_all(&src).unwrap();
1771 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1772
1773 let result = resolve_entry_path(
1774 &canonical,
1775 "src/index",
1776 &canonical,
1777 EntryPointSource::PackageJsonMain,
1778 );
1779 assert!(
1780 result.is_some(),
1781 "should resolve via extension fallback when exact path doesn't exist"
1782 );
1783 let ep = result.unwrap();
1784 assert!(
1785 ep.path.to_string_lossy().contains("index.ts"),
1786 "should find index.ts via extension fallback"
1787 );
1788 }
1789
1790 #[test]
1791 fn exact_file_wins_before_directory_index_fallback() {
1792 let dir = tempfile::tempdir().expect("create temp dir");
1793 let canonical = dunce::canonicalize(dir.path()).unwrap();
1794 let scripts = canonical.join("scripts");
1795 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1796 std::fs::write(
1797 scripts.join("process-messages.js"),
1798 "export const direct = true;",
1799 )
1800 .unwrap();
1801 std::fs::write(
1802 scripts.join("process-messages").join("index.js"),
1803 "export const index = true;",
1804 )
1805 .unwrap();
1806
1807 let result = resolve_entry_path(
1808 &canonical,
1809 "scripts/process-messages.js",
1810 &canonical,
1811 EntryPointSource::PackageJsonScript,
1812 )
1813 .expect("exact file should resolve");
1814
1815 assert!(result.path.ends_with("scripts/process-messages.js"));
1816 }
1817
1818 #[test]
1819 fn extension_fallback_wins_before_directory_index_fallback() {
1820 let dir = tempfile::tempdir().expect("create temp dir");
1821 let canonical = dunce::canonicalize(dir.path()).unwrap();
1822 let scripts = canonical.join("scripts");
1823 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1824 std::fs::write(
1825 scripts.join("process-messages.ts"),
1826 "export const withExt = true;",
1827 )
1828 .unwrap();
1829 std::fs::write(
1830 scripts.join("process-messages").join("index.js"),
1831 "export const index = true;",
1832 )
1833 .unwrap();
1834
1835 let result = resolve_entry_path(
1836 &canonical,
1837 "scripts/process-messages",
1838 &canonical,
1839 EntryPointSource::PackageJsonScript,
1840 )
1841 .expect("extension fallback should resolve");
1842
1843 assert!(result.path.ends_with("scripts/process-messages.ts"));
1844 }
1845
1846 #[test]
1847 fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1848 let dir = tempfile::tempdir().expect("create temp dir");
1849 let canonical = dunce::canonicalize(dir.path()).unwrap();
1850 let scripts = canonical.join("scripts/process-messages");
1851 std::fs::create_dir_all(&scripts).unwrap();
1852 std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1853
1854 let result = resolve_entry_path(
1855 &canonical,
1856 "scripts/process-messages",
1857 &canonical,
1858 EntryPointSource::PackageJsonScript,
1859 )
1860 .expect("directory index should resolve");
1861
1862 assert!(result.path.ends_with("scripts/process-messages/index.js"));
1863 }
1864
1865 #[test]
1866 fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1867 let dir = tempfile::tempdir().expect("create temp dir");
1868 let canonical = dunce::canonicalize(dir.path()).unwrap();
1869 std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1870 std::fs::write(
1871 canonical.join("scripts/process-messages/index.js"),
1872 "export const index = true;",
1873 )
1874 .unwrap();
1875
1876 for entry in [
1877 "scripts/*",
1878 "https://example.com/scripts/process-messages",
1879 "@scope/package/scripts/process-messages",
1880 ] {
1881 let result = resolve_entry_path(
1882 &canonical,
1883 entry,
1884 &canonical,
1885 EntryPointSource::PackageJsonScript,
1886 );
1887 assert!(result.is_none(), "{entry} should not resolve");
1888 }
1889 }
1890
1891 #[test]
1892 fn returns_none_for_nonexistent_file() {
1893 let dir = tempfile::tempdir().expect("create temp dir");
1894 let canonical = dunce::canonicalize(dir.path()).unwrap();
1895 let result = resolve_entry_path(
1896 dir.path(),
1897 "does/not/exist.ts",
1898 &canonical,
1899 EntryPointSource::PackageJsonMain,
1900 );
1901 assert!(result.is_none(), "should return None for nonexistent files");
1902 }
1903
1904 #[test]
1905 fn maps_dist_output_to_src() {
1906 let dir = tempfile::tempdir().expect("create temp dir");
1907 let src = dir.path().join("src");
1908 std::fs::create_dir_all(&src).unwrap();
1909 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1910
1911 let dist = dir.path().join("dist");
1912 std::fs::create_dir_all(&dist).unwrap();
1913 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1914
1915 let canonical = dunce::canonicalize(dir.path()).unwrap();
1916 let result = resolve_entry_path(
1917 dir.path(),
1918 "./dist/utils.js",
1919 &canonical,
1920 EntryPointSource::PackageJsonExports,
1921 );
1922 assert!(result.is_some(), "should resolve dist/ path to src/");
1923 let ep = result.unwrap();
1924 assert!(
1925 ep.path
1926 .to_string_lossy()
1927 .replace('\\', "/")
1928 .contains("src/utils.ts"),
1929 "should map ./dist/utils.js to src/utils.ts"
1930 );
1931 }
1932
1933 #[test]
1934 fn maps_build_output_to_src() {
1935 let dir = tempfile::tempdir().expect("create temp dir");
1936 let canonical = dunce::canonicalize(dir.path()).unwrap();
1937 let src = canonical.join("src");
1938 std::fs::create_dir_all(&src).unwrap();
1939 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1940
1941 let result = resolve_entry_path(
1942 &canonical,
1943 "./build/index.js",
1944 &canonical,
1945 EntryPointSource::PackageJsonExports,
1946 );
1947 assert!(result.is_some(), "should map build/ output to src/");
1948 let ep = result.unwrap();
1949 assert!(
1950 ep.path
1951 .to_string_lossy()
1952 .replace('\\', "/")
1953 .contains("src/index.tsx"),
1954 "should map ./build/index.js to src/index.tsx"
1955 );
1956 }
1957
1958 #[test]
1959 fn preserves_entry_point_source() {
1960 let dir = tempfile::tempdir().expect("create temp dir");
1961 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1962
1963 let canonical = dunce::canonicalize(dir.path()).unwrap();
1964 let result = resolve_entry_path(
1965 dir.path(),
1966 "index.ts",
1967 &canonical,
1968 EntryPointSource::PackageJsonScript,
1969 );
1970 assert!(result.is_some());
1971 assert!(
1972 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1973 "should preserve the source kind"
1974 );
1975 }
1976 #[test]
1977 fn tracks_skipped_entries_without_logging_each_repeat() {
1978 let dir = tempfile::tempdir().expect("create temp dir");
1979 let canonical = dunce::canonicalize(dir.path()).unwrap();
1980 let mut skipped_entries = FxHashMap::default();
1981
1982 let result = resolve_entry_path_with_tracking(
1983 dir.path(),
1984 "../scripts/build.js",
1985 &canonical,
1986 EntryPointSource::PackageJsonScript,
1987 Some(&mut skipped_entries),
1988 );
1989
1990 assert!(result.is_none(), "unsafe entry should be skipped");
1991 assert_eq!(
1992 skipped_entries.get("../scripts/build.js"),
1993 Some(&1),
1994 "warning tracker should count the skipped path"
1995 );
1996 }
1997
1998 #[test]
1999 fn formats_skipped_entry_warning_with_counts() {
2000 let mut skipped_entries = FxHashMap::default();
2001 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
2002 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
2003
2004 let warning =
2005 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
2006
2007 assert_eq!(
2008 warning,
2009 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
2010 );
2011 }
2012
2013 #[test]
2014 fn skipped_entry_summary_dedupes_identical_messages() {
2015 let message = format!(
2016 "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
2017 std::process::id()
2018 );
2019 assert!(
2020 should_warn_skipped_entry(&message),
2021 "first occurrence of a message emits"
2022 );
2023 assert!(
2024 !should_warn_skipped_entry(&message),
2025 "identical repeat is suppressed"
2026 );
2027 }
2028
2029 #[test]
2030 fn rejects_parent_dir_escape_for_exact_file() {
2031 let sandbox = tempfile::tempdir().expect("create sandbox");
2032 let root = sandbox.path().join("project");
2033 std::fs::create_dir_all(&root).unwrap();
2034 std::fs::write(
2035 sandbox.path().join("escape.ts"),
2036 "export const escape = true;",
2037 )
2038 .unwrap();
2039
2040 let canonical = dunce::canonicalize(&root).unwrap();
2041 let result = resolve_entry_path(
2042 &root,
2043 "../escape.ts",
2044 &canonical,
2045 EntryPointSource::PackageJsonMain,
2046 );
2047
2048 assert!(
2049 result.is_none(),
2050 "should reject exact paths that escape the root"
2051 );
2052 }
2053
2054 #[test]
2055 fn rejects_parent_dir_escape_via_extension_fallback() {
2056 let sandbox = tempfile::tempdir().expect("create sandbox");
2057 let root = sandbox.path().join("project");
2058 std::fs::create_dir_all(&root).unwrap();
2059 std::fs::write(
2060 sandbox.path().join("escape.ts"),
2061 "export const escape = true;",
2062 )
2063 .unwrap();
2064
2065 let canonical = dunce::canonicalize(&root).unwrap();
2066 let result = resolve_entry_path(
2067 &root,
2068 "../escape",
2069 &canonical,
2070 EntryPointSource::PackageJsonMain,
2071 );
2072
2073 assert!(
2074 result.is_none(),
2075 "should reject extension fallback paths that escape the root"
2076 );
2077 }
2078 }
2079
2080 mod output_to_source_tests {
2081 use super::*;
2082
2083 #[test]
2084 fn maps_dist_to_src_with_ts_extension() {
2085 let dir = tempfile::tempdir().expect("create temp dir");
2086 let src = dir.path().join("src");
2087 std::fs::create_dir_all(&src).unwrap();
2088 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2089
2090 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
2091 assert!(result.is_some());
2092 assert!(
2093 result
2094 .unwrap()
2095 .to_string_lossy()
2096 .replace('\\', "/")
2097 .contains("src/utils.ts")
2098 );
2099 }
2100
2101 #[test]
2102 fn returns_none_when_no_source_file_exists() {
2103 let dir = tempfile::tempdir().expect("create temp dir");
2104 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
2105 assert!(result.is_none());
2106 }
2107
2108 #[test]
2109 fn ignores_non_output_directories() {
2110 let dir = tempfile::tempdir().expect("create temp dir");
2111 let src = dir.path().join("src");
2112 std::fs::create_dir_all(&src).unwrap();
2113 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2114
2115 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
2116 assert!(result.is_none());
2117 }
2118
2119 #[test]
2120 fn maps_nested_output_path_preserving_prefix() {
2121 let dir = tempfile::tempdir().expect("create temp dir");
2122 let modules_src = dir.path().join("modules").join("src");
2123 std::fs::create_dir_all(&modules_src).unwrap();
2124 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
2125
2126 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
2127 assert!(result.is_some());
2128 assert!(
2129 result
2130 .unwrap()
2131 .to_string_lossy()
2132 .replace('\\', "/")
2133 .contains("modules/src/helper.ts")
2134 );
2135 }
2136 }
2137
2138 mod source_index_fallback_tests {
2139 use super::*;
2140
2141 #[test]
2142 fn detects_dist_entry_in_output_dir() {
2143 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
2144 assert!(is_entry_in_output_dir("dist/index.js"));
2145 assert!(is_entry_in_output_dir("./build/index.js"));
2146 assert!(is_entry_in_output_dir("./out/main.js"));
2147 assert!(is_entry_in_output_dir("./esm/index.js"));
2148 assert!(is_entry_in_output_dir("./cjs/index.js"));
2149 }
2150
2151 #[test]
2152 fn rejects_non_output_entry_paths() {
2153 assert!(!is_entry_in_output_dir("./src/index.ts"));
2154 assert!(!is_entry_in_output_dir("src/main.ts"));
2155 assert!(!is_entry_in_output_dir("./index.js"));
2156 assert!(!is_entry_in_output_dir(""));
2157 }
2158
2159 #[test]
2160 fn root_index_entries_are_recognized_for_source_fallback() {
2161 assert!(is_package_root_index_entry("./index.js"));
2162 assert!(is_package_root_index_entry("index.cjs"));
2163 assert!(is_package_root_index_entry("./index.d.ts"));
2164 assert!(!is_package_root_index_entry("./src/index.js"));
2165 assert!(!is_package_root_index_entry("./main.js"));
2166 assert!(!is_package_root_index_entry(""));
2167 }
2168
2169 #[test]
2170 fn rejects_substring_match_for_output_dir() {
2171 assert!(!is_entry_in_output_dir("./distro/index.js"));
2172 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
2173 }
2174
2175 #[test]
2176 fn finds_src_index_ts() {
2177 let dir = tempfile::tempdir().expect("create temp dir");
2178 let src = dir.path().join("src");
2179 std::fs::create_dir_all(&src).unwrap();
2180 let index_path = src.join("index.ts");
2181 std::fs::write(&index_path, "export const a = 1;").unwrap();
2182
2183 let result = try_source_index_fallback(dir.path());
2184 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2185 }
2186
2187 #[test]
2188 fn finds_src_index_tsx_when_ts_missing() {
2189 let dir = tempfile::tempdir().expect("create temp dir");
2190 let src = dir.path().join("src");
2191 std::fs::create_dir_all(&src).unwrap();
2192 let index_path = src.join("index.tsx");
2193 std::fs::write(&index_path, "export default 1;").unwrap();
2194
2195 let result = try_source_index_fallback(dir.path());
2196 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2197 }
2198
2199 #[test]
2200 fn prefers_src_index_over_root_index() {
2201 let dir = tempfile::tempdir().expect("create temp dir");
2202 let src = dir.path().join("src");
2203 std::fs::create_dir_all(&src).unwrap();
2204 let src_index = src.join("index.ts");
2205 std::fs::write(&src_index, "export const a = 1;").unwrap();
2206 let root_index = dir.path().join("index.ts");
2207 std::fs::write(&root_index, "export const b = 2;").unwrap();
2208
2209 let result = try_source_index_fallback(dir.path());
2210 assert_eq!(result.as_deref(), Some(src_index.as_path()));
2211 }
2212
2213 #[test]
2214 fn falls_back_to_src_main() {
2215 let dir = tempfile::tempdir().expect("create temp dir");
2216 let src = dir.path().join("src");
2217 std::fs::create_dir_all(&src).unwrap();
2218 let main_path = src.join("main.ts");
2219 std::fs::write(&main_path, "export const a = 1;").unwrap();
2220
2221 let result = try_source_index_fallback(dir.path());
2222 assert_eq!(result.as_deref(), Some(main_path.as_path()));
2223 }
2224
2225 #[test]
2226 fn falls_back_to_root_index_when_no_src() {
2227 let dir = tempfile::tempdir().expect("create temp dir");
2228 let index_path = dir.path().join("index.js");
2229 std::fs::write(&index_path, "module.exports = {};").unwrap();
2230
2231 let result = try_source_index_fallback(dir.path());
2232 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2233 }
2234
2235 #[test]
2236 fn returns_none_when_nothing_matches() {
2237 let dir = tempfile::tempdir().expect("create temp dir");
2238 let result = try_source_index_fallback(dir.path());
2239 assert!(result.is_none());
2240 }
2241
2242 #[test]
2243 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
2244 let dir = tempfile::tempdir().expect("create temp dir");
2245 let canonical = dunce::canonicalize(dir.path()).unwrap();
2246
2247 let dist_dir = canonical.join("dist").join("esm2022");
2248 std::fs::create_dir_all(&dist_dir).unwrap();
2249 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
2250
2251 let src = canonical.join("src");
2252 std::fs::create_dir_all(&src).unwrap();
2253 let src_index = src.join("index.ts");
2254 std::fs::write(&src_index, "export const x = 1;").unwrap();
2255
2256 let result = resolve_entry_path(
2257 &canonical,
2258 "./dist/esm2022/index.js",
2259 &canonical,
2260 EntryPointSource::PackageJsonMain,
2261 );
2262 assert!(result.is_some());
2263 let entry = result.unwrap();
2264 assert_eq!(entry.path, src_index);
2265 }
2266
2267 #[test]
2268 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2269 let dir = tempfile::tempdir().expect("create temp dir");
2270 let canonical = dunce::canonicalize(dir.path()).unwrap();
2271
2272 let src_mirror = canonical.join("src").join("esm2022");
2273 std::fs::create_dir_all(&src_mirror).unwrap();
2274 let mirror_index = src_mirror.join("index.ts");
2275 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2276
2277 let src_index = canonical.join("src").join("index.ts");
2278 std::fs::write(&src_index, "export const y = 2;").unwrap();
2279
2280 let result = resolve_entry_path(
2281 &canonical,
2282 "./dist/esm2022/index.js",
2283 &canonical,
2284 EntryPointSource::PackageJsonMain,
2285 );
2286 assert_eq!(result.map(|e| e.path), Some(mirror_index));
2287 }
2288
2289 #[test]
2290 fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2291 let dir = tempfile::tempdir().expect("create temp dir");
2292 let canonical = dunce::canonicalize(dir.path()).unwrap();
2293
2294 let src = canonical.join("src");
2295 std::fs::create_dir_all(&src).unwrap();
2296 let src_index = src.join("index.ts");
2297 std::fs::write(&src_index, "export const x = 1;").unwrap();
2298
2299 let result = resolve_entry_path(
2300 &canonical,
2301 "./index.js",
2302 &canonical,
2303 EntryPointSource::PackageJsonMain,
2304 );
2305 assert_eq!(result.map(|entry| entry.path), Some(src_index));
2306 }
2307 }
2308
2309 mod default_fallback_tests {
2310 use super::*;
2311
2312 #[test]
2313 fn finds_src_index_ts_as_fallback() {
2314 let dir = tempfile::tempdir().expect("create temp dir");
2315 let src = dir.path().join("src");
2316 std::fs::create_dir_all(&src).unwrap();
2317 let index_path = src.join("index.ts");
2318 std::fs::write(&index_path, "export const a = 1;").unwrap();
2319
2320 let files = vec![DiscoveredFile {
2321 id: FileId(0),
2322 path: index_path.clone(),
2323 size_bytes: 20,
2324 }];
2325
2326 let entries = apply_default_fallback(&files, dir.path(), None);
2327 assert_eq!(entries.len(), 1);
2328 assert_eq!(entries[0].path, index_path);
2329 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2330 }
2331
2332 #[test]
2333 fn finds_root_index_js_as_fallback() {
2334 let dir = tempfile::tempdir().expect("create temp dir");
2335 let index_path = dir.path().join("index.js");
2336 std::fs::write(&index_path, "module.exports = {};").unwrap();
2337
2338 let files = vec![DiscoveredFile {
2339 id: FileId(0),
2340 path: index_path.clone(),
2341 size_bytes: 21,
2342 }];
2343
2344 let entries = apply_default_fallback(&files, dir.path(), None);
2345 assert_eq!(entries.len(), 1);
2346 assert_eq!(entries[0].path, index_path);
2347 }
2348
2349 #[test]
2350 fn returns_empty_when_no_index_file() {
2351 let dir = tempfile::tempdir().expect("create temp dir");
2352 let other_path = dir.path().join("src").join("utils.ts");
2353
2354 let files = vec![DiscoveredFile {
2355 id: FileId(0),
2356 path: other_path,
2357 size_bytes: 10,
2358 }];
2359
2360 let entries = apply_default_fallback(&files, dir.path(), None);
2361 assert!(
2362 entries.is_empty(),
2363 "non-index files should not match default fallback"
2364 );
2365 }
2366
2367 #[test]
2368 fn workspace_filter_restricts_scope() {
2369 let dir = tempfile::tempdir().expect("create temp dir");
2370 let ws_a = dir.path().join("packages").join("a").join("src");
2371 std::fs::create_dir_all(&ws_a).unwrap();
2372 let ws_b = dir.path().join("packages").join("b").join("src");
2373 std::fs::create_dir_all(&ws_b).unwrap();
2374
2375 let index_a = ws_a.join("index.ts");
2376 let index_b = ws_b.join("index.ts");
2377
2378 let files = vec![
2379 DiscoveredFile {
2380 id: FileId(0),
2381 path: index_a.clone(),
2382 size_bytes: 10,
2383 },
2384 DiscoveredFile {
2385 id: FileId(1),
2386 path: index_b,
2387 size_bytes: 10,
2388 },
2389 ];
2390
2391 let ws_root = dir.path().join("packages").join("a");
2392 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2393 assert_eq!(entries.len(), 1);
2394 assert_eq!(entries[0].path, index_a);
2395 }
2396 }
2397
2398 mod wildcard_entry_tests {
2399 use super::*;
2400
2401 #[test]
2402 fn expands_wildcard_css_entries() {
2403 let dir = tempfile::tempdir().expect("create temp dir");
2404 let themes = dir.path().join("src").join("themes");
2405 std::fs::create_dir_all(&themes).unwrap();
2406 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2407 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2408
2409 let canonical = dunce::canonicalize(dir.path()).unwrap();
2410 let mut entries = Vec::new();
2411 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2412
2413 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2414 let paths: Vec<String> = entries
2415 .iter()
2416 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2417 .collect();
2418 assert!(paths.contains(&"dark.css".to_string()));
2419 assert!(paths.contains(&"light.css".to_string()));
2420 assert!(
2421 entries
2422 .iter()
2423 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2424 );
2425 }
2426
2427 #[test]
2428 fn wildcard_does_not_match_nonexistent_files() {
2429 let dir = tempfile::tempdir().expect("create temp dir");
2430 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2431
2432 let canonical = dunce::canonicalize(dir.path()).unwrap();
2433 let mut entries = Vec::new();
2434 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2435
2436 assert!(
2437 entries.is_empty(),
2438 "should return empty when no files match the wildcard"
2439 );
2440 }
2441
2442 #[test]
2443 fn wildcard_only_matches_specified_extension() {
2444 let dir = tempfile::tempdir().expect("create temp dir");
2445 let themes = dir.path().join("src").join("themes");
2446 std::fs::create_dir_all(&themes).unwrap();
2447 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2448 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2449
2450 let canonical = dunce::canonicalize(dir.path()).unwrap();
2451 let mut entries = Vec::new();
2452 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2453
2454 assert_eq!(entries.len(), 1, "should only match CSS files");
2455 assert!(
2456 entries[0]
2457 .path
2458 .file_name()
2459 .unwrap()
2460 .to_string_lossy()
2461 .ends_with(".css")
2462 );
2463 }
2464 }
2465
2466 fn config_for(root: &Path) -> ResolvedConfig {
2467 FallowConfig::default().resolve(
2468 root.to_path_buf(),
2469 OutputFormat::Human,
2470 1,
2471 true,
2472 true,
2473 None,
2474 )
2475 }
2476
2477 fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
2478 DiscoveredFile {
2479 id: FileId(id),
2480 path: root.join(rel),
2481 size_bytes: 1,
2482 }
2483 }
2484
2485 #[test]
2486 fn root_entry_points_use_package_json_scripts() {
2487 let dir = tempfile::tempdir().expect("tempdir");
2488 let root = dir.path();
2489 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2490 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2491 .expect("script file");
2492 std::fs::write(
2493 root.join("package.json"),
2494 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2495 )
2496 .expect("package file");
2497 let config = config_for(root);
2498 let files = [discovered(root, "scripts/build.ts", 0)];
2499
2500 let entries = discover_entry_points(&config, &files);
2501
2502 assert_eq!(entries.len(), 1);
2503 assert!(entries[0].path.ends_with("scripts/build.ts"));
2504 assert!(matches!(
2505 entries[0].source,
2506 EntryPointSource::PackageJsonScript
2507 ));
2508 }
2509
2510 #[test]
2511 fn build_script_is_support_while_default_index_stays_runtime() {
2512 let dir = tempfile::tempdir().expect("tempdir");
2513 let root = dir.path();
2514 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2515 std::fs::create_dir_all(root.join("src")).expect("src dir");
2516 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2517 .expect("script file");
2518 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2519 std::fs::write(
2520 root.join("package.json"),
2521 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2522 )
2523 .expect("package file");
2524 let config = config_for(root);
2525 let files = [
2526 discovered(root, "scripts/build.ts", 0),
2527 discovered(root, "src/index.ts", 1),
2528 ];
2529
2530 let discovery = discover_entry_points_with_warnings(&config, &files);
2531
2532 assert_eq!(discovery.entries.len(), 1);
2533 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2534 assert_eq!(discovery.support_entries.len(), 1);
2535 assert!(
2536 discovery.support_entries[0]
2537 .path
2538 .ends_with("scripts/build.ts")
2539 );
2540 }
2541
2542 #[test]
2543 fn indirect_quoted_start_script_stays_runtime() {
2544 let dir = tempfile::tempdir().expect("tempdir");
2545 let root = dir.path();
2546 std::fs::create_dir_all(root.join("src")).expect("src dir");
2547 std::fs::write(root.join("src/server.ts"), "export const server = true;")
2548 .expect("server file");
2549 std::fs::write(
2550 root.join("package.json"),
2551 r#"{"scripts":{"start":"npm run serve","serve":"node 'src/server.ts'"}}"#,
2552 )
2553 .expect("package file");
2554 let config = config_for(root);
2555 let files = [discovered(root, "src/server.ts", 0)];
2556
2557 let discovery = discover_entry_points_with_warnings(&config, &files);
2558
2559 assert_eq!(discovery.entries.len(), 1);
2560 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2561 assert!(discovery.support_entries.is_empty());
2562 }
2563
2564 #[test]
2565 fn indirect_start_script_includes_its_lifecycle_hooks() {
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 for file in ["pre.ts", "server.ts", "post.ts"] {
2570 std::fs::write(root.join("src").join(file), "export const value = true;")
2571 .expect("runtime file");
2572 }
2573 std::fs::write(
2574 root.join("package.json"),
2575 r#"{"scripts":{"start":"npm run serve","preserve":"node src/pre.ts","serve":"node src/server.ts","postserve":"node src/post.ts"}}"#,
2576 )
2577 .expect("package file");
2578 let config = config_for(root);
2579 let files = [
2580 discovered(root, "src/pre.ts", 0),
2581 discovered(root, "src/server.ts", 1),
2582 discovered(root, "src/post.ts", 2),
2583 ];
2584
2585 let discovery = discover_entry_points_with_warnings(&config, &files);
2586
2587 assert_eq!(discovery.entries.len(), 3);
2588 assert!(discovery.support_entries.is_empty());
2589 }
2590
2591 #[test]
2592 #[expect(
2593 clippy::disallowed_types,
2594 reason = "test input matches the serde-deserialized package.json scripts map"
2595 )]
2596 fn start_does_not_recursively_invent_hooks_for_lifecycle_hooks() {
2597 let scripts = std::collections::HashMap::from([
2598 ("preprestart".to_string(), "node src/tool.ts".to_string()),
2599 ("prestart".to_string(), "node src/pre.ts".to_string()),
2600 ("start".to_string(), "node src/server.ts".to_string()),
2601 ("poststart".to_string(), "node src/post.ts".to_string()),
2602 ("postpoststart".to_string(), "node src/tool.ts".to_string()),
2603 ]);
2604
2605 let runtime = runtime_package_script_names(&scripts);
2606
2607 assert_eq!(
2608 runtime,
2609 FxHashSet::from_iter([
2610 "prestart".to_string(),
2611 "start".to_string(),
2612 "poststart".to_string(),
2613 ])
2614 );
2615 }
2616
2617 #[test]
2618 fn bun_build_script_remains_support_entry() {
2619 let dir = tempfile::tempdir().expect("tempdir");
2620 let root = dir.path();
2621 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2622 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2623 .expect("build file");
2624 std::fs::write(
2625 root.join("package.json"),
2626 r#"{"scripts":{"build":"bun scripts/build.ts"}}"#,
2627 )
2628 .expect("package file");
2629 let config = config_for(root);
2630 let files = [discovered(root, "scripts/build.ts", 0)];
2631
2632 let discovery = discover_entry_points_with_warnings(&config, &files);
2633
2634 assert!(discovery.entries.is_empty());
2635 assert_eq!(discovery.support_entries.len(), 1);
2636 }
2637
2638 #[test]
2639 fn start_script_config_remains_support_only() {
2640 let dir = tempfile::tempdir().expect("tempdir");
2641 let root = dir.path();
2642 std::fs::write(root.join("vite.config.ts"), "export default {};").expect("config file");
2643 std::fs::write(
2644 root.join("package.json"),
2645 r#"{"scripts":{"start":"vite --config vite.config.ts"}}"#,
2646 )
2647 .expect("package file");
2648 let config = config_for(root);
2649 let files = [discovered(root, "vite.config.ts", 0)];
2650
2651 let discovery = discover_entry_points_with_warnings(&config, &files);
2652
2653 assert!(discovery.entries.is_empty());
2654 assert_eq!(discovery.support_entries.len(), 1);
2655 assert!(
2656 discovery.support_entries[0]
2657 .path
2658 .ends_with("vite.config.ts")
2659 );
2660 }
2661
2662 #[test]
2663 fn manifest_entry_remains_runtime_when_build_script_references_same_path() {
2664 let dir = tempfile::tempdir().expect("tempdir");
2665 let root = dir.path();
2666 std::fs::create_dir_all(root.join("src")).expect("src dir");
2667 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2668 std::fs::write(
2669 root.join("package.json"),
2670 r#"{"main":"src/index.ts","scripts":{"build":"tsx src/index.ts"}}"#,
2671 )
2672 .expect("package file");
2673 let config = config_for(root);
2674 let files = [discovered(root, "src/index.ts", 0)];
2675
2676 let discovery = discover_entry_points_with_warnings(&config, &files);
2677
2678 assert_eq!(discovery.entries.len(), 1);
2679 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2680 }
2681
2682 #[test]
2683 fn workspace_build_script_is_support_with_runtime_fallback() {
2684 let dir = tempfile::tempdir().expect("tempdir");
2685 let root = dir.path();
2686 let workspace = root.join("packages/app");
2687 std::fs::create_dir_all(workspace.join("scripts")).expect("scripts dir");
2688 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2689 std::fs::write(
2690 workspace.join("scripts/build.ts"),
2691 "export const build = true;",
2692 )
2693 .expect("script file");
2694 std::fs::write(workspace.join("src/index.ts"), "export const app = true;")
2695 .expect("index file");
2696 std::fs::write(
2697 workspace.join("package.json"),
2698 r#"{"name":"app","scripts":{"build":"tsx scripts/build.ts"}}"#,
2699 )
2700 .expect("package file");
2701 let files = [
2702 discovered(root, "packages/app/scripts/build.ts", 0),
2703 discovered(root, "packages/app/src/index.ts", 1),
2704 ];
2705
2706 let discovery =
2707 discover_workspace_entry_points_with_warnings(&workspace, &config_for(root), &files);
2708
2709 assert_eq!(discovery.entries.len(), 1);
2710 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2711 assert_eq!(discovery.support_entries.len(), 1);
2712 assert!(
2713 discovery.support_entries[0]
2714 .path
2715 .ends_with("scripts/build.ts")
2716 );
2717 }
2718
2719 #[test]
2720 fn workspace_script_selected_by_root_start_is_runtime() {
2721 let dir = tempfile::tempdir().expect("tempdir");
2722 let root = dir.path();
2723 let workspace = root.join("packages/api");
2724 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2725 std::fs::write(workspace.join("src/server.ts"), "export const app = true;")
2726 .expect("server file");
2727 let workspace_pkg: PackageJson = serde_json::from_str(
2728 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2729 )
2730 .expect("workspace package");
2731 let files = [discovered(root, "packages/api/src/server.ts", 0)];
2732 let seeds = FxHashSet::from_iter(["serve".to_string()]);
2733
2734 let discovery = discover_workspace_entry_points_with_runtime_scripts(
2735 &workspace,
2736 &files,
2737 Some(&workspace_pkg),
2738 &seeds,
2739 );
2740
2741 assert_eq!(discovery.entries.len(), 1);
2742 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2743 assert!(discovery.support_entries.is_empty());
2744 }
2745
2746 #[test]
2747 fn workspace_script_selection_does_not_promote_same_named_root_script() {
2748 let dir = tempfile::tempdir().expect("tempdir");
2749 let root = dir.path();
2750 let root_pkg: PackageJson = serde_json::from_str(
2751 r#"{
2752 "scripts": {
2753 "start": "pnpm --filter=@scope/api run serve",
2754 "serve": "node src/unrelated.ts"
2755 }
2756 }"#,
2757 )
2758 .expect("root package");
2759 let api_pkg: PackageJson = serde_json::from_str(
2760 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2761 )
2762 .expect("api package");
2763 let workspace_pkgs = vec![(
2764 fallow_config::WorkspaceInfo {
2765 root: root.join("packages/api"),
2766 name: "@scope/api".to_string(),
2767 is_internal_dependency: false,
2768 },
2769 api_pkg,
2770 )];
2771 let scripts = root_pkg.scripts.as_ref().expect("root scripts");
2772
2773 let runtime = runtime_package_script_names(scripts);
2774 let workspace_seeds =
2775 workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2776
2777 assert_eq!(runtime, FxHashSet::from_iter(["start".to_string()]));
2778 assert_eq!(
2779 workspace_seeds.get("@scope/api"),
2780 Some(&FxHashSet::from_iter(["serve".to_string()]))
2781 );
2782 }
2783
2784 #[test]
2785 fn workspace_runtime_script_seeds_follow_transitive_workspace_calls() {
2786 let dir = tempfile::tempdir().expect("tempdir");
2787 let root = dir.path();
2788 let root_pkg: PackageJson =
2789 serde_json::from_str(r#"{"scripts":{"start":"pnpm --filter @scope/api run serve"}}"#)
2790 .expect("root package");
2791 let api_pkg: PackageJson = serde_json::from_str(
2792 r#"{"name":"@scope/api","scripts":{"serve":"npm --workspace packages/worker run work"}}"#,
2793 )
2794 .expect("api package");
2795 let worker_pkg: PackageJson = serde_json::from_str(
2796 r#"{"name":"@scope/worker","scripts":{"prework":"node pre.ts","work":"node worker.ts"}}"#,
2797 )
2798 .expect("worker package");
2799 let workspace_pkgs = vec![
2800 (
2801 fallow_config::WorkspaceInfo {
2802 root: root.join("packages/api"),
2803 name: "@scope/api".to_string(),
2804 is_internal_dependency: false,
2805 },
2806 api_pkg,
2807 ),
2808 (
2809 fallow_config::WorkspaceInfo {
2810 root: root.join("packages/worker"),
2811 name: "@scope/worker".to_string(),
2812 is_internal_dependency: false,
2813 },
2814 worker_pkg,
2815 ),
2816 ];
2817
2818 let seeds = workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2819
2820 assert_eq!(
2821 seeds.get("@scope/api"),
2822 Some(&FxHashSet::from_iter(["serve".to_string()]))
2823 );
2824 assert_eq!(
2825 seeds.get("@scope/worker"),
2826 Some(&FxHashSet::from_iter([
2827 "prework".to_string(),
2828 "work".to_string(),
2829 ]))
2830 );
2831 }
2832
2833 #[test]
2834 fn root_entry_points_fall_back_to_default_index() {
2835 let dir = tempfile::tempdir().expect("tempdir");
2836 let root = dir.path();
2837 std::fs::create_dir_all(root.join("src")).expect("src dir");
2838 std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
2839 let config = config_for(root);
2840 let files = [discovered(root, "src/index.ts", 0)];
2841
2842 let entries = discover_entry_points(&config, &files);
2843
2844 assert_eq!(entries.len(), 1);
2845 assert!(entries[0].path.ends_with("src/index.ts"));
2846 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2847 }
2848
2849 #[test]
2850 fn default_index_matchers_preserve_patterns_and_are_shared() {
2851 let matchers = default_index_matchers();
2852 for path in [
2853 "src/index.ts",
2854 "src/index.tsx",
2855 "src/index.js",
2856 "src/index.jsx",
2857 "src/main.ts",
2858 "src/main.tsx",
2859 "src/main.js",
2860 "src/main.jsx",
2861 "index.ts",
2862 "index.tsx",
2863 "index.js",
2864 "index.jsx",
2865 "main.ts",
2866 "main.tsx",
2867 "main.js",
2868 "main.jsx",
2869 ] {
2870 assert!(
2871 matchers.is_match(path),
2872 "default index did not match {path}"
2873 );
2874 }
2875 for path in [
2876 "src/index.mts",
2877 "src/nested/index.ts",
2878 "packages/app/src/index.ts",
2879 "custom-entry.ts",
2880 ] {
2881 assert!(!matchers.is_match(path), "non-default index matched {path}");
2882 }
2883
2884 std::thread::scope(|scope| {
2885 let handles = std::iter::repeat_with(|| scope.spawn(default_index_matchers))
2886 .take(4)
2887 .collect::<Vec<_>>();
2888 for handle in handles {
2889 let from_thread = handle.join().expect("fallback matcher thread");
2890 assert!(std::ptr::eq(matchers, from_thread));
2891 }
2892 });
2893 }
2894
2895 #[test]
2896 fn workspace_entry_points_use_workspace_package_json() {
2897 let dir = tempfile::tempdir().expect("tempdir");
2898 let root = dir.path();
2899 let ws_root = root.join("packages/app");
2900 std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
2901 std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
2902 .expect("workspace main");
2903 std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
2904 .expect("workspace package file");
2905 let config = config_for(root);
2906 let files = [discovered(root, "packages/app/src/main.ts", 0)];
2907
2908 let entries = discover_workspace_entry_points(&ws_root, &config, &files);
2909
2910 assert_eq!(entries.len(), 1);
2911 assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
2912 assert!(matches!(
2913 entries[0].source,
2914 EntryPointSource::PackageJsonMain
2915 ));
2916 }
2917}