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