Skip to main content

fallow_core/discover/
entry_points.rs

1use std::path::{Component, Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7use fallow_types::path_util::is_absolute_path_any_platform;
8use rustc_hash::{FxHashMap, FxHashSet};
9
10/// Known output directory names from exports maps.
11/// When an entry point path is inside one of these directories, we also try
12/// the `src/` equivalent to find the tracked source file.
13const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
14const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
15
16fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
17    if skipped_entries.is_empty() {
18        return None;
19    }
20
21    let mut entries = skipped_entries
22        .iter()
23        .map(|(path, count)| (path.as_str(), *count))
24        .collect::<Vec<_>>();
25    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
26
27    let preview = entries
28        .iter()
29        .take(SKIPPED_ENTRY_WARNING_PREVIEW)
30        .map(|(path, count)| {
31            if *count > 1 {
32                format!("{path} ({count}x)")
33            } else {
34                (*path).to_owned()
35            }
36        })
37        .collect::<Vec<_>>();
38
39    let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
40    let tail = if omitted > 0 {
41        format!(" (and {omitted} more)")
42    } else {
43        String::new()
44    };
45    let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
46    let noun = if total == 1 {
47        "package.json entry point"
48    } else {
49        "package.json entry points"
50    };
51
52    Some(format!(
53        "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
54        preview.join(", ")
55    ))
56}
57
58pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
59    let Some(message) = format_skipped_entry_warning(skipped_entries) else {
60        return;
61    };
62    if should_warn_skipped_entry(&message) {
63        tracing::warn!("{message}");
64    }
65}
66
67/// Process-wide dedupe for [`warn_skipped_entry_summary`]. Returns `true` when
68/// `message` was newly inserted (caller should emit). On a poisoned mutex
69/// returns `true` so over-warning beats swallowing.
70fn should_warn_skipped_entry(message: &str) -> bool {
71    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
72        std::sync::OnceLock::new();
73    let warned = WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()));
74    warned
75        .lock()
76        .map_or(true, |mut set| set.insert(message.to_owned()))
77}
78
79/// Entry points grouped by reachability role.
80#[derive(Debug, Clone, Default)]
81pub struct CategorizedEntryPoints {
82    pub all: Vec<EntryPoint>,
83    pub runtime: Vec<EntryPoint>,
84    pub test: Vec<EntryPoint>,
85}
86
87impl CategorizedEntryPoints {
88    pub fn push_runtime(&mut self, entry: EntryPoint) {
89        self.runtime.push(entry.clone());
90        self.all.push(entry);
91    }
92
93    pub fn push_test(&mut self, entry: EntryPoint) {
94        self.test.push(entry.clone());
95        self.all.push(entry);
96    }
97
98    pub fn push_support(&mut self, entry: EntryPoint) {
99        self.all.push(entry);
100    }
101
102    pub fn extend_runtime<I>(&mut self, entries: I)
103    where
104        I: IntoIterator<Item = EntryPoint>,
105    {
106        for entry in entries {
107            self.push_runtime(entry);
108        }
109    }
110
111    pub fn extend_test<I>(&mut self, entries: I)
112    where
113        I: IntoIterator<Item = EntryPoint>,
114    {
115        for entry in entries {
116            self.push_test(entry);
117        }
118    }
119
120    pub fn extend_support<I>(&mut self, entries: I)
121    where
122        I: IntoIterator<Item = EntryPoint>,
123    {
124        for entry in entries {
125            self.push_support(entry);
126        }
127    }
128
129    pub fn extend(&mut self, other: Self) {
130        self.all.extend(other.all);
131        self.runtime.extend(other.runtime);
132        self.test.extend(other.test);
133    }
134
135    #[must_use]
136    pub fn dedup(mut self) -> Self {
137        dedup_entry_paths(&mut self.all);
138        dedup_entry_paths(&mut self.runtime);
139        dedup_entry_paths(&mut self.test);
140        self
141    }
142}
143
144fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
145    entries.sort_by(|a, b| a.path.cmp(&b.path));
146    entries.dedup_by(|a, b| a.path == b.path);
147}
148
149#[derive(Debug, Default)]
150pub struct EntryPointDiscovery {
151    pub entries: Vec<EntryPoint>,
152    pub skipped_entries: FxHashMap<String, usize>,
153}
154
155/// Resolve a path relative to a base directory, with security check and extension fallback.
156///
157/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
158/// trying source extensions as fallback when the exact path doesn't exist.
159/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
160/// by trying to map back to the source file (e.g., `./src/utils.ts`).
161fn resolve_entry_path_with_tracking(
162    base: &Path,
163    entry: &str,
164    canonical_root: &Path,
165    source: EntryPointSource,
166    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
167) -> Option<EntryPoint> {
168    if entry.contains('*') {
169        return None;
170    }
171
172    if entry_has_parent_dir(entry) {
173        if let Some(skipped_entries) = skipped_entries.as_mut() {
174            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
175        } else {
176            tracing::warn!(path = %entry, "Skipping entry point containing parent directory traversal");
177        }
178        return None;
179    }
180
181    let resolved = base.join(entry);
182
183    if let Some(source_path) = try_output_to_source_path(base, entry) {
184        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
185    }
186
187    if is_entry_in_output_dir(entry)
188        && let Some(source_path) = try_source_index_fallback(base)
189    {
190        tracing::info!(
191            entry = %entry,
192            fallback = %source_path.display(),
193            "package.json entry resolves to an ignored output directory; falling back to source index"
194        );
195        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
196    }
197
198    if resolved.is_file() {
199        return validated_entry_point(
200            &resolved,
201            canonical_root,
202            entry,
203            source,
204            skipped_entries.as_deref_mut(),
205        );
206    }
207
208    for ext in SOURCE_EXTENSIONS {
209        let with_ext = resolved.with_extension(ext);
210        if with_ext.is_file() {
211            return validated_entry_point(
212                &with_ext,
213                canonical_root,
214                entry,
215                source,
216                skipped_entries.as_deref_mut(),
217            );
218        }
219    }
220
221    if let Some(index_entry) = try_directory_index_entry(&resolved) {
222        return validated_entry_point(
223            &index_entry,
224            canonical_root,
225            entry,
226            source,
227            skipped_entries.as_deref_mut(),
228        );
229    }
230
231    if is_package_root_index_entry(entry)
232        && let Some(source_path) = try_source_index_fallback(base)
233    {
234        tracing::info!(
235            entry = %entry,
236            fallback = %source_path.display(),
237            "package.json root index entry is missing; falling back to source index"
238        );
239        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
240    }
241    None
242}
243
244fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
245    for ext in SOURCE_EXTENSIONS {
246        let candidate = resolved.join(format!("index.{ext}"));
247        if candidate.is_file() {
248            return Some(candidate);
249        }
250    }
251    None
252}
253
254fn entry_has_parent_dir(entry: &str) -> bool {
255    Path::new(entry)
256        .components()
257        .any(|component| matches!(component, Component::ParentDir))
258}
259
260fn is_package_root_index_entry(entry: &str) -> bool {
261    let mut components = Path::new(entry)
262        .components()
263        .filter(|component| !matches!(component, Component::CurDir));
264
265    let Some(Component::Normal(file_name)) = components.next() else {
266        return false;
267    };
268    if components.next().is_some() {
269        return false;
270    }
271
272    file_name
273        .to_str()
274        .is_some_and(|name| name == "index" || name.starts_with("index."))
275}
276
277fn validated_entry_point(
278    candidate: &Path,
279    canonical_root: &Path,
280    entry: &str,
281    source: EntryPointSource,
282    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
283) -> Option<EntryPoint> {
284    let canonical_candidate = match dunce::canonicalize(candidate) {
285        Ok(path) => path,
286        Err(err) => {
287            tracing::warn!(
288                path = %candidate.display(),
289                %entry,
290                error = %err,
291                "Skipping entry point that could not be canonicalized"
292            );
293            return None;
294        }
295    };
296
297    if !canonical_candidate.starts_with(canonical_root) {
298        if let Some(skipped_entries) = skipped_entries.as_mut() {
299            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
300        } else {
301            tracing::warn!(
302                path = %candidate.display(),
303                %entry,
304                "Skipping entry point outside project root"
305            );
306        }
307        return None;
308    }
309
310    Some(EntryPoint {
311        path: candidate.to_path_buf(),
312        source,
313    })
314}
315
316pub fn resolve_entry_path(
317    base: &Path,
318    entry: &str,
319    canonical_root: &Path,
320    source: EntryPointSource,
321) -> Option<EntryPoint> {
322    resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
323}
324/// Try to map an entry path from an output directory to its source equivalent.
325///
326/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
327/// - `/project/packages/ui/src/utils.ts`
328/// - `/project/packages/ui/src/utils.tsx`
329/// - etc. for all source extensions
330///
331/// Preserves any path prefix between the package root and the output dir,
332/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
333///
334/// Returns `Some(path)` if a source file is found.
335fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
336    let entry_path = Path::new(entry);
337    let components: Vec<_> = entry_path.components().collect();
338
339    let output_pos = components.iter().rposition(|c| {
340        if let std::path::Component::Normal(s) = c
341            && let Some(name) = s.to_str()
342        {
343            return OUTPUT_DIRS.contains(&name);
344        }
345        false
346    })?;
347
348    let prefix: PathBuf = components[..output_pos]
349        .iter()
350        .filter(|c| !matches!(c, std::path::Component::CurDir))
351        .collect();
352
353    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
354
355    for ext in SOURCE_EXTENSIONS {
356        let source_candidate = base
357            .join(&prefix)
358            .join("src")
359            .join(suffix.with_extension(ext));
360        if source_candidate.exists() {
361            return Some(source_candidate);
362        }
363    }
364
365    None
366}
367
368/// Conventional source index file stems probed when a package.json entry lives
369/// in an ignored output directory. Ordered by preference.
370const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
371
372/// Return `true` when `entry` contains a known output directory component.
373///
374/// Matches any segment in `OUTPUT_DIRS`, e.g. `./dist/esm2022/index.js` →
375/// `true`, `src/main.ts` → `false`.
376fn is_entry_in_output_dir(entry: &str) -> bool {
377    Path::new(entry).components().any(|c| {
378        matches!(
379            c,
380            std::path::Component::Normal(s)
381                if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
382        )
383    })
384}
385
386/// Probe a package root for a conventional source index file.
387///
388/// Used when `package.json` points at compiled output but the canonical source
389/// entry is a standard TypeScript/JavaScript index file. Tries `src/index`,
390/// `src/main`, `index`, and `main` with each supported source extension, in
391/// that order.
392fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
393    for stem in SOURCE_INDEX_FALLBACK_STEMS {
394        for ext in SOURCE_EXTENSIONS {
395            let candidate = base.join(format!("{stem}.{ext}"));
396            if candidate.is_file() {
397                return Some(candidate);
398            }
399        }
400    }
401    None
402}
403
404/// Default index patterns used when no other entry points are found.
405const DEFAULT_INDEX_PATTERNS: &[&str] = &[
406    "src/index.{ts,tsx,js,jsx}",
407    "src/main.{ts,tsx,js,jsx}",
408    "index.{ts,tsx,js,jsx}",
409    "main.{ts,tsx,js,jsx}",
410];
411
412/// Fall back to default index patterns if no entries were found.
413///
414/// When `ws_filter` is `Some`, only files whose path starts with the given
415/// workspace root are considered (used for workspace-scoped discovery).
416fn apply_default_fallback(
417    files: &[DiscoveredFile],
418    root: &Path,
419    ws_filter: Option<&Path>,
420) -> Vec<EntryPoint> {
421    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
422        .iter()
423        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
424        .collect();
425
426    let mut entries = Vec::new();
427    for file in files {
428        if let Some(ws_root) = ws_filter
429            && file.path.strip_prefix(ws_root).is_err()
430        {
431            continue;
432        }
433        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
434        let relative_str = relative.to_string_lossy();
435        if default_matchers
436            .iter()
437            .any(|m| m.is_match(relative_str.as_ref()))
438        {
439            entries.push(EntryPoint {
440                path: file.path.clone(),
441                source: EntryPointSource::DefaultIndex,
442            });
443        }
444    }
445    entries
446}
447
448/// Discover entry points from package.json, framework rules, and defaults.
449#[expect(
450    clippy::expect_used,
451    reason = "entry glob patterns are validated before entry point discovery"
452)]
453fn discover_entry_points_with_warnings_impl(
454    config: &ResolvedConfig,
455    files: &[DiscoveredFile],
456    root_pkg: Option<&PackageJson>,
457    include_nested_package_entries: bool,
458) -> EntryPointDiscovery {
459    let _span = tracing::info_span!("discover_entry_points").entered();
460    let mut discovery = EntryPointDiscovery::default();
461
462    let relative_paths: Vec<String> = files
463        .iter()
464        .map(|f| {
465            f.path
466                .strip_prefix(&config.root)
467                .unwrap_or(&f.path)
468                .to_string_lossy()
469                .into_owned()
470        })
471        .collect();
472
473    {
474        let mut builder = globset::GlobSetBuilder::new();
475        for pattern in &config.entry_patterns {
476            builder.add(
477                globset::Glob::new(pattern)
478                    .expect("entry pattern was validated at config load time"),
479            );
480        }
481        if let Ok(glob_set) = builder.build()
482            && !glob_set.is_empty()
483        {
484            for (idx, rel) in relative_paths.iter().enumerate() {
485                if glob_set.is_match(rel) {
486                    discovery.entries.push(EntryPoint {
487                        path: files[idx].path.clone(),
488                        source: EntryPointSource::ManualEntry,
489                    });
490                }
491            }
492        }
493    }
494
495    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
496    if let Some(pkg) = root_pkg {
497        for entry_path in pkg.entry_points() {
498            if let Some(ep) = resolve_entry_path_with_tracking(
499                &config.root,
500                &entry_path,
501                &canonical_root,
502                EntryPointSource::PackageJsonMain,
503                Some(&mut discovery.skipped_entries),
504            ) {
505                discovery.entries.push(ep);
506            }
507        }
508
509        if let Some(scripts) = &pkg.scripts {
510            for script_value in scripts.values() {
511                for file_ref in extract_script_file_refs(script_value) {
512                    if let Some(ep) = resolve_entry_path_with_tracking(
513                        &config.root,
514                        &file_ref,
515                        &canonical_root,
516                        EntryPointSource::PackageJsonScript,
517                        Some(&mut discovery.skipped_entries),
518                    ) {
519                        discovery.entries.push(ep);
520                    }
521                }
522            }
523        }
524    }
525
526    if include_nested_package_entries {
527        let exports_dirs = root_pkg
528            .map(PackageJson::exports_subdirectories)
529            .unwrap_or_default();
530        discover_nested_package_entries(
531            &config.root,
532            files,
533            &mut discovery.entries,
534            &canonical_root,
535            &exports_dirs,
536            &mut discovery.skipped_entries,
537        );
538    }
539
540    if discovery.entries.is_empty() {
541        discovery.entries = apply_default_fallback(files, &config.root, None);
542    }
543
544    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
545    discovery.entries.dedup_by(|a, b| a.path == b.path);
546
547    discovery
548}
549
550pub fn discover_entry_points_with_warnings_from_pkg(
551    config: &ResolvedConfig,
552    files: &[DiscoveredFile],
553    root_pkg: Option<&PackageJson>,
554    include_nested_package_entries: bool,
555) -> EntryPointDiscovery {
556    discover_entry_points_with_warnings_impl(
557        config,
558        files,
559        root_pkg,
560        include_nested_package_entries,
561    )
562}
563
564pub fn discover_entry_points_with_warnings(
565    config: &ResolvedConfig,
566    files: &[DiscoveredFile],
567) -> EntryPointDiscovery {
568    let pkg_path = config.root.join("package.json");
569    let root_pkg = PackageJson::load(&pkg_path).ok();
570    discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
571}
572
573pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
574    let discovery = discover_entry_points_with_warnings(config, files);
575    warn_skipped_entry_summary(&discovery.skipped_entries);
576    discovery.entries
577}
578
579/// Discover entry points from nested package.json files in subdirectories.
580///
581/// Scans two sources for sub-packages:
582/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
583/// 2. Directories derived from the root package.json `exports` map keys
584///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
585///
586/// For each discovered sub-package with a `package.json`, the `main`, `module`,
587/// `source`, `exports`, and `bin` fields are treated as entry points.
588fn discover_nested_package_entries(
589    root: &Path,
590    _files: &[DiscoveredFile],
591    entries: &mut Vec<EntryPoint>,
592    canonical_root: &Path,
593    exports_subdirectories: &[String],
594    skipped_entries: &mut FxHashMap<String, usize>,
595) {
596    let mut visited = rustc_hash::FxHashSet::default();
597
598    let search_dirs = [
599        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
600    ];
601    for dir_name in &search_dirs {
602        let search_dir = root.join(dir_name);
603        if !search_dir.is_dir() {
604            continue;
605        }
606        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
607            continue;
608        };
609        for entry in read_dir.flatten() {
610            let pkg_dir = entry.path();
611            if visited.insert(pkg_dir.clone()) {
612                collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
613            }
614        }
615    }
616
617    for dir_name in exports_subdirectories {
618        let pkg_dir = root.join(dir_name);
619        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
620            collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
621        }
622    }
623}
624
625/// Collect entry points from a single sub-package directory.
626fn collect_nested_package_entries(
627    pkg_dir: &Path,
628    entries: &mut Vec<EntryPoint>,
629    canonical_root: &Path,
630    skipped_entries: &mut FxHashMap<String, usize>,
631) {
632    let pkg_path = pkg_dir.join("package.json");
633    if !pkg_path.exists() {
634        return;
635    }
636    let Ok(pkg) = PackageJson::load(&pkg_path) else {
637        return;
638    };
639    for entry_path in pkg.entry_points() {
640        if entry_path.contains('*') {
641            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
642        } else if let Some(ep) = resolve_entry_path_with_tracking(
643            pkg_dir,
644            &entry_path,
645            canonical_root,
646            EntryPointSource::PackageJsonExports,
647            Some(&mut *skipped_entries),
648        ) {
649            entries.push(ep);
650        }
651    }
652    if let Some(scripts) = &pkg.scripts {
653        for script_value in scripts.values() {
654            for file_ref in extract_script_file_refs(script_value) {
655                if let Some(ep) = resolve_entry_path_with_tracking(
656                    pkg_dir,
657                    &file_ref,
658                    canonical_root,
659                    EntryPointSource::PackageJsonScript,
660                    Some(&mut *skipped_entries),
661                ) {
662                    entries.push(ep);
663                }
664            }
665        }
666    }
667}
668
669/// Expand wildcard subpath exports to matching files on disk.
670///
671/// Handles patterns like `./src/themes/*.css` from package.json exports maps
672/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
673/// match actual files in the target directory.
674fn expand_wildcard_entries(
675    base: &Path,
676    pattern: &str,
677    canonical_root: &Path,
678    entries: &mut Vec<EntryPoint>,
679) {
680    let full_pattern = base.join(pattern).to_string_lossy().to_string();
681    let Ok(matches) = glob::glob(&full_pattern) else {
682        return;
683    };
684    for path_result in matches {
685        let Ok(path) = path_result else {
686            continue;
687        };
688        if let Ok(canonical) = dunce::canonicalize(&path)
689            && canonical.starts_with(canonical_root)
690        {
691            entries.push(EntryPoint {
692                path,
693                source: EntryPointSource::PackageJsonExports,
694            });
695        }
696    }
697}
698
699/// Discover entry points for a workspace package.
700#[must_use]
701fn discover_workspace_entry_points_with_warnings_impl(
702    ws_root: &Path,
703    all_files: &[DiscoveredFile],
704    pkg: Option<&PackageJson>,
705) -> EntryPointDiscovery {
706    let mut discovery = EntryPointDiscovery::default();
707
708    if let Some(pkg) = pkg {
709        let canonical_ws_root =
710            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
711        for entry_path in pkg.entry_points() {
712            if entry_path.contains('*') {
713                expand_wildcard_entries(
714                    ws_root,
715                    &entry_path,
716                    &canonical_ws_root,
717                    &mut discovery.entries,
718                );
719            } else if let Some(ep) = resolve_entry_path_with_tracking(
720                ws_root,
721                &entry_path,
722                &canonical_ws_root,
723                EntryPointSource::PackageJsonMain,
724                Some(&mut discovery.skipped_entries),
725            ) {
726                discovery.entries.push(ep);
727            }
728        }
729
730        if let Some(scripts) = &pkg.scripts {
731            for script_value in scripts.values() {
732                for file_ref in extract_script_file_refs(script_value) {
733                    if let Some(ep) = resolve_entry_path_with_tracking(
734                        ws_root,
735                        &file_ref,
736                        &canonical_ws_root,
737                        EntryPointSource::PackageJsonScript,
738                        Some(&mut discovery.skipped_entries),
739                    ) {
740                        discovery.entries.push(ep);
741                    }
742                }
743            }
744        }
745    }
746
747    if discovery.entries.is_empty() {
748        discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
749    }
750
751    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
752    discovery.entries.dedup_by(|a, b| a.path == b.path);
753    discovery
754}
755
756pub fn discover_workspace_entry_points_with_warnings_from_pkg(
757    ws_root: &Path,
758    all_files: &[DiscoveredFile],
759    pkg: Option<&PackageJson>,
760) -> EntryPointDiscovery {
761    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
762}
763
764#[must_use]
765pub fn discover_workspace_entry_points_with_warnings(
766    ws_root: &Path,
767    _config: &ResolvedConfig,
768    all_files: &[DiscoveredFile],
769) -> EntryPointDiscovery {
770    let pkg_path = ws_root.join("package.json");
771    let pkg = PackageJson::load(&pkg_path).ok();
772    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
773}
774
775#[must_use]
776pub fn discover_workspace_entry_points(
777    ws_root: &Path,
778    config: &ResolvedConfig,
779    all_files: &[DiscoveredFile],
780) -> Vec<EntryPoint> {
781    let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
782    warn_skipped_entry_summary(&discovery.skipped_entries);
783    discovery.entries
784}
785
786/// Discover entry points from plugin results (dynamic config parsing).
787///
788/// Converts plugin-discovered patterns and setup files into concrete entry points
789/// by matching them against the discovered file list.
790#[must_use]
791pub fn discover_plugin_entry_points(
792    plugin_result: &crate::plugins::AggregatedPluginResult,
793    config: &ResolvedConfig,
794    files: &[DiscoveredFile],
795) -> Vec<EntryPoint> {
796    discover_plugin_entry_point_sets(plugin_result, config, files).all
797}
798
799/// Discover plugin-derived entry points with runtime/test/support roles preserved.
800#[must_use]
801pub fn discover_plugin_entry_point_sets(
802    plugin_result: &crate::plugins::AggregatedPluginResult,
803    config: &ResolvedConfig,
804    files: &[DiscoveredFile],
805) -> CategorizedEntryPoints {
806    let mut entries = CategorizedEntryPoints::default();
807
808    let relative_paths: Vec<String> = files
809        .iter()
810        .map(|f| {
811            f.path
812                .strip_prefix(&config.root)
813                .unwrap_or(&f.path)
814                .to_string_lossy()
815                .into_owned()
816        })
817        .collect();
818
819    let mut builder = globset::GlobSetBuilder::new();
820    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
821    for (rule, pname) in &plugin_result.entry_patterns {
822        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
823            builder.add(include);
824            glob_meta.push(compiled);
825        }
826    }
827    for (pattern, pname) in plugin_result
828        .discovered_always_used
829        .iter()
830        .chain(plugin_result.always_used.iter())
831        .chain(plugin_result.fixture_patterns.iter())
832    {
833        if let Ok(glob) = globset::GlobBuilder::new(pattern)
834            .literal_separator(true)
835            .build()
836        {
837            builder.add(glob);
838            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
839                &crate::plugins::PathRule::new(pattern.clone()),
840                "support entry pattern",
841            ) {
842                glob_meta.push(CompiledEntryRule {
843                    path,
844                    plugin_name: pname,
845                    role: EntryPointRole::Support,
846                });
847            }
848        }
849    }
850    if let Ok(glob_set) = builder.build()
851        && !glob_set.is_empty()
852    {
853        for (idx, rel) in relative_paths.iter().enumerate() {
854            let matches: Vec<usize> = glob_set
855                .matches(rel)
856                .into_iter()
857                .filter(|match_idx| glob_meta[*match_idx].matches(rel))
858                .collect();
859            if !matches.is_empty() {
860                let name = glob_meta[matches[0]].plugin_name;
861                let entry = EntryPoint {
862                    path: files[idx].path.clone(),
863                    source: EntryPointSource::Plugin {
864                        name: name.to_string(),
865                    },
866                };
867
868                let mut has_runtime = false;
869                let mut has_test = false;
870                let mut has_support = false;
871                for match_idx in matches {
872                    match glob_meta[match_idx].role {
873                        EntryPointRole::Runtime => has_runtime = true,
874                        EntryPointRole::Test => has_test = true,
875                        EntryPointRole::Support => has_support = true,
876                    }
877                }
878
879                if has_runtime {
880                    entries.push_runtime(entry.clone());
881                }
882                if has_test {
883                    entries.push_test(entry.clone());
884                }
885                if has_support || (!has_runtime && !has_test) {
886                    entries.push_support(entry);
887                }
888            }
889        }
890    }
891
892    for (setup_file, pname) in &plugin_result.setup_files {
893        let resolved = resolve_plugin_setup_file(&config.root, setup_file);
894        if resolved.exists() {
895            entries.push_support(EntryPoint {
896                path: resolved,
897                source: EntryPointSource::Plugin {
898                    name: pname.clone(),
899                },
900            });
901        } else {
902            for ext in SOURCE_EXTENSIONS {
903                let with_ext = resolved.with_extension(ext);
904                if with_ext.exists() {
905                    entries.push_support(EntryPoint {
906                        path: with_ext,
907                        source: EntryPointSource::Plugin {
908                            name: pname.clone(),
909                        },
910                    });
911                    break;
912                }
913            }
914        }
915    }
916
917    entries.dedup()
918}
919
920fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
921    if is_absolute_path_any_platform(setup_file) {
922        setup_file.to_path_buf()
923    } else {
924        root.join(setup_file)
925    }
926}
927
928/// Discover entry points from `dynamicallyLoaded` config patterns.
929///
930/// Matches the configured glob patterns against the discovered file list and
931/// marks matching files as entry points so they are never flagged as unused.
932#[expect(
933    clippy::expect_used,
934    reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
935)]
936#[must_use]
937pub fn discover_dynamically_loaded_entry_points(
938    config: &ResolvedConfig,
939    files: &[DiscoveredFile],
940) -> Vec<EntryPoint> {
941    if config.dynamically_loaded.is_empty() {
942        return Vec::new();
943    }
944
945    let mut builder = globset::GlobSetBuilder::new();
946    for pattern in &config.dynamically_loaded {
947        builder.add(
948            globset::Glob::new(pattern)
949                .expect("dynamicallyLoaded pattern was validated at config load time"),
950        );
951    }
952    let Ok(glob_set) = builder.build() else {
953        return Vec::new();
954    };
955    if glob_set.is_empty() {
956        return Vec::new();
957    }
958
959    let mut entries = Vec::new();
960    for file in files {
961        let rel = file
962            .path
963            .strip_prefix(&config.root)
964            .unwrap_or(&file.path)
965            .to_string_lossy();
966        if glob_set.is_match(rel.as_ref()) {
967            entries.push(EntryPoint {
968                path: file.path.clone(),
969                source: EntryPointSource::DynamicallyLoaded,
970            });
971        }
972    }
973    entries
974}
975
976struct CompiledEntryRule<'a> {
977    path: crate::plugins::CompiledPathRule,
978    plugin_name: &'a str,
979    role: EntryPointRole,
980}
981
982impl CompiledEntryRule<'_> {
983    fn matches(&self, path: &str) -> bool {
984        self.path.matches(path)
985    }
986}
987
988fn compile_entry_rule<'a>(
989    rule: &'a crate::plugins::PathRule,
990    plugin_name: &'a str,
991    plugin_result: &'a crate::plugins::AggregatedPluginResult,
992) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
993    let include = match globset::GlobBuilder::new(&rule.pattern)
994        .literal_separator(true)
995        .build()
996    {
997        Ok(glob) => glob,
998        Err(err) => {
999            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1000            return None;
1001        }
1002    };
1003    let role = plugin_result
1004        .entry_point_roles
1005        .get(plugin_name)
1006        .copied()
1007        .unwrap_or(EntryPointRole::Support);
1008    Some((
1009        include,
1010        CompiledEntryRule {
1011            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1012            plugin_name,
1013            role,
1014        },
1015    ))
1016}
1017
1018/// Pre-compile a set of glob patterns for efficient matching against many paths.
1019#[must_use]
1020pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1021    if patterns.is_empty() {
1022        return None;
1023    }
1024    let mut builder = globset::GlobSetBuilder::new();
1025    for pattern in patterns {
1026        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1027            .literal_separator(true)
1028            .build()
1029        {
1030            builder.add(glob);
1031        }
1032    }
1033    builder.build().ok()
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1040    use fallow_types::discover::FileId;
1041    use proptest::prelude::*;
1042
1043    proptest! {
1044        /// Valid glob patterns should never panic when compiled via globset.
1045        #[test]
1046        fn glob_patterns_never_panic_on_compile(
1047            prefix in "[a-zA-Z0-9_]{1,20}",
1048            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1049        ) {
1050            let pattern = format!("**/{prefix}*.{ext}");
1051            let result = globset::Glob::new(&pattern);
1052            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1053        }
1054
1055        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
1056        #[test]
1057        fn non_source_extensions_not_in_list(
1058            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1059        ) {
1060            prop_assert!(
1061                !SOURCE_EXTENSIONS.contains(&ext),
1062                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1063            );
1064        }
1065
1066        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
1067        #[test]
1068        fn compile_glob_set_no_panic(
1069            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1070        ) {
1071            let _ = compile_glob_set(&patterns);
1072        }
1073    }
1074
1075    #[test]
1076    fn compile_glob_set_empty_input() {
1077        assert!(
1078            compile_glob_set(&[]).is_none(),
1079            "empty patterns should return None"
1080        );
1081    }
1082
1083    #[test]
1084    fn compile_glob_set_valid_patterns() {
1085        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1086        let set = compile_glob_set(&patterns);
1087        assert!(set.is_some(), "valid patterns should compile");
1088        let set = set.unwrap();
1089        assert!(set.is_match("src/foo.ts"));
1090        assert!(set.is_match("src/bar.js"));
1091        assert!(!set.is_match("src/bar.py"));
1092    }
1093
1094    #[test]
1095    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1096        let patterns = vec!["composables/*.{ts,js}".to_string()];
1097        let set = compile_glob_set(&patterns).expect("pattern should compile");
1098
1099        assert!(set.is_match("composables/useFoo.ts"));
1100        assert!(!set.is_match("composables/nested/useFoo.ts"));
1101    }
1102
1103    #[test]
1104    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1105        let dir = tempfile::tempdir().expect("create temp dir");
1106        let root = dir.path();
1107        std::fs::create_dir_all(root.join("src")).unwrap();
1108        std::fs::create_dir_all(root.join("tests")).unwrap();
1109        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1110        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1111        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1112
1113        let config = FallowConfig {
1114            schema: None,
1115            extends: vec![],
1116            entry: vec![],
1117            ignore_patterns: vec![],
1118            framework: vec![],
1119            workspaces: None,
1120            ignore_dependencies: vec![],
1121            ignore_unresolved_imports: vec![],
1122            ignore_exports: vec![],
1123            ignore_catalog_references: vec![],
1124            ignore_dependency_overrides: vec![],
1125            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1126            used_class_members: vec![],
1127            ignore_decorators: vec![],
1128            duplicates: fallow_config::DuplicatesConfig::default(),
1129            health: fallow_config::HealthConfig::default(),
1130            rules: RulesConfig::default(),
1131            boundaries: fallow_config::BoundaryConfig::default(),
1132            production: false.into(),
1133            plugins: vec![],
1134            rule_packs: vec![],
1135            dynamically_loaded: vec![],
1136            overrides: vec![],
1137            regression: None,
1138            audit: fallow_config::AuditConfig::default(),
1139            codeowners: None,
1140            public_packages: vec![],
1141            flags: fallow_config::FlagsConfig::default(),
1142            security: fallow_config::SecurityConfig::default(),
1143            fix: fallow_config::FixConfig::default(),
1144            resolve: fallow_config::ResolveConfig::default(),
1145            sealed: false,
1146            include_entry_exports: false,
1147            auto_imports: false,
1148            cache: fallow_config::CacheConfig::default(),
1149        }
1150        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1151
1152        let files = vec![
1153            DiscoveredFile {
1154                id: FileId(0),
1155                path: root.join("src/runtime.ts"),
1156                size_bytes: 1,
1157            },
1158            DiscoveredFile {
1159                id: FileId(1),
1160                path: root.join("src/setup.ts"),
1161                size_bytes: 1,
1162            },
1163            DiscoveredFile {
1164                id: FileId(2),
1165                path: root.join("tests/app.test.ts"),
1166                size_bytes: 1,
1167            },
1168        ];
1169
1170        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1171        plugin_result.entry_patterns.push((
1172            crate::plugins::PathRule::new("src/runtime.ts"),
1173            "runtime-plugin".to_string(),
1174        ));
1175        plugin_result.entry_patterns.push((
1176            crate::plugins::PathRule::new("tests/app.test.ts"),
1177            "test-plugin".to_string(),
1178        ));
1179        plugin_result
1180            .always_used
1181            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1182        plugin_result
1183            .entry_point_roles
1184            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1185        plugin_result
1186            .entry_point_roles
1187            .insert("test-plugin".to_string(), EntryPointRole::Test);
1188        plugin_result
1189            .entry_point_roles
1190            .insert("support-plugin".to_string(), EntryPointRole::Support);
1191
1192        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1193
1194        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1195        assert!(
1196            entries.runtime[0].path.ends_with("src/runtime.ts"),
1197            "runtime entry should stay runtime-only"
1198        );
1199        assert_eq!(entries.test.len(), 1, "expected one test entry");
1200        assert!(
1201            entries.test[0].path.ends_with("tests/app.test.ts"),
1202            "test entry should stay test-only"
1203        );
1204        assert_eq!(
1205            entries.all.len(),
1206            3,
1207            "support entries should stay in all entries"
1208        );
1209        assert!(
1210            entries
1211                .all
1212                .iter()
1213                .any(|entry| entry.path.ends_with("src/setup.ts")),
1214            "support entries should remain in the overall entry-point set"
1215        );
1216        assert!(
1217            !entries
1218                .runtime
1219                .iter()
1220                .any(|entry| entry.path.ends_with("src/setup.ts")),
1221            "support entries should not bleed into runtime reachability"
1222        );
1223        assert!(
1224            !entries
1225                .test
1226                .iter()
1227                .any(|entry| entry.path.ends_with("src/setup.ts")),
1228            "support entries should not bleed into test reachability"
1229        );
1230    }
1231
1232    #[test]
1233    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1234        let root = Path::new("/workspace/project");
1235        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1236
1237        assert_eq!(
1238            resolve_plugin_setup_file(root, setup_file),
1239            setup_file.to_path_buf()
1240        );
1241    }
1242
1243    #[cfg(windows)]
1244    #[test]
1245    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1246        let root = Path::new(r"C:\workspace\project");
1247        let setup_file = Path::new(r"/workspace/project/setup.ts");
1248
1249        assert_eq!(
1250            resolve_plugin_setup_file(root, setup_file),
1251            setup_file.to_path_buf()
1252        );
1253    }
1254
1255    #[test]
1256    fn plugin_entry_point_rules_respect_exclusions() {
1257        let dir = tempfile::tempdir().expect("create temp dir");
1258        let root = dir.path();
1259        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1260        std::fs::write(
1261            root.join("app/pages/index.tsx"),
1262            "export default function Page() { return null; }",
1263        )
1264        .unwrap();
1265        std::fs::write(
1266            root.join("app/pages/-helper.ts"),
1267            "export const helper = 1;",
1268        )
1269        .unwrap();
1270
1271        let config = FallowConfig {
1272            schema: None,
1273            extends: vec![],
1274            entry: vec![],
1275            ignore_patterns: vec![],
1276            framework: vec![],
1277            workspaces: None,
1278            ignore_dependencies: vec![],
1279            ignore_unresolved_imports: vec![],
1280            ignore_exports: vec![],
1281            ignore_catalog_references: vec![],
1282            ignore_dependency_overrides: vec![],
1283            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1284            used_class_members: vec![],
1285            ignore_decorators: vec![],
1286            duplicates: fallow_config::DuplicatesConfig::default(),
1287            health: fallow_config::HealthConfig::default(),
1288            rules: RulesConfig::default(),
1289            boundaries: fallow_config::BoundaryConfig::default(),
1290            production: false.into(),
1291            plugins: vec![],
1292            rule_packs: vec![],
1293            dynamically_loaded: vec![],
1294            overrides: vec![],
1295            regression: None,
1296            audit: fallow_config::AuditConfig::default(),
1297            codeowners: None,
1298            public_packages: vec![],
1299            flags: fallow_config::FlagsConfig::default(),
1300            security: fallow_config::SecurityConfig::default(),
1301            fix: fallow_config::FixConfig::default(),
1302            resolve: fallow_config::ResolveConfig::default(),
1303            sealed: false,
1304            include_entry_exports: false,
1305            auto_imports: false,
1306            cache: fallow_config::CacheConfig::default(),
1307        }
1308        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1309
1310        let files = vec![
1311            DiscoveredFile {
1312                id: FileId(0),
1313                path: root.join("app/pages/index.tsx"),
1314                size_bytes: 1,
1315            },
1316            DiscoveredFile {
1317                id: FileId(1),
1318                path: root.join("app/pages/-helper.ts"),
1319                size_bytes: 1,
1320            },
1321        ];
1322
1323        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1324        plugin_result.entry_patterns.push((
1325            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1326                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1327            "tanstack-router".to_string(),
1328        ));
1329        plugin_result
1330            .entry_point_roles
1331            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1332
1333        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1334        let entry_paths: Vec<_> = entries
1335            .all
1336            .iter()
1337            .map(|entry| {
1338                entry
1339                    .path
1340                    .strip_prefix(root)
1341                    .unwrap()
1342                    .to_string_lossy()
1343                    .into_owned()
1344            })
1345            .collect();
1346
1347        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1348        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1349    }
1350
1351    mod resolve_entry_path_tests {
1352        use super::*;
1353
1354        #[test]
1355        fn resolves_existing_file() {
1356            let dir = tempfile::tempdir().expect("create temp dir");
1357            let src = dir.path().join("src");
1358            std::fs::create_dir_all(&src).unwrap();
1359            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1360
1361            let canonical = dunce::canonicalize(dir.path()).unwrap();
1362            let result = resolve_entry_path(
1363                dir.path(),
1364                "src/index.ts",
1365                &canonical,
1366                EntryPointSource::PackageJsonMain,
1367            );
1368            assert!(result.is_some(), "should resolve an existing file");
1369            assert!(result.unwrap().path.ends_with("src/index.ts"));
1370        }
1371
1372        #[test]
1373        fn resolves_with_extension_fallback() {
1374            let dir = tempfile::tempdir().expect("create temp dir");
1375            let canonical = dunce::canonicalize(dir.path()).unwrap();
1376            let src = canonical.join("src");
1377            std::fs::create_dir_all(&src).unwrap();
1378            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1379
1380            let result = resolve_entry_path(
1381                &canonical,
1382                "src/index",
1383                &canonical,
1384                EntryPointSource::PackageJsonMain,
1385            );
1386            assert!(
1387                result.is_some(),
1388                "should resolve via extension fallback when exact path doesn't exist"
1389            );
1390            let ep = result.unwrap();
1391            assert!(
1392                ep.path.to_string_lossy().contains("index.ts"),
1393                "should find index.ts via extension fallback"
1394            );
1395        }
1396
1397        #[test]
1398        fn exact_file_wins_before_directory_index_fallback() {
1399            let dir = tempfile::tempdir().expect("create temp dir");
1400            let canonical = dunce::canonicalize(dir.path()).unwrap();
1401            let scripts = canonical.join("scripts");
1402            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1403            std::fs::write(
1404                scripts.join("process-messages.js"),
1405                "export const direct = true;",
1406            )
1407            .unwrap();
1408            std::fs::write(
1409                scripts.join("process-messages").join("index.js"),
1410                "export const index = true;",
1411            )
1412            .unwrap();
1413
1414            let result = resolve_entry_path(
1415                &canonical,
1416                "scripts/process-messages.js",
1417                &canonical,
1418                EntryPointSource::PackageJsonScript,
1419            )
1420            .expect("exact file should resolve");
1421
1422            assert!(result.path.ends_with("scripts/process-messages.js"));
1423        }
1424
1425        #[test]
1426        fn extension_fallback_wins_before_directory_index_fallback() {
1427            let dir = tempfile::tempdir().expect("create temp dir");
1428            let canonical = dunce::canonicalize(dir.path()).unwrap();
1429            let scripts = canonical.join("scripts");
1430            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1431            std::fs::write(
1432                scripts.join("process-messages.ts"),
1433                "export const withExt = true;",
1434            )
1435            .unwrap();
1436            std::fs::write(
1437                scripts.join("process-messages").join("index.js"),
1438                "export const index = true;",
1439            )
1440            .unwrap();
1441
1442            let result = resolve_entry_path(
1443                &canonical,
1444                "scripts/process-messages",
1445                &canonical,
1446                EntryPointSource::PackageJsonScript,
1447            )
1448            .expect("extension fallback should resolve");
1449
1450            assert!(result.path.ends_with("scripts/process-messages.ts"));
1451        }
1452
1453        #[test]
1454        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1455            let dir = tempfile::tempdir().expect("create temp dir");
1456            let canonical = dunce::canonicalize(dir.path()).unwrap();
1457            let scripts = canonical.join("scripts/process-messages");
1458            std::fs::create_dir_all(&scripts).unwrap();
1459            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1460
1461            let result = resolve_entry_path(
1462                &canonical,
1463                "scripts/process-messages",
1464                &canonical,
1465                EntryPointSource::PackageJsonScript,
1466            )
1467            .expect("directory index should resolve");
1468
1469            assert!(result.path.ends_with("scripts/process-messages/index.js"));
1470        }
1471
1472        #[test]
1473        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1474            let dir = tempfile::tempdir().expect("create temp dir");
1475            let canonical = dunce::canonicalize(dir.path()).unwrap();
1476            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1477            std::fs::write(
1478                canonical.join("scripts/process-messages/index.js"),
1479                "export const index = true;",
1480            )
1481            .unwrap();
1482
1483            for entry in [
1484                "scripts/*",
1485                "https://example.com/scripts/process-messages",
1486                "@scope/package/scripts/process-messages",
1487            ] {
1488                let result = resolve_entry_path(
1489                    &canonical,
1490                    entry,
1491                    &canonical,
1492                    EntryPointSource::PackageJsonScript,
1493                );
1494                assert!(result.is_none(), "{entry} should not resolve");
1495            }
1496        }
1497
1498        #[test]
1499        fn returns_none_for_nonexistent_file() {
1500            let dir = tempfile::tempdir().expect("create temp dir");
1501            let canonical = dunce::canonicalize(dir.path()).unwrap();
1502            let result = resolve_entry_path(
1503                dir.path(),
1504                "does/not/exist.ts",
1505                &canonical,
1506                EntryPointSource::PackageJsonMain,
1507            );
1508            assert!(result.is_none(), "should return None for nonexistent files");
1509        }
1510
1511        #[test]
1512        fn maps_dist_output_to_src() {
1513            let dir = tempfile::tempdir().expect("create temp dir");
1514            let src = dir.path().join("src");
1515            std::fs::create_dir_all(&src).unwrap();
1516            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1517
1518            let dist = dir.path().join("dist");
1519            std::fs::create_dir_all(&dist).unwrap();
1520            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1521
1522            let canonical = dunce::canonicalize(dir.path()).unwrap();
1523            let result = resolve_entry_path(
1524                dir.path(),
1525                "./dist/utils.js",
1526                &canonical,
1527                EntryPointSource::PackageJsonExports,
1528            );
1529            assert!(result.is_some(), "should resolve dist/ path to src/");
1530            let ep = result.unwrap();
1531            assert!(
1532                ep.path
1533                    .to_string_lossy()
1534                    .replace('\\', "/")
1535                    .contains("src/utils.ts"),
1536                "should map ./dist/utils.js to src/utils.ts"
1537            );
1538        }
1539
1540        #[test]
1541        fn maps_build_output_to_src() {
1542            let dir = tempfile::tempdir().expect("create temp dir");
1543            let canonical = dunce::canonicalize(dir.path()).unwrap();
1544            let src = canonical.join("src");
1545            std::fs::create_dir_all(&src).unwrap();
1546            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1547
1548            let result = resolve_entry_path(
1549                &canonical,
1550                "./build/index.js",
1551                &canonical,
1552                EntryPointSource::PackageJsonExports,
1553            );
1554            assert!(result.is_some(), "should map build/ output to src/");
1555            let ep = result.unwrap();
1556            assert!(
1557                ep.path
1558                    .to_string_lossy()
1559                    .replace('\\', "/")
1560                    .contains("src/index.tsx"),
1561                "should map ./build/index.js to src/index.tsx"
1562            );
1563        }
1564
1565        #[test]
1566        fn preserves_entry_point_source() {
1567            let dir = tempfile::tempdir().expect("create temp dir");
1568            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1569
1570            let canonical = dunce::canonicalize(dir.path()).unwrap();
1571            let result = resolve_entry_path(
1572                dir.path(),
1573                "index.ts",
1574                &canonical,
1575                EntryPointSource::PackageJsonScript,
1576            );
1577            assert!(result.is_some());
1578            assert!(
1579                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1580                "should preserve the source kind"
1581            );
1582        }
1583        #[test]
1584        fn tracks_skipped_entries_without_logging_each_repeat() {
1585            let dir = tempfile::tempdir().expect("create temp dir");
1586            let canonical = dunce::canonicalize(dir.path()).unwrap();
1587            let mut skipped_entries = FxHashMap::default();
1588
1589            let result = resolve_entry_path_with_tracking(
1590                dir.path(),
1591                "../scripts/build.js",
1592                &canonical,
1593                EntryPointSource::PackageJsonScript,
1594                Some(&mut skipped_entries),
1595            );
1596
1597            assert!(result.is_none(), "unsafe entry should be skipped");
1598            assert_eq!(
1599                skipped_entries.get("../scripts/build.js"),
1600                Some(&1),
1601                "warning tracker should count the skipped path"
1602            );
1603        }
1604
1605        #[test]
1606        fn formats_skipped_entry_warning_with_counts() {
1607            let mut skipped_entries = FxHashMap::default();
1608            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1609            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1610
1611            let warning =
1612                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1613
1614            assert_eq!(
1615                warning,
1616                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1617            );
1618        }
1619
1620        #[test]
1621        fn skipped_entry_summary_dedupes_identical_messages() {
1622            let message = format!(
1623                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
1624                std::process::id()
1625            );
1626            assert!(
1627                should_warn_skipped_entry(&message),
1628                "first occurrence of a message emits"
1629            );
1630            assert!(
1631                !should_warn_skipped_entry(&message),
1632                "identical repeat is suppressed"
1633            );
1634        }
1635
1636        #[test]
1637        fn rejects_parent_dir_escape_for_exact_file() {
1638            let sandbox = tempfile::tempdir().expect("create sandbox");
1639            let root = sandbox.path().join("project");
1640            std::fs::create_dir_all(&root).unwrap();
1641            std::fs::write(
1642                sandbox.path().join("escape.ts"),
1643                "export const escape = true;",
1644            )
1645            .unwrap();
1646
1647            let canonical = dunce::canonicalize(&root).unwrap();
1648            let result = resolve_entry_path(
1649                &root,
1650                "../escape.ts",
1651                &canonical,
1652                EntryPointSource::PackageJsonMain,
1653            );
1654
1655            assert!(
1656                result.is_none(),
1657                "should reject exact paths that escape the root"
1658            );
1659        }
1660
1661        #[test]
1662        fn rejects_parent_dir_escape_via_extension_fallback() {
1663            let sandbox = tempfile::tempdir().expect("create sandbox");
1664            let root = sandbox.path().join("project");
1665            std::fs::create_dir_all(&root).unwrap();
1666            std::fs::write(
1667                sandbox.path().join("escape.ts"),
1668                "export const escape = true;",
1669            )
1670            .unwrap();
1671
1672            let canonical = dunce::canonicalize(&root).unwrap();
1673            let result = resolve_entry_path(
1674                &root,
1675                "../escape",
1676                &canonical,
1677                EntryPointSource::PackageJsonMain,
1678            );
1679
1680            assert!(
1681                result.is_none(),
1682                "should reject extension fallback paths that escape the root"
1683            );
1684        }
1685    }
1686
1687    mod output_to_source_tests {
1688        use super::*;
1689
1690        #[test]
1691        fn maps_dist_to_src_with_ts_extension() {
1692            let dir = tempfile::tempdir().expect("create temp dir");
1693            let src = dir.path().join("src");
1694            std::fs::create_dir_all(&src).unwrap();
1695            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1696
1697            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1698            assert!(result.is_some());
1699            assert!(
1700                result
1701                    .unwrap()
1702                    .to_string_lossy()
1703                    .replace('\\', "/")
1704                    .contains("src/utils.ts")
1705            );
1706        }
1707
1708        #[test]
1709        fn returns_none_when_no_source_file_exists() {
1710            let dir = tempfile::tempdir().expect("create temp dir");
1711            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1712            assert!(result.is_none());
1713        }
1714
1715        #[test]
1716        fn ignores_non_output_directories() {
1717            let dir = tempfile::tempdir().expect("create temp dir");
1718            let src = dir.path().join("src");
1719            std::fs::create_dir_all(&src).unwrap();
1720            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1721
1722            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1723            assert!(result.is_none());
1724        }
1725
1726        #[test]
1727        fn maps_nested_output_path_preserving_prefix() {
1728            let dir = tempfile::tempdir().expect("create temp dir");
1729            let modules_src = dir.path().join("modules").join("src");
1730            std::fs::create_dir_all(&modules_src).unwrap();
1731            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1732
1733            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1734            assert!(result.is_some());
1735            assert!(
1736                result
1737                    .unwrap()
1738                    .to_string_lossy()
1739                    .replace('\\', "/")
1740                    .contains("modules/src/helper.ts")
1741            );
1742        }
1743    }
1744
1745    mod source_index_fallback_tests {
1746        use super::*;
1747
1748        #[test]
1749        fn detects_dist_entry_in_output_dir() {
1750            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1751            assert!(is_entry_in_output_dir("dist/index.js"));
1752            assert!(is_entry_in_output_dir("./build/index.js"));
1753            assert!(is_entry_in_output_dir("./out/main.js"));
1754            assert!(is_entry_in_output_dir("./esm/index.js"));
1755            assert!(is_entry_in_output_dir("./cjs/index.js"));
1756        }
1757
1758        #[test]
1759        fn rejects_non_output_entry_paths() {
1760            assert!(!is_entry_in_output_dir("./src/index.ts"));
1761            assert!(!is_entry_in_output_dir("src/main.ts"));
1762            assert!(!is_entry_in_output_dir("./index.js"));
1763            assert!(!is_entry_in_output_dir(""));
1764        }
1765
1766        #[test]
1767        fn root_index_entries_are_recognized_for_source_fallback() {
1768            assert!(is_package_root_index_entry("./index.js"));
1769            assert!(is_package_root_index_entry("index.cjs"));
1770            assert!(is_package_root_index_entry("./index.d.ts"));
1771            assert!(!is_package_root_index_entry("./src/index.js"));
1772            assert!(!is_package_root_index_entry("./main.js"));
1773            assert!(!is_package_root_index_entry(""));
1774        }
1775
1776        #[test]
1777        fn rejects_substring_match_for_output_dir() {
1778            assert!(!is_entry_in_output_dir("./distro/index.js"));
1779            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1780        }
1781
1782        #[test]
1783        fn finds_src_index_ts() {
1784            let dir = tempfile::tempdir().expect("create temp dir");
1785            let src = dir.path().join("src");
1786            std::fs::create_dir_all(&src).unwrap();
1787            let index_path = src.join("index.ts");
1788            std::fs::write(&index_path, "export const a = 1;").unwrap();
1789
1790            let result = try_source_index_fallback(dir.path());
1791            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1792        }
1793
1794        #[test]
1795        fn finds_src_index_tsx_when_ts_missing() {
1796            let dir = tempfile::tempdir().expect("create temp dir");
1797            let src = dir.path().join("src");
1798            std::fs::create_dir_all(&src).unwrap();
1799            let index_path = src.join("index.tsx");
1800            std::fs::write(&index_path, "export default 1;").unwrap();
1801
1802            let result = try_source_index_fallback(dir.path());
1803            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1804        }
1805
1806        #[test]
1807        fn prefers_src_index_over_root_index() {
1808            let dir = tempfile::tempdir().expect("create temp dir");
1809            let src = dir.path().join("src");
1810            std::fs::create_dir_all(&src).unwrap();
1811            let src_index = src.join("index.ts");
1812            std::fs::write(&src_index, "export const a = 1;").unwrap();
1813            let root_index = dir.path().join("index.ts");
1814            std::fs::write(&root_index, "export const b = 2;").unwrap();
1815
1816            let result = try_source_index_fallback(dir.path());
1817            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1818        }
1819
1820        #[test]
1821        fn falls_back_to_src_main() {
1822            let dir = tempfile::tempdir().expect("create temp dir");
1823            let src = dir.path().join("src");
1824            std::fs::create_dir_all(&src).unwrap();
1825            let main_path = src.join("main.ts");
1826            std::fs::write(&main_path, "export const a = 1;").unwrap();
1827
1828            let result = try_source_index_fallback(dir.path());
1829            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1830        }
1831
1832        #[test]
1833        fn falls_back_to_root_index_when_no_src() {
1834            let dir = tempfile::tempdir().expect("create temp dir");
1835            let index_path = dir.path().join("index.js");
1836            std::fs::write(&index_path, "module.exports = {};").unwrap();
1837
1838            let result = try_source_index_fallback(dir.path());
1839            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1840        }
1841
1842        #[test]
1843        fn returns_none_when_nothing_matches() {
1844            let dir = tempfile::tempdir().expect("create temp dir");
1845            let result = try_source_index_fallback(dir.path());
1846            assert!(result.is_none());
1847        }
1848
1849        #[test]
1850        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1851            let dir = tempfile::tempdir().expect("create temp dir");
1852            let canonical = dunce::canonicalize(dir.path()).unwrap();
1853
1854            let dist_dir = canonical.join("dist").join("esm2022");
1855            std::fs::create_dir_all(&dist_dir).unwrap();
1856            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1857
1858            let src = canonical.join("src");
1859            std::fs::create_dir_all(&src).unwrap();
1860            let src_index = src.join("index.ts");
1861            std::fs::write(&src_index, "export const x = 1;").unwrap();
1862
1863            let result = resolve_entry_path(
1864                &canonical,
1865                "./dist/esm2022/index.js",
1866                &canonical,
1867                EntryPointSource::PackageJsonMain,
1868            );
1869            assert!(result.is_some());
1870            let entry = result.unwrap();
1871            assert_eq!(entry.path, src_index);
1872        }
1873
1874        #[test]
1875        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1876            let dir = tempfile::tempdir().expect("create temp dir");
1877            let canonical = dunce::canonicalize(dir.path()).unwrap();
1878
1879            let src_mirror = canonical.join("src").join("esm2022");
1880            std::fs::create_dir_all(&src_mirror).unwrap();
1881            let mirror_index = src_mirror.join("index.ts");
1882            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1883
1884            let src_index = canonical.join("src").join("index.ts");
1885            std::fs::write(&src_index, "export const y = 2;").unwrap();
1886
1887            let result = resolve_entry_path(
1888                &canonical,
1889                "./dist/esm2022/index.js",
1890                &canonical,
1891                EntryPointSource::PackageJsonMain,
1892            );
1893            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1894        }
1895
1896        #[test]
1897        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
1898            let dir = tempfile::tempdir().expect("create temp dir");
1899            let canonical = dunce::canonicalize(dir.path()).unwrap();
1900
1901            let src = canonical.join("src");
1902            std::fs::create_dir_all(&src).unwrap();
1903            let src_index = src.join("index.ts");
1904            std::fs::write(&src_index, "export const x = 1;").unwrap();
1905
1906            let result = resolve_entry_path(
1907                &canonical,
1908                "./index.js",
1909                &canonical,
1910                EntryPointSource::PackageJsonMain,
1911            );
1912            assert_eq!(result.map(|entry| entry.path), Some(src_index));
1913        }
1914    }
1915
1916    mod default_fallback_tests {
1917        use super::*;
1918
1919        #[test]
1920        fn finds_src_index_ts_as_fallback() {
1921            let dir = tempfile::tempdir().expect("create temp dir");
1922            let src = dir.path().join("src");
1923            std::fs::create_dir_all(&src).unwrap();
1924            let index_path = src.join("index.ts");
1925            std::fs::write(&index_path, "export const a = 1;").unwrap();
1926
1927            let files = vec![DiscoveredFile {
1928                id: FileId(0),
1929                path: index_path.clone(),
1930                size_bytes: 20,
1931            }];
1932
1933            let entries = apply_default_fallback(&files, dir.path(), None);
1934            assert_eq!(entries.len(), 1);
1935            assert_eq!(entries[0].path, index_path);
1936            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1937        }
1938
1939        #[test]
1940        fn finds_root_index_js_as_fallback() {
1941            let dir = tempfile::tempdir().expect("create temp dir");
1942            let index_path = dir.path().join("index.js");
1943            std::fs::write(&index_path, "module.exports = {};").unwrap();
1944
1945            let files = vec![DiscoveredFile {
1946                id: FileId(0),
1947                path: index_path.clone(),
1948                size_bytes: 21,
1949            }];
1950
1951            let entries = apply_default_fallback(&files, dir.path(), None);
1952            assert_eq!(entries.len(), 1);
1953            assert_eq!(entries[0].path, index_path);
1954        }
1955
1956        #[test]
1957        fn returns_empty_when_no_index_file() {
1958            let dir = tempfile::tempdir().expect("create temp dir");
1959            let other_path = dir.path().join("src").join("utils.ts");
1960
1961            let files = vec![DiscoveredFile {
1962                id: FileId(0),
1963                path: other_path,
1964                size_bytes: 10,
1965            }];
1966
1967            let entries = apply_default_fallback(&files, dir.path(), None);
1968            assert!(
1969                entries.is_empty(),
1970                "non-index files should not match default fallback"
1971            );
1972        }
1973
1974        #[test]
1975        fn workspace_filter_restricts_scope() {
1976            let dir = tempfile::tempdir().expect("create temp dir");
1977            let ws_a = dir.path().join("packages").join("a").join("src");
1978            std::fs::create_dir_all(&ws_a).unwrap();
1979            let ws_b = dir.path().join("packages").join("b").join("src");
1980            std::fs::create_dir_all(&ws_b).unwrap();
1981
1982            let index_a = ws_a.join("index.ts");
1983            let index_b = ws_b.join("index.ts");
1984
1985            let files = vec![
1986                DiscoveredFile {
1987                    id: FileId(0),
1988                    path: index_a.clone(),
1989                    size_bytes: 10,
1990                },
1991                DiscoveredFile {
1992                    id: FileId(1),
1993                    path: index_b,
1994                    size_bytes: 10,
1995                },
1996            ];
1997
1998            let ws_root = dir.path().join("packages").join("a");
1999            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2000            assert_eq!(entries.len(), 1);
2001            assert_eq!(entries[0].path, index_a);
2002        }
2003    }
2004
2005    mod wildcard_entry_tests {
2006        use super::*;
2007
2008        #[test]
2009        fn expands_wildcard_css_entries() {
2010            let dir = tempfile::tempdir().expect("create temp dir");
2011            let themes = dir.path().join("src").join("themes");
2012            std::fs::create_dir_all(&themes).unwrap();
2013            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2014            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2015
2016            let canonical = dunce::canonicalize(dir.path()).unwrap();
2017            let mut entries = Vec::new();
2018            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2019
2020            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2021            let paths: Vec<String> = entries
2022                .iter()
2023                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2024                .collect();
2025            assert!(paths.contains(&"dark.css".to_string()));
2026            assert!(paths.contains(&"light.css".to_string()));
2027            assert!(
2028                entries
2029                    .iter()
2030                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2031            );
2032        }
2033
2034        #[test]
2035        fn wildcard_does_not_match_nonexistent_files() {
2036            let dir = tempfile::tempdir().expect("create temp dir");
2037            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2038
2039            let canonical = dunce::canonicalize(dir.path()).unwrap();
2040            let mut entries = Vec::new();
2041            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2042
2043            assert!(
2044                entries.is_empty(),
2045                "should return empty when no files match the wildcard"
2046            );
2047        }
2048
2049        #[test]
2050        fn wildcard_only_matches_specified_extension() {
2051            let dir = tempfile::tempdir().expect("create temp dir");
2052            let themes = dir.path().join("src").join("themes");
2053            std::fs::create_dir_all(&themes).unwrap();
2054            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2055            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2056
2057            let canonical = dunce::canonicalize(dir.path()).unwrap();
2058            let mut entries = Vec::new();
2059            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2060
2061            assert_eq!(entries.len(), 1, "should only match CSS files");
2062            assert!(
2063                entries[0]
2064                    .path
2065                    .file_name()
2066                    .unwrap()
2067                    .to_string_lossy()
2068                    .ends_with(".css")
2069            );
2070        }
2071    }
2072}