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 extends: vec![],
1500 entry: vec![],
1501 ignore_patterns: vec![],
1502 ignore_findings: vec![],
1503 framework: vec![],
1504 workspaces: None,
1505 ignore_dependencies: vec![],
1506 ignore_unresolved_imports: vec![],
1507 ignore_exports: vec![],
1508 ignore_catalog_references: vec![],
1509 ignore_dependency_overrides: vec![],
1510 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1511 used_class_members: vec![],
1512 ignore_decorators: vec![],
1513 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1514 duplicates: fallow_config::DuplicatesConfig::default(),
1515 similar_code: fallow_config::SimilarCodeConfig::default(),
1516 health: fallow_config::HealthConfig::default(),
1517 rules: RulesConfig::default(),
1518 boundaries: fallow_config::BoundaryConfig::default(),
1519 production: false.into(),
1520 plugins: vec![],
1521 rule_packs: vec![],
1522 dynamically_loaded: vec![],
1523 overrides: vec![],
1524 regression: None,
1525 audit: fallow_config::AuditConfig::default(),
1526 codeowners: None,
1527 public_packages: vec![],
1528 flags: fallow_config::FlagsConfig::default(),
1529 security: fallow_config::SecurityConfig::default(),
1530 fix: fallow_config::FixConfig::default(),
1531 resolve: fallow_config::ResolveConfig::default(),
1532 sealed: false,
1533 include_entry_exports: false,
1534 auto_imports: false,
1535 cache: fallow_config::CacheConfig::default(),
1536 }
1537 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1538
1539 let files = vec![
1540 DiscoveredFile {
1541 id: FileId(0),
1542 path: root.join("src/runtime.ts"),
1543 size_bytes: 1,
1544 },
1545 DiscoveredFile {
1546 id: FileId(1),
1547 path: root.join("src/setup.ts"),
1548 size_bytes: 1,
1549 },
1550 DiscoveredFile {
1551 id: FileId(2),
1552 path: root.join("tests/app.test.ts"),
1553 size_bytes: 1,
1554 },
1555 ];
1556
1557 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1558 plugin_result.entry_patterns.push((
1559 crate::plugins::PathRule::new("src/runtime.ts"),
1560 "runtime-plugin".to_string(),
1561 ));
1562 plugin_result.entry_patterns.push((
1563 crate::plugins::PathRule::new("tests/app.test.ts"),
1564 "test-plugin".to_string(),
1565 ));
1566 plugin_result
1567 .always_used
1568 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1569 plugin_result
1570 .entry_point_roles
1571 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1572 plugin_result
1573 .entry_point_roles
1574 .insert("test-plugin".to_string(), EntryPointRole::Test);
1575 plugin_result
1576 .entry_point_roles
1577 .insert("support-plugin".to_string(), EntryPointRole::Support);
1578
1579 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1580
1581 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1582 assert!(
1583 entries.runtime[0].path.ends_with("src/runtime.ts"),
1584 "runtime entry should stay runtime-only"
1585 );
1586 assert_eq!(entries.test.len(), 1, "expected one test entry");
1587 assert!(
1588 entries.test[0].path.ends_with("tests/app.test.ts"),
1589 "test entry should stay test-only"
1590 );
1591 assert_eq!(
1592 entries.all.len(),
1593 3,
1594 "support entries should stay in all entries"
1595 );
1596 assert!(
1597 entries
1598 .all
1599 .iter()
1600 .any(|entry| entry.path.ends_with("src/setup.ts")),
1601 "support entries should remain in the overall entry-point set"
1602 );
1603 assert!(
1604 !entries
1605 .runtime
1606 .iter()
1607 .any(|entry| entry.path.ends_with("src/setup.ts")),
1608 "support entries should not bleed into runtime reachability"
1609 );
1610 assert!(
1611 !entries
1612 .test
1613 .iter()
1614 .any(|entry| entry.path.ends_with("src/setup.ts")),
1615 "support entries should not bleed into test reachability"
1616 );
1617 }
1618
1619 #[test]
1620 fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1621 let root = Path::new("/workspace/project");
1622 let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1623
1624 assert_eq!(
1625 resolve_plugin_setup_file(root, setup_file),
1626 setup_file.to_path_buf()
1627 );
1628 }
1629
1630 #[cfg(windows)]
1631 #[test]
1632 fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1633 let root = Path::new(r"C:\workspace\project");
1634 let setup_file = Path::new(r"/workspace/project/setup.ts");
1635
1636 assert_eq!(
1637 resolve_plugin_setup_file(root, setup_file),
1638 setup_file.to_path_buf()
1639 );
1640 }
1641
1642 #[test]
1643 fn plugin_entry_point_rules_respect_exclusions() {
1644 let dir = tempfile::tempdir().expect("create temp dir");
1645 let root = dir.path();
1646 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1647 std::fs::write(
1648 root.join("app/pages/index.tsx"),
1649 "export default function Page() { return null; }",
1650 )
1651 .unwrap();
1652 std::fs::write(
1653 root.join("app/pages/-helper.ts"),
1654 "export const helper = 1;",
1655 )
1656 .unwrap();
1657
1658 let config = FallowConfig {
1659 type_aware: fallow_config::TypeAwareConfig::default(),
1660 schema: None,
1661 extends: vec![],
1662 entry: vec![],
1663 ignore_patterns: vec![],
1664 ignore_findings: vec![],
1665 framework: vec![],
1666 workspaces: None,
1667 ignore_dependencies: vec![],
1668 ignore_unresolved_imports: vec![],
1669 ignore_exports: vec![],
1670 ignore_catalog_references: vec![],
1671 ignore_dependency_overrides: vec![],
1672 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1673 used_class_members: vec![],
1674 ignore_decorators: vec![],
1675 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1676 duplicates: fallow_config::DuplicatesConfig::default(),
1677 similar_code: fallow_config::SimilarCodeConfig::default(),
1678 health: fallow_config::HealthConfig::default(),
1679 rules: RulesConfig::default(),
1680 boundaries: fallow_config::BoundaryConfig::default(),
1681 production: false.into(),
1682 plugins: vec![],
1683 rule_packs: vec![],
1684 dynamically_loaded: vec![],
1685 overrides: vec![],
1686 regression: None,
1687 audit: fallow_config::AuditConfig::default(),
1688 codeowners: None,
1689 public_packages: vec![],
1690 flags: fallow_config::FlagsConfig::default(),
1691 security: fallow_config::SecurityConfig::default(),
1692 fix: fallow_config::FixConfig::default(),
1693 resolve: fallow_config::ResolveConfig::default(),
1694 sealed: false,
1695 include_entry_exports: false,
1696 auto_imports: false,
1697 cache: fallow_config::CacheConfig::default(),
1698 }
1699 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1700
1701 let files = vec![
1702 DiscoveredFile {
1703 id: FileId(0),
1704 path: root.join("app/pages/index.tsx"),
1705 size_bytes: 1,
1706 },
1707 DiscoveredFile {
1708 id: FileId(1),
1709 path: root.join("app/pages/-helper.ts"),
1710 size_bytes: 1,
1711 },
1712 ];
1713
1714 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1715 plugin_result.entry_patterns.push((
1716 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1717 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1718 "tanstack-router".to_string(),
1719 ));
1720 plugin_result
1721 .entry_point_roles
1722 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1723
1724 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1725 let entry_paths: Vec<_> = entries
1726 .all
1727 .iter()
1728 .map(|entry| {
1729 entry
1730 .path
1731 .strip_prefix(root)
1732 .unwrap()
1733 .to_string_lossy()
1734 .into_owned()
1735 })
1736 .collect();
1737
1738 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1739 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1740 }
1741
1742 mod resolve_entry_path_tests {
1743 use super::*;
1744
1745 #[test]
1746 fn resolves_existing_file() {
1747 let dir = tempfile::tempdir().expect("create temp dir");
1748 let src = dir.path().join("src");
1749 std::fs::create_dir_all(&src).unwrap();
1750 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1751
1752 let canonical = dunce::canonicalize(dir.path()).unwrap();
1753 let result = resolve_entry_path(
1754 dir.path(),
1755 "src/index.ts",
1756 &canonical,
1757 EntryPointSource::PackageJsonMain,
1758 );
1759 assert!(result.is_some(), "should resolve an existing file");
1760 assert!(result.unwrap().path.ends_with("src/index.ts"));
1761 }
1762
1763 #[test]
1764 fn resolves_with_extension_fallback() {
1765 let dir = tempfile::tempdir().expect("create temp dir");
1766 let canonical = dunce::canonicalize(dir.path()).unwrap();
1767 let src = canonical.join("src");
1768 std::fs::create_dir_all(&src).unwrap();
1769 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1770
1771 let result = resolve_entry_path(
1772 &canonical,
1773 "src/index",
1774 &canonical,
1775 EntryPointSource::PackageJsonMain,
1776 );
1777 assert!(
1778 result.is_some(),
1779 "should resolve via extension fallback when exact path doesn't exist"
1780 );
1781 let ep = result.unwrap();
1782 assert!(
1783 ep.path.to_string_lossy().contains("index.ts"),
1784 "should find index.ts via extension fallback"
1785 );
1786 }
1787
1788 #[test]
1789 fn exact_file_wins_before_directory_index_fallback() {
1790 let dir = tempfile::tempdir().expect("create temp dir");
1791 let canonical = dunce::canonicalize(dir.path()).unwrap();
1792 let scripts = canonical.join("scripts");
1793 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1794 std::fs::write(
1795 scripts.join("process-messages.js"),
1796 "export const direct = true;",
1797 )
1798 .unwrap();
1799 std::fs::write(
1800 scripts.join("process-messages").join("index.js"),
1801 "export const index = true;",
1802 )
1803 .unwrap();
1804
1805 let result = resolve_entry_path(
1806 &canonical,
1807 "scripts/process-messages.js",
1808 &canonical,
1809 EntryPointSource::PackageJsonScript,
1810 )
1811 .expect("exact file should resolve");
1812
1813 assert!(result.path.ends_with("scripts/process-messages.js"));
1814 }
1815
1816 #[test]
1817 fn extension_fallback_wins_before_directory_index_fallback() {
1818 let dir = tempfile::tempdir().expect("create temp dir");
1819 let canonical = dunce::canonicalize(dir.path()).unwrap();
1820 let scripts = canonical.join("scripts");
1821 std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1822 std::fs::write(
1823 scripts.join("process-messages.ts"),
1824 "export const withExt = true;",
1825 )
1826 .unwrap();
1827 std::fs::write(
1828 scripts.join("process-messages").join("index.js"),
1829 "export const index = true;",
1830 )
1831 .unwrap();
1832
1833 let result = resolve_entry_path(
1834 &canonical,
1835 "scripts/process-messages",
1836 &canonical,
1837 EntryPointSource::PackageJsonScript,
1838 )
1839 .expect("extension fallback should resolve");
1840
1841 assert!(result.path.ends_with("scripts/process-messages.ts"));
1842 }
1843
1844 #[test]
1845 fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1846 let dir = tempfile::tempdir().expect("create temp dir");
1847 let canonical = dunce::canonicalize(dir.path()).unwrap();
1848 let scripts = canonical.join("scripts/process-messages");
1849 std::fs::create_dir_all(&scripts).unwrap();
1850 std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1851
1852 let result = resolve_entry_path(
1853 &canonical,
1854 "scripts/process-messages",
1855 &canonical,
1856 EntryPointSource::PackageJsonScript,
1857 )
1858 .expect("directory index should resolve");
1859
1860 assert!(result.path.ends_with("scripts/process-messages/index.js"));
1861 }
1862
1863 #[test]
1864 fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1865 let dir = tempfile::tempdir().expect("create temp dir");
1866 let canonical = dunce::canonicalize(dir.path()).unwrap();
1867 std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1868 std::fs::write(
1869 canonical.join("scripts/process-messages/index.js"),
1870 "export const index = true;",
1871 )
1872 .unwrap();
1873
1874 for entry in [
1875 "scripts/*",
1876 "https://example.com/scripts/process-messages",
1877 "@scope/package/scripts/process-messages",
1878 ] {
1879 let result = resolve_entry_path(
1880 &canonical,
1881 entry,
1882 &canonical,
1883 EntryPointSource::PackageJsonScript,
1884 );
1885 assert!(result.is_none(), "{entry} should not resolve");
1886 }
1887 }
1888
1889 #[test]
1890 fn returns_none_for_nonexistent_file() {
1891 let dir = tempfile::tempdir().expect("create temp dir");
1892 let canonical = dunce::canonicalize(dir.path()).unwrap();
1893 let result = resolve_entry_path(
1894 dir.path(),
1895 "does/not/exist.ts",
1896 &canonical,
1897 EntryPointSource::PackageJsonMain,
1898 );
1899 assert!(result.is_none(), "should return None for nonexistent files");
1900 }
1901
1902 #[test]
1903 fn maps_dist_output_to_src() {
1904 let dir = tempfile::tempdir().expect("create temp dir");
1905 let src = dir.path().join("src");
1906 std::fs::create_dir_all(&src).unwrap();
1907 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1908
1909 let dist = dir.path().join("dist");
1910 std::fs::create_dir_all(&dist).unwrap();
1911 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1912
1913 let canonical = dunce::canonicalize(dir.path()).unwrap();
1914 let result = resolve_entry_path(
1915 dir.path(),
1916 "./dist/utils.js",
1917 &canonical,
1918 EntryPointSource::PackageJsonExports,
1919 );
1920 assert!(result.is_some(), "should resolve dist/ path to src/");
1921 let ep = result.unwrap();
1922 assert!(
1923 ep.path
1924 .to_string_lossy()
1925 .replace('\\', "/")
1926 .contains("src/utils.ts"),
1927 "should map ./dist/utils.js to src/utils.ts"
1928 );
1929 }
1930
1931 #[test]
1932 fn maps_build_output_to_src() {
1933 let dir = tempfile::tempdir().expect("create temp dir");
1934 let canonical = dunce::canonicalize(dir.path()).unwrap();
1935 let src = canonical.join("src");
1936 std::fs::create_dir_all(&src).unwrap();
1937 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1938
1939 let result = resolve_entry_path(
1940 &canonical,
1941 "./build/index.js",
1942 &canonical,
1943 EntryPointSource::PackageJsonExports,
1944 );
1945 assert!(result.is_some(), "should map build/ output to src/");
1946 let ep = result.unwrap();
1947 assert!(
1948 ep.path
1949 .to_string_lossy()
1950 .replace('\\', "/")
1951 .contains("src/index.tsx"),
1952 "should map ./build/index.js to src/index.tsx"
1953 );
1954 }
1955
1956 #[test]
1957 fn preserves_entry_point_source() {
1958 let dir = tempfile::tempdir().expect("create temp dir");
1959 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1960
1961 let canonical = dunce::canonicalize(dir.path()).unwrap();
1962 let result = resolve_entry_path(
1963 dir.path(),
1964 "index.ts",
1965 &canonical,
1966 EntryPointSource::PackageJsonScript,
1967 );
1968 assert!(result.is_some());
1969 assert!(
1970 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1971 "should preserve the source kind"
1972 );
1973 }
1974 #[test]
1975 fn tracks_skipped_entries_without_logging_each_repeat() {
1976 let dir = tempfile::tempdir().expect("create temp dir");
1977 let canonical = dunce::canonicalize(dir.path()).unwrap();
1978 let mut skipped_entries = FxHashMap::default();
1979
1980 let result = resolve_entry_path_with_tracking(
1981 dir.path(),
1982 "../scripts/build.js",
1983 &canonical,
1984 EntryPointSource::PackageJsonScript,
1985 Some(&mut skipped_entries),
1986 );
1987
1988 assert!(result.is_none(), "unsafe entry should be skipped");
1989 assert_eq!(
1990 skipped_entries.get("../scripts/build.js"),
1991 Some(&1),
1992 "warning tracker should count the skipped path"
1993 );
1994 }
1995
1996 #[test]
1997 fn formats_skipped_entry_warning_with_counts() {
1998 let mut skipped_entries = FxHashMap::default();
1999 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
2000 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
2001
2002 let warning =
2003 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
2004
2005 assert_eq!(
2006 warning,
2007 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
2008 );
2009 }
2010
2011 #[test]
2012 fn skipped_entry_summary_dedupes_identical_messages() {
2013 let message = format!(
2014 "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
2015 std::process::id()
2016 );
2017 assert!(
2018 should_warn_skipped_entry(&message),
2019 "first occurrence of a message emits"
2020 );
2021 assert!(
2022 !should_warn_skipped_entry(&message),
2023 "identical repeat is suppressed"
2024 );
2025 }
2026
2027 #[test]
2028 fn rejects_parent_dir_escape_for_exact_file() {
2029 let sandbox = tempfile::tempdir().expect("create sandbox");
2030 let root = sandbox.path().join("project");
2031 std::fs::create_dir_all(&root).unwrap();
2032 std::fs::write(
2033 sandbox.path().join("escape.ts"),
2034 "export const escape = true;",
2035 )
2036 .unwrap();
2037
2038 let canonical = dunce::canonicalize(&root).unwrap();
2039 let result = resolve_entry_path(
2040 &root,
2041 "../escape.ts",
2042 &canonical,
2043 EntryPointSource::PackageJsonMain,
2044 );
2045
2046 assert!(
2047 result.is_none(),
2048 "should reject exact paths that escape the root"
2049 );
2050 }
2051
2052 #[test]
2053 fn rejects_parent_dir_escape_via_extension_fallback() {
2054 let sandbox = tempfile::tempdir().expect("create sandbox");
2055 let root = sandbox.path().join("project");
2056 std::fs::create_dir_all(&root).unwrap();
2057 std::fs::write(
2058 sandbox.path().join("escape.ts"),
2059 "export const escape = true;",
2060 )
2061 .unwrap();
2062
2063 let canonical = dunce::canonicalize(&root).unwrap();
2064 let result = resolve_entry_path(
2065 &root,
2066 "../escape",
2067 &canonical,
2068 EntryPointSource::PackageJsonMain,
2069 );
2070
2071 assert!(
2072 result.is_none(),
2073 "should reject extension fallback paths that escape the root"
2074 );
2075 }
2076 }
2077
2078 mod output_to_source_tests {
2079 use super::*;
2080
2081 #[test]
2082 fn maps_dist_to_src_with_ts_extension() {
2083 let dir = tempfile::tempdir().expect("create temp dir");
2084 let src = dir.path().join("src");
2085 std::fs::create_dir_all(&src).unwrap();
2086 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2087
2088 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
2089 assert!(result.is_some());
2090 assert!(
2091 result
2092 .unwrap()
2093 .to_string_lossy()
2094 .replace('\\', "/")
2095 .contains("src/utils.ts")
2096 );
2097 }
2098
2099 #[test]
2100 fn returns_none_when_no_source_file_exists() {
2101 let dir = tempfile::tempdir().expect("create temp dir");
2102 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
2103 assert!(result.is_none());
2104 }
2105
2106 #[test]
2107 fn ignores_non_output_directories() {
2108 let dir = tempfile::tempdir().expect("create temp dir");
2109 let src = dir.path().join("src");
2110 std::fs::create_dir_all(&src).unwrap();
2111 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2112
2113 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
2114 assert!(result.is_none());
2115 }
2116
2117 #[test]
2118 fn maps_nested_output_path_preserving_prefix() {
2119 let dir = tempfile::tempdir().expect("create temp dir");
2120 let modules_src = dir.path().join("modules").join("src");
2121 std::fs::create_dir_all(&modules_src).unwrap();
2122 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
2123
2124 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
2125 assert!(result.is_some());
2126 assert!(
2127 result
2128 .unwrap()
2129 .to_string_lossy()
2130 .replace('\\', "/")
2131 .contains("modules/src/helper.ts")
2132 );
2133 }
2134 }
2135
2136 mod source_index_fallback_tests {
2137 use super::*;
2138
2139 #[test]
2140 fn detects_dist_entry_in_output_dir() {
2141 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
2142 assert!(is_entry_in_output_dir("dist/index.js"));
2143 assert!(is_entry_in_output_dir("./build/index.js"));
2144 assert!(is_entry_in_output_dir("./out/main.js"));
2145 assert!(is_entry_in_output_dir("./esm/index.js"));
2146 assert!(is_entry_in_output_dir("./cjs/index.js"));
2147 }
2148
2149 #[test]
2150 fn rejects_non_output_entry_paths() {
2151 assert!(!is_entry_in_output_dir("./src/index.ts"));
2152 assert!(!is_entry_in_output_dir("src/main.ts"));
2153 assert!(!is_entry_in_output_dir("./index.js"));
2154 assert!(!is_entry_in_output_dir(""));
2155 }
2156
2157 #[test]
2158 fn root_index_entries_are_recognized_for_source_fallback() {
2159 assert!(is_package_root_index_entry("./index.js"));
2160 assert!(is_package_root_index_entry("index.cjs"));
2161 assert!(is_package_root_index_entry("./index.d.ts"));
2162 assert!(!is_package_root_index_entry("./src/index.js"));
2163 assert!(!is_package_root_index_entry("./main.js"));
2164 assert!(!is_package_root_index_entry(""));
2165 }
2166
2167 #[test]
2168 fn rejects_substring_match_for_output_dir() {
2169 assert!(!is_entry_in_output_dir("./distro/index.js"));
2170 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
2171 }
2172
2173 #[test]
2174 fn finds_src_index_ts() {
2175 let dir = tempfile::tempdir().expect("create temp dir");
2176 let src = dir.path().join("src");
2177 std::fs::create_dir_all(&src).unwrap();
2178 let index_path = src.join("index.ts");
2179 std::fs::write(&index_path, "export const a = 1;").unwrap();
2180
2181 let result = try_source_index_fallback(dir.path());
2182 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2183 }
2184
2185 #[test]
2186 fn finds_src_index_tsx_when_ts_missing() {
2187 let dir = tempfile::tempdir().expect("create temp dir");
2188 let src = dir.path().join("src");
2189 std::fs::create_dir_all(&src).unwrap();
2190 let index_path = src.join("index.tsx");
2191 std::fs::write(&index_path, "export default 1;").unwrap();
2192
2193 let result = try_source_index_fallback(dir.path());
2194 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2195 }
2196
2197 #[test]
2198 fn prefers_src_index_over_root_index() {
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 src_index = src.join("index.ts");
2203 std::fs::write(&src_index, "export const a = 1;").unwrap();
2204 let root_index = dir.path().join("index.ts");
2205 std::fs::write(&root_index, "export const b = 2;").unwrap();
2206
2207 let result = try_source_index_fallback(dir.path());
2208 assert_eq!(result.as_deref(), Some(src_index.as_path()));
2209 }
2210
2211 #[test]
2212 fn falls_back_to_src_main() {
2213 let dir = tempfile::tempdir().expect("create temp dir");
2214 let src = dir.path().join("src");
2215 std::fs::create_dir_all(&src).unwrap();
2216 let main_path = src.join("main.ts");
2217 std::fs::write(&main_path, "export const a = 1;").unwrap();
2218
2219 let result = try_source_index_fallback(dir.path());
2220 assert_eq!(result.as_deref(), Some(main_path.as_path()));
2221 }
2222
2223 #[test]
2224 fn falls_back_to_root_index_when_no_src() {
2225 let dir = tempfile::tempdir().expect("create temp dir");
2226 let index_path = dir.path().join("index.js");
2227 std::fs::write(&index_path, "module.exports = {};").unwrap();
2228
2229 let result = try_source_index_fallback(dir.path());
2230 assert_eq!(result.as_deref(), Some(index_path.as_path()));
2231 }
2232
2233 #[test]
2234 fn returns_none_when_nothing_matches() {
2235 let dir = tempfile::tempdir().expect("create temp dir");
2236 let result = try_source_index_fallback(dir.path());
2237 assert!(result.is_none());
2238 }
2239
2240 #[test]
2241 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
2242 let dir = tempfile::tempdir().expect("create temp dir");
2243 let canonical = dunce::canonicalize(dir.path()).unwrap();
2244
2245 let dist_dir = canonical.join("dist").join("esm2022");
2246 std::fs::create_dir_all(&dist_dir).unwrap();
2247 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
2248
2249 let src = canonical.join("src");
2250 std::fs::create_dir_all(&src).unwrap();
2251 let src_index = src.join("index.ts");
2252 std::fs::write(&src_index, "export const x = 1;").unwrap();
2253
2254 let result = resolve_entry_path(
2255 &canonical,
2256 "./dist/esm2022/index.js",
2257 &canonical,
2258 EntryPointSource::PackageJsonMain,
2259 );
2260 assert!(result.is_some());
2261 let entry = result.unwrap();
2262 assert_eq!(entry.path, src_index);
2263 }
2264
2265 #[test]
2266 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2267 let dir = tempfile::tempdir().expect("create temp dir");
2268 let canonical = dunce::canonicalize(dir.path()).unwrap();
2269
2270 let src_mirror = canonical.join("src").join("esm2022");
2271 std::fs::create_dir_all(&src_mirror).unwrap();
2272 let mirror_index = src_mirror.join("index.ts");
2273 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2274
2275 let src_index = canonical.join("src").join("index.ts");
2276 std::fs::write(&src_index, "export const y = 2;").unwrap();
2277
2278 let result = resolve_entry_path(
2279 &canonical,
2280 "./dist/esm2022/index.js",
2281 &canonical,
2282 EntryPointSource::PackageJsonMain,
2283 );
2284 assert_eq!(result.map(|e| e.path), Some(mirror_index));
2285 }
2286
2287 #[test]
2288 fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2289 let dir = tempfile::tempdir().expect("create temp dir");
2290 let canonical = dunce::canonicalize(dir.path()).unwrap();
2291
2292 let src = canonical.join("src");
2293 std::fs::create_dir_all(&src).unwrap();
2294 let src_index = src.join("index.ts");
2295 std::fs::write(&src_index, "export const x = 1;").unwrap();
2296
2297 let result = resolve_entry_path(
2298 &canonical,
2299 "./index.js",
2300 &canonical,
2301 EntryPointSource::PackageJsonMain,
2302 );
2303 assert_eq!(result.map(|entry| entry.path), Some(src_index));
2304 }
2305 }
2306
2307 mod default_fallback_tests {
2308 use super::*;
2309
2310 #[test]
2311 fn finds_src_index_ts_as_fallback() {
2312 let dir = tempfile::tempdir().expect("create temp dir");
2313 let src = dir.path().join("src");
2314 std::fs::create_dir_all(&src).unwrap();
2315 let index_path = src.join("index.ts");
2316 std::fs::write(&index_path, "export const a = 1;").unwrap();
2317
2318 let files = vec![DiscoveredFile {
2319 id: FileId(0),
2320 path: index_path.clone(),
2321 size_bytes: 20,
2322 }];
2323
2324 let entries = apply_default_fallback(&files, dir.path(), None);
2325 assert_eq!(entries.len(), 1);
2326 assert_eq!(entries[0].path, index_path);
2327 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2328 }
2329
2330 #[test]
2331 fn finds_root_index_js_as_fallback() {
2332 let dir = tempfile::tempdir().expect("create temp dir");
2333 let index_path = dir.path().join("index.js");
2334 std::fs::write(&index_path, "module.exports = {};").unwrap();
2335
2336 let files = vec![DiscoveredFile {
2337 id: FileId(0),
2338 path: index_path.clone(),
2339 size_bytes: 21,
2340 }];
2341
2342 let entries = apply_default_fallback(&files, dir.path(), None);
2343 assert_eq!(entries.len(), 1);
2344 assert_eq!(entries[0].path, index_path);
2345 }
2346
2347 #[test]
2348 fn returns_empty_when_no_index_file() {
2349 let dir = tempfile::tempdir().expect("create temp dir");
2350 let other_path = dir.path().join("src").join("utils.ts");
2351
2352 let files = vec![DiscoveredFile {
2353 id: FileId(0),
2354 path: other_path,
2355 size_bytes: 10,
2356 }];
2357
2358 let entries = apply_default_fallback(&files, dir.path(), None);
2359 assert!(
2360 entries.is_empty(),
2361 "non-index files should not match default fallback"
2362 );
2363 }
2364
2365 #[test]
2366 fn workspace_filter_restricts_scope() {
2367 let dir = tempfile::tempdir().expect("create temp dir");
2368 let ws_a = dir.path().join("packages").join("a").join("src");
2369 std::fs::create_dir_all(&ws_a).unwrap();
2370 let ws_b = dir.path().join("packages").join("b").join("src");
2371 std::fs::create_dir_all(&ws_b).unwrap();
2372
2373 let index_a = ws_a.join("index.ts");
2374 let index_b = ws_b.join("index.ts");
2375
2376 let files = vec![
2377 DiscoveredFile {
2378 id: FileId(0),
2379 path: index_a.clone(),
2380 size_bytes: 10,
2381 },
2382 DiscoveredFile {
2383 id: FileId(1),
2384 path: index_b,
2385 size_bytes: 10,
2386 },
2387 ];
2388
2389 let ws_root = dir.path().join("packages").join("a");
2390 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2391 assert_eq!(entries.len(), 1);
2392 assert_eq!(entries[0].path, index_a);
2393 }
2394 }
2395
2396 mod wildcard_entry_tests {
2397 use super::*;
2398
2399 #[test]
2400 fn expands_wildcard_css_entries() {
2401 let dir = tempfile::tempdir().expect("create temp dir");
2402 let themes = dir.path().join("src").join("themes");
2403 std::fs::create_dir_all(&themes).unwrap();
2404 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2405 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2406
2407 let canonical = dunce::canonicalize(dir.path()).unwrap();
2408 let mut entries = Vec::new();
2409 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2410
2411 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2412 let paths: Vec<String> = entries
2413 .iter()
2414 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2415 .collect();
2416 assert!(paths.contains(&"dark.css".to_string()));
2417 assert!(paths.contains(&"light.css".to_string()));
2418 assert!(
2419 entries
2420 .iter()
2421 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2422 );
2423 }
2424
2425 #[test]
2426 fn wildcard_does_not_match_nonexistent_files() {
2427 let dir = tempfile::tempdir().expect("create temp dir");
2428 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2429
2430 let canonical = dunce::canonicalize(dir.path()).unwrap();
2431 let mut entries = Vec::new();
2432 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2433
2434 assert!(
2435 entries.is_empty(),
2436 "should return empty when no files match the wildcard"
2437 );
2438 }
2439
2440 #[test]
2441 fn wildcard_only_matches_specified_extension() {
2442 let dir = tempfile::tempdir().expect("create temp dir");
2443 let themes = dir.path().join("src").join("themes");
2444 std::fs::create_dir_all(&themes).unwrap();
2445 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2446 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2447
2448 let canonical = dunce::canonicalize(dir.path()).unwrap();
2449 let mut entries = Vec::new();
2450 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2451
2452 assert_eq!(entries.len(), 1, "should only match CSS files");
2453 assert!(
2454 entries[0]
2455 .path
2456 .file_name()
2457 .unwrap()
2458 .to_string_lossy()
2459 .ends_with(".css")
2460 );
2461 }
2462 }
2463
2464 fn config_for(root: &Path) -> ResolvedConfig {
2465 FallowConfig::default().resolve(
2466 root.to_path_buf(),
2467 OutputFormat::Human,
2468 1,
2469 true,
2470 true,
2471 None,
2472 )
2473 }
2474
2475 fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
2476 DiscoveredFile {
2477 id: FileId(id),
2478 path: root.join(rel),
2479 size_bytes: 1,
2480 }
2481 }
2482
2483 #[test]
2484 fn root_entry_points_use_package_json_scripts() {
2485 let dir = tempfile::tempdir().expect("tempdir");
2486 let root = dir.path();
2487 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2488 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2489 .expect("script file");
2490 std::fs::write(
2491 root.join("package.json"),
2492 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2493 )
2494 .expect("package file");
2495 let config = config_for(root);
2496 let files = [discovered(root, "scripts/build.ts", 0)];
2497
2498 let entries = discover_entry_points(&config, &files);
2499
2500 assert_eq!(entries.len(), 1);
2501 assert!(entries[0].path.ends_with("scripts/build.ts"));
2502 assert!(matches!(
2503 entries[0].source,
2504 EntryPointSource::PackageJsonScript
2505 ));
2506 }
2507
2508 #[test]
2509 fn build_script_is_support_while_default_index_stays_runtime() {
2510 let dir = tempfile::tempdir().expect("tempdir");
2511 let root = dir.path();
2512 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2513 std::fs::create_dir_all(root.join("src")).expect("src dir");
2514 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2515 .expect("script file");
2516 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2517 std::fs::write(
2518 root.join("package.json"),
2519 r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2520 )
2521 .expect("package file");
2522 let config = config_for(root);
2523 let files = [
2524 discovered(root, "scripts/build.ts", 0),
2525 discovered(root, "src/index.ts", 1),
2526 ];
2527
2528 let discovery = discover_entry_points_with_warnings(&config, &files);
2529
2530 assert_eq!(discovery.entries.len(), 1);
2531 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2532 assert_eq!(discovery.support_entries.len(), 1);
2533 assert!(
2534 discovery.support_entries[0]
2535 .path
2536 .ends_with("scripts/build.ts")
2537 );
2538 }
2539
2540 #[test]
2541 fn indirect_quoted_start_script_stays_runtime() {
2542 let dir = tempfile::tempdir().expect("tempdir");
2543 let root = dir.path();
2544 std::fs::create_dir_all(root.join("src")).expect("src dir");
2545 std::fs::write(root.join("src/server.ts"), "export const server = true;")
2546 .expect("server file");
2547 std::fs::write(
2548 root.join("package.json"),
2549 r#"{"scripts":{"start":"npm run serve","serve":"node 'src/server.ts'"}}"#,
2550 )
2551 .expect("package file");
2552 let config = config_for(root);
2553 let files = [discovered(root, "src/server.ts", 0)];
2554
2555 let discovery = discover_entry_points_with_warnings(&config, &files);
2556
2557 assert_eq!(discovery.entries.len(), 1);
2558 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2559 assert!(discovery.support_entries.is_empty());
2560 }
2561
2562 #[test]
2563 fn indirect_start_script_includes_its_lifecycle_hooks() {
2564 let dir = tempfile::tempdir().expect("tempdir");
2565 let root = dir.path();
2566 std::fs::create_dir_all(root.join("src")).expect("src dir");
2567 for file in ["pre.ts", "server.ts", "post.ts"] {
2568 std::fs::write(root.join("src").join(file), "export const value = true;")
2569 .expect("runtime file");
2570 }
2571 std::fs::write(
2572 root.join("package.json"),
2573 r#"{"scripts":{"start":"npm run serve","preserve":"node src/pre.ts","serve":"node src/server.ts","postserve":"node src/post.ts"}}"#,
2574 )
2575 .expect("package file");
2576 let config = config_for(root);
2577 let files = [
2578 discovered(root, "src/pre.ts", 0),
2579 discovered(root, "src/server.ts", 1),
2580 discovered(root, "src/post.ts", 2),
2581 ];
2582
2583 let discovery = discover_entry_points_with_warnings(&config, &files);
2584
2585 assert_eq!(discovery.entries.len(), 3);
2586 assert!(discovery.support_entries.is_empty());
2587 }
2588
2589 #[test]
2590 #[expect(
2591 clippy::disallowed_types,
2592 reason = "test input matches the serde-deserialized package.json scripts map"
2593 )]
2594 fn start_does_not_recursively_invent_hooks_for_lifecycle_hooks() {
2595 let scripts = std::collections::HashMap::from([
2596 ("preprestart".to_string(), "node src/tool.ts".to_string()),
2597 ("prestart".to_string(), "node src/pre.ts".to_string()),
2598 ("start".to_string(), "node src/server.ts".to_string()),
2599 ("poststart".to_string(), "node src/post.ts".to_string()),
2600 ("postpoststart".to_string(), "node src/tool.ts".to_string()),
2601 ]);
2602
2603 let runtime = runtime_package_script_names(&scripts);
2604
2605 assert_eq!(
2606 runtime,
2607 FxHashSet::from_iter([
2608 "prestart".to_string(),
2609 "start".to_string(),
2610 "poststart".to_string(),
2611 ])
2612 );
2613 }
2614
2615 #[test]
2616 fn bun_build_script_remains_support_entry() {
2617 let dir = tempfile::tempdir().expect("tempdir");
2618 let root = dir.path();
2619 std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2620 std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2621 .expect("build file");
2622 std::fs::write(
2623 root.join("package.json"),
2624 r#"{"scripts":{"build":"bun scripts/build.ts"}}"#,
2625 )
2626 .expect("package file");
2627 let config = config_for(root);
2628 let files = [discovered(root, "scripts/build.ts", 0)];
2629
2630 let discovery = discover_entry_points_with_warnings(&config, &files);
2631
2632 assert!(discovery.entries.is_empty());
2633 assert_eq!(discovery.support_entries.len(), 1);
2634 }
2635
2636 #[test]
2637 fn start_script_config_remains_support_only() {
2638 let dir = tempfile::tempdir().expect("tempdir");
2639 let root = dir.path();
2640 std::fs::write(root.join("vite.config.ts"), "export default {};").expect("config file");
2641 std::fs::write(
2642 root.join("package.json"),
2643 r#"{"scripts":{"start":"vite --config vite.config.ts"}}"#,
2644 )
2645 .expect("package file");
2646 let config = config_for(root);
2647 let files = [discovered(root, "vite.config.ts", 0)];
2648
2649 let discovery = discover_entry_points_with_warnings(&config, &files);
2650
2651 assert!(discovery.entries.is_empty());
2652 assert_eq!(discovery.support_entries.len(), 1);
2653 assert!(
2654 discovery.support_entries[0]
2655 .path
2656 .ends_with("vite.config.ts")
2657 );
2658 }
2659
2660 #[test]
2661 fn manifest_entry_remains_runtime_when_build_script_references_same_path() {
2662 let dir = tempfile::tempdir().expect("tempdir");
2663 let root = dir.path();
2664 std::fs::create_dir_all(root.join("src")).expect("src dir");
2665 std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2666 std::fs::write(
2667 root.join("package.json"),
2668 r#"{"main":"src/index.ts","scripts":{"build":"tsx src/index.ts"}}"#,
2669 )
2670 .expect("package file");
2671 let config = config_for(root);
2672 let files = [discovered(root, "src/index.ts", 0)];
2673
2674 let discovery = discover_entry_points_with_warnings(&config, &files);
2675
2676 assert_eq!(discovery.entries.len(), 1);
2677 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2678 }
2679
2680 #[test]
2681 fn workspace_build_script_is_support_with_runtime_fallback() {
2682 let dir = tempfile::tempdir().expect("tempdir");
2683 let root = dir.path();
2684 let workspace = root.join("packages/app");
2685 std::fs::create_dir_all(workspace.join("scripts")).expect("scripts dir");
2686 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2687 std::fs::write(
2688 workspace.join("scripts/build.ts"),
2689 "export const build = true;",
2690 )
2691 .expect("script file");
2692 std::fs::write(workspace.join("src/index.ts"), "export const app = true;")
2693 .expect("index file");
2694 std::fs::write(
2695 workspace.join("package.json"),
2696 r#"{"name":"app","scripts":{"build":"tsx scripts/build.ts"}}"#,
2697 )
2698 .expect("package file");
2699 let files = [
2700 discovered(root, "packages/app/scripts/build.ts", 0),
2701 discovered(root, "packages/app/src/index.ts", 1),
2702 ];
2703
2704 let discovery =
2705 discover_workspace_entry_points_with_warnings(&workspace, &config_for(root), &files);
2706
2707 assert_eq!(discovery.entries.len(), 1);
2708 assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2709 assert_eq!(discovery.support_entries.len(), 1);
2710 assert!(
2711 discovery.support_entries[0]
2712 .path
2713 .ends_with("scripts/build.ts")
2714 );
2715 }
2716
2717 #[test]
2718 fn workspace_script_selected_by_root_start_is_runtime() {
2719 let dir = tempfile::tempdir().expect("tempdir");
2720 let root = dir.path();
2721 let workspace = root.join("packages/api");
2722 std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2723 std::fs::write(workspace.join("src/server.ts"), "export const app = true;")
2724 .expect("server file");
2725 let workspace_pkg: PackageJson = serde_json::from_str(
2726 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2727 )
2728 .expect("workspace package");
2729 let files = [discovered(root, "packages/api/src/server.ts", 0)];
2730 let seeds = FxHashSet::from_iter(["serve".to_string()]);
2731
2732 let discovery = discover_workspace_entry_points_with_runtime_scripts(
2733 &workspace,
2734 &files,
2735 Some(&workspace_pkg),
2736 &seeds,
2737 );
2738
2739 assert_eq!(discovery.entries.len(), 1);
2740 assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2741 assert!(discovery.support_entries.is_empty());
2742 }
2743
2744 #[test]
2745 fn workspace_script_selection_does_not_promote_same_named_root_script() {
2746 let dir = tempfile::tempdir().expect("tempdir");
2747 let root = dir.path();
2748 let root_pkg: PackageJson = serde_json::from_str(
2749 r#"{
2750 "scripts": {
2751 "start": "pnpm --filter=@scope/api run serve",
2752 "serve": "node src/unrelated.ts"
2753 }
2754 }"#,
2755 )
2756 .expect("root package");
2757 let api_pkg: PackageJson = serde_json::from_str(
2758 r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2759 )
2760 .expect("api package");
2761 let workspace_pkgs = vec![(
2762 fallow_config::WorkspaceInfo {
2763 root: root.join("packages/api"),
2764 name: "@scope/api".to_string(),
2765 is_internal_dependency: false,
2766 },
2767 api_pkg,
2768 )];
2769 let scripts = root_pkg.scripts.as_ref().expect("root scripts");
2770
2771 let runtime = runtime_package_script_names(scripts);
2772 let workspace_seeds =
2773 workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2774
2775 assert_eq!(runtime, FxHashSet::from_iter(["start".to_string()]));
2776 assert_eq!(
2777 workspace_seeds.get("@scope/api"),
2778 Some(&FxHashSet::from_iter(["serve".to_string()]))
2779 );
2780 }
2781
2782 #[test]
2783 fn workspace_runtime_script_seeds_follow_transitive_workspace_calls() {
2784 let dir = tempfile::tempdir().expect("tempdir");
2785 let root = dir.path();
2786 let root_pkg: PackageJson =
2787 serde_json::from_str(r#"{"scripts":{"start":"pnpm --filter @scope/api run serve"}}"#)
2788 .expect("root package");
2789 let api_pkg: PackageJson = serde_json::from_str(
2790 r#"{"name":"@scope/api","scripts":{"serve":"npm --workspace packages/worker run work"}}"#,
2791 )
2792 .expect("api package");
2793 let worker_pkg: PackageJson = serde_json::from_str(
2794 r#"{"name":"@scope/worker","scripts":{"prework":"node pre.ts","work":"node worker.ts"}}"#,
2795 )
2796 .expect("worker package");
2797 let workspace_pkgs = vec![
2798 (
2799 fallow_config::WorkspaceInfo {
2800 root: root.join("packages/api"),
2801 name: "@scope/api".to_string(),
2802 is_internal_dependency: false,
2803 },
2804 api_pkg,
2805 ),
2806 (
2807 fallow_config::WorkspaceInfo {
2808 root: root.join("packages/worker"),
2809 name: "@scope/worker".to_string(),
2810 is_internal_dependency: false,
2811 },
2812 worker_pkg,
2813 ),
2814 ];
2815
2816 let seeds = workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2817
2818 assert_eq!(
2819 seeds.get("@scope/api"),
2820 Some(&FxHashSet::from_iter(["serve".to_string()]))
2821 );
2822 assert_eq!(
2823 seeds.get("@scope/worker"),
2824 Some(&FxHashSet::from_iter([
2825 "prework".to_string(),
2826 "work".to_string(),
2827 ]))
2828 );
2829 }
2830
2831 #[test]
2832 fn root_entry_points_fall_back_to_default_index() {
2833 let dir = tempfile::tempdir().expect("tempdir");
2834 let root = dir.path();
2835 std::fs::create_dir_all(root.join("src")).expect("src dir");
2836 std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
2837 let config = config_for(root);
2838 let files = [discovered(root, "src/index.ts", 0)];
2839
2840 let entries = discover_entry_points(&config, &files);
2841
2842 assert_eq!(entries.len(), 1);
2843 assert!(entries[0].path.ends_with("src/index.ts"));
2844 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2845 }
2846
2847 #[test]
2848 fn default_index_matchers_preserve_patterns_and_are_shared() {
2849 let matchers = default_index_matchers();
2850 for path in [
2851 "src/index.ts",
2852 "src/index.tsx",
2853 "src/index.js",
2854 "src/index.jsx",
2855 "src/main.ts",
2856 "src/main.tsx",
2857 "src/main.js",
2858 "src/main.jsx",
2859 "index.ts",
2860 "index.tsx",
2861 "index.js",
2862 "index.jsx",
2863 "main.ts",
2864 "main.tsx",
2865 "main.js",
2866 "main.jsx",
2867 ] {
2868 assert!(
2869 matchers.is_match(path),
2870 "default index did not match {path}"
2871 );
2872 }
2873 for path in [
2874 "src/index.mts",
2875 "src/nested/index.ts",
2876 "packages/app/src/index.ts",
2877 "custom-entry.ts",
2878 ] {
2879 assert!(!matchers.is_match(path), "non-default index matched {path}");
2880 }
2881
2882 std::thread::scope(|scope| {
2883 let handles = std::iter::repeat_with(|| scope.spawn(default_index_matchers))
2884 .take(4)
2885 .collect::<Vec<_>>();
2886 for handle in handles {
2887 let from_thread = handle.join().expect("fallback matcher thread");
2888 assert!(std::ptr::eq(matchers, from_thread));
2889 }
2890 });
2891 }
2892
2893 #[test]
2894 fn workspace_entry_points_use_workspace_package_json() {
2895 let dir = tempfile::tempdir().expect("tempdir");
2896 let root = dir.path();
2897 let ws_root = root.join("packages/app");
2898 std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
2899 std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
2900 .expect("workspace main");
2901 std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
2902 .expect("workspace package file");
2903 let config = config_for(root);
2904 let files = [discovered(root, "packages/app/src/main.ts", 0)];
2905
2906 let entries = discover_workspace_entry_points(&ws_root, &config, &files);
2907
2908 assert_eq!(entries.len(), 1);
2909 assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
2910 assert!(matches!(
2911 entries[0].source,
2912 EntryPointSource::PackageJsonMain
2913 ));
2914 }
2915}