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            dynamically_loaded: vec![],
1135            overrides: vec![],
1136            regression: None,
1137            audit: fallow_config::AuditConfig::default(),
1138            codeowners: None,
1139            public_packages: vec![],
1140            flags: fallow_config::FlagsConfig::default(),
1141            security: fallow_config::SecurityConfig::default(),
1142            fix: fallow_config::FixConfig::default(),
1143            resolve: fallow_config::ResolveConfig::default(),
1144            sealed: false,
1145            include_entry_exports: false,
1146            auto_imports: false,
1147            cache: fallow_config::CacheConfig::default(),
1148        }
1149        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1150
1151        let files = vec![
1152            DiscoveredFile {
1153                id: FileId(0),
1154                path: root.join("src/runtime.ts"),
1155                size_bytes: 1,
1156            },
1157            DiscoveredFile {
1158                id: FileId(1),
1159                path: root.join("src/setup.ts"),
1160                size_bytes: 1,
1161            },
1162            DiscoveredFile {
1163                id: FileId(2),
1164                path: root.join("tests/app.test.ts"),
1165                size_bytes: 1,
1166            },
1167        ];
1168
1169        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1170        plugin_result.entry_patterns.push((
1171            crate::plugins::PathRule::new("src/runtime.ts"),
1172            "runtime-plugin".to_string(),
1173        ));
1174        plugin_result.entry_patterns.push((
1175            crate::plugins::PathRule::new("tests/app.test.ts"),
1176            "test-plugin".to_string(),
1177        ));
1178        plugin_result
1179            .always_used
1180            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1181        plugin_result
1182            .entry_point_roles
1183            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1184        plugin_result
1185            .entry_point_roles
1186            .insert("test-plugin".to_string(), EntryPointRole::Test);
1187        plugin_result
1188            .entry_point_roles
1189            .insert("support-plugin".to_string(), EntryPointRole::Support);
1190
1191        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1192
1193        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1194        assert!(
1195            entries.runtime[0].path.ends_with("src/runtime.ts"),
1196            "runtime entry should stay runtime-only"
1197        );
1198        assert_eq!(entries.test.len(), 1, "expected one test entry");
1199        assert!(
1200            entries.test[0].path.ends_with("tests/app.test.ts"),
1201            "test entry should stay test-only"
1202        );
1203        assert_eq!(
1204            entries.all.len(),
1205            3,
1206            "support entries should stay in all entries"
1207        );
1208        assert!(
1209            entries
1210                .all
1211                .iter()
1212                .any(|entry| entry.path.ends_with("src/setup.ts")),
1213            "support entries should remain in the overall entry-point set"
1214        );
1215        assert!(
1216            !entries
1217                .runtime
1218                .iter()
1219                .any(|entry| entry.path.ends_with("src/setup.ts")),
1220            "support entries should not bleed into runtime reachability"
1221        );
1222        assert!(
1223            !entries
1224                .test
1225                .iter()
1226                .any(|entry| entry.path.ends_with("src/setup.ts")),
1227            "support entries should not bleed into test reachability"
1228        );
1229    }
1230
1231    #[test]
1232    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1233        let root = Path::new("/workspace/project");
1234        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1235
1236        assert_eq!(
1237            resolve_plugin_setup_file(root, setup_file),
1238            setup_file.to_path_buf()
1239        );
1240    }
1241
1242    #[cfg(windows)]
1243    #[test]
1244    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1245        let root = Path::new(r"C:\workspace\project");
1246        let setup_file = Path::new(r"/workspace/project/setup.ts");
1247
1248        assert_eq!(
1249            resolve_plugin_setup_file(root, setup_file),
1250            setup_file.to_path_buf()
1251        );
1252    }
1253
1254    #[test]
1255    fn plugin_entry_point_rules_respect_exclusions() {
1256        let dir = tempfile::tempdir().expect("create temp dir");
1257        let root = dir.path();
1258        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1259        std::fs::write(
1260            root.join("app/pages/index.tsx"),
1261            "export default function Page() { return null; }",
1262        )
1263        .unwrap();
1264        std::fs::write(
1265            root.join("app/pages/-helper.ts"),
1266            "export const helper = 1;",
1267        )
1268        .unwrap();
1269
1270        let config = FallowConfig {
1271            schema: None,
1272            extends: vec![],
1273            entry: vec![],
1274            ignore_patterns: vec![],
1275            framework: vec![],
1276            workspaces: None,
1277            ignore_dependencies: vec![],
1278            ignore_unresolved_imports: vec![],
1279            ignore_exports: vec![],
1280            ignore_catalog_references: vec![],
1281            ignore_dependency_overrides: vec![],
1282            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1283            used_class_members: vec![],
1284            ignore_decorators: vec![],
1285            duplicates: fallow_config::DuplicatesConfig::default(),
1286            health: fallow_config::HealthConfig::default(),
1287            rules: RulesConfig::default(),
1288            boundaries: fallow_config::BoundaryConfig::default(),
1289            production: false.into(),
1290            plugins: vec![],
1291            dynamically_loaded: vec![],
1292            overrides: vec![],
1293            regression: None,
1294            audit: fallow_config::AuditConfig::default(),
1295            codeowners: None,
1296            public_packages: vec![],
1297            flags: fallow_config::FlagsConfig::default(),
1298            security: fallow_config::SecurityConfig::default(),
1299            fix: fallow_config::FixConfig::default(),
1300            resolve: fallow_config::ResolveConfig::default(),
1301            sealed: false,
1302            include_entry_exports: false,
1303            auto_imports: false,
1304            cache: fallow_config::CacheConfig::default(),
1305        }
1306        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1307
1308        let files = vec![
1309            DiscoveredFile {
1310                id: FileId(0),
1311                path: root.join("app/pages/index.tsx"),
1312                size_bytes: 1,
1313            },
1314            DiscoveredFile {
1315                id: FileId(1),
1316                path: root.join("app/pages/-helper.ts"),
1317                size_bytes: 1,
1318            },
1319        ];
1320
1321        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1322        plugin_result.entry_patterns.push((
1323            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1324                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1325            "tanstack-router".to_string(),
1326        ));
1327        plugin_result
1328            .entry_point_roles
1329            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1330
1331        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1332        let entry_paths: Vec<_> = entries
1333            .all
1334            .iter()
1335            .map(|entry| {
1336                entry
1337                    .path
1338                    .strip_prefix(root)
1339                    .unwrap()
1340                    .to_string_lossy()
1341                    .into_owned()
1342            })
1343            .collect();
1344
1345        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1346        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1347    }
1348
1349    mod resolve_entry_path_tests {
1350        use super::*;
1351
1352        #[test]
1353        fn resolves_existing_file() {
1354            let dir = tempfile::tempdir().expect("create temp dir");
1355            let src = dir.path().join("src");
1356            std::fs::create_dir_all(&src).unwrap();
1357            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1358
1359            let canonical = dunce::canonicalize(dir.path()).unwrap();
1360            let result = resolve_entry_path(
1361                dir.path(),
1362                "src/index.ts",
1363                &canonical,
1364                EntryPointSource::PackageJsonMain,
1365            );
1366            assert!(result.is_some(), "should resolve an existing file");
1367            assert!(result.unwrap().path.ends_with("src/index.ts"));
1368        }
1369
1370        #[test]
1371        fn resolves_with_extension_fallback() {
1372            let dir = tempfile::tempdir().expect("create temp dir");
1373            let canonical = dunce::canonicalize(dir.path()).unwrap();
1374            let src = canonical.join("src");
1375            std::fs::create_dir_all(&src).unwrap();
1376            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1377
1378            let result = resolve_entry_path(
1379                &canonical,
1380                "src/index",
1381                &canonical,
1382                EntryPointSource::PackageJsonMain,
1383            );
1384            assert!(
1385                result.is_some(),
1386                "should resolve via extension fallback when exact path doesn't exist"
1387            );
1388            let ep = result.unwrap();
1389            assert!(
1390                ep.path.to_string_lossy().contains("index.ts"),
1391                "should find index.ts via extension fallback"
1392            );
1393        }
1394
1395        #[test]
1396        fn exact_file_wins_before_directory_index_fallback() {
1397            let dir = tempfile::tempdir().expect("create temp dir");
1398            let canonical = dunce::canonicalize(dir.path()).unwrap();
1399            let scripts = canonical.join("scripts");
1400            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1401            std::fs::write(
1402                scripts.join("process-messages.js"),
1403                "export const direct = true;",
1404            )
1405            .unwrap();
1406            std::fs::write(
1407                scripts.join("process-messages").join("index.js"),
1408                "export const index = true;",
1409            )
1410            .unwrap();
1411
1412            let result = resolve_entry_path(
1413                &canonical,
1414                "scripts/process-messages.js",
1415                &canonical,
1416                EntryPointSource::PackageJsonScript,
1417            )
1418            .expect("exact file should resolve");
1419
1420            assert!(result.path.ends_with("scripts/process-messages.js"));
1421        }
1422
1423        #[test]
1424        fn extension_fallback_wins_before_directory_index_fallback() {
1425            let dir = tempfile::tempdir().expect("create temp dir");
1426            let canonical = dunce::canonicalize(dir.path()).unwrap();
1427            let scripts = canonical.join("scripts");
1428            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1429            std::fs::write(
1430                scripts.join("process-messages.ts"),
1431                "export const withExt = true;",
1432            )
1433            .unwrap();
1434            std::fs::write(
1435                scripts.join("process-messages").join("index.js"),
1436                "export const index = true;",
1437            )
1438            .unwrap();
1439
1440            let result = resolve_entry_path(
1441                &canonical,
1442                "scripts/process-messages",
1443                &canonical,
1444                EntryPointSource::PackageJsonScript,
1445            )
1446            .expect("extension fallback should resolve");
1447
1448            assert!(result.path.ends_with("scripts/process-messages.ts"));
1449        }
1450
1451        #[test]
1452        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1453            let dir = tempfile::tempdir().expect("create temp dir");
1454            let canonical = dunce::canonicalize(dir.path()).unwrap();
1455            let scripts = canonical.join("scripts/process-messages");
1456            std::fs::create_dir_all(&scripts).unwrap();
1457            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1458
1459            let result = resolve_entry_path(
1460                &canonical,
1461                "scripts/process-messages",
1462                &canonical,
1463                EntryPointSource::PackageJsonScript,
1464            )
1465            .expect("directory index should resolve");
1466
1467            assert!(result.path.ends_with("scripts/process-messages/index.js"));
1468        }
1469
1470        #[test]
1471        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1472            let dir = tempfile::tempdir().expect("create temp dir");
1473            let canonical = dunce::canonicalize(dir.path()).unwrap();
1474            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1475            std::fs::write(
1476                canonical.join("scripts/process-messages/index.js"),
1477                "export const index = true;",
1478            )
1479            .unwrap();
1480
1481            for entry in [
1482                "scripts/*",
1483                "https://example.com/scripts/process-messages",
1484                "@scope/package/scripts/process-messages",
1485            ] {
1486                let result = resolve_entry_path(
1487                    &canonical,
1488                    entry,
1489                    &canonical,
1490                    EntryPointSource::PackageJsonScript,
1491                );
1492                assert!(result.is_none(), "{entry} should not resolve");
1493            }
1494        }
1495
1496        #[test]
1497        fn returns_none_for_nonexistent_file() {
1498            let dir = tempfile::tempdir().expect("create temp dir");
1499            let canonical = dunce::canonicalize(dir.path()).unwrap();
1500            let result = resolve_entry_path(
1501                dir.path(),
1502                "does/not/exist.ts",
1503                &canonical,
1504                EntryPointSource::PackageJsonMain,
1505            );
1506            assert!(result.is_none(), "should return None for nonexistent files");
1507        }
1508
1509        #[test]
1510        fn maps_dist_output_to_src() {
1511            let dir = tempfile::tempdir().expect("create temp dir");
1512            let src = dir.path().join("src");
1513            std::fs::create_dir_all(&src).unwrap();
1514            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1515
1516            let dist = dir.path().join("dist");
1517            std::fs::create_dir_all(&dist).unwrap();
1518            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1519
1520            let canonical = dunce::canonicalize(dir.path()).unwrap();
1521            let result = resolve_entry_path(
1522                dir.path(),
1523                "./dist/utils.js",
1524                &canonical,
1525                EntryPointSource::PackageJsonExports,
1526            );
1527            assert!(result.is_some(), "should resolve dist/ path to src/");
1528            let ep = result.unwrap();
1529            assert!(
1530                ep.path
1531                    .to_string_lossy()
1532                    .replace('\\', "/")
1533                    .contains("src/utils.ts"),
1534                "should map ./dist/utils.js to src/utils.ts"
1535            );
1536        }
1537
1538        #[test]
1539        fn maps_build_output_to_src() {
1540            let dir = tempfile::tempdir().expect("create temp dir");
1541            let canonical = dunce::canonicalize(dir.path()).unwrap();
1542            let src = canonical.join("src");
1543            std::fs::create_dir_all(&src).unwrap();
1544            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1545
1546            let result = resolve_entry_path(
1547                &canonical,
1548                "./build/index.js",
1549                &canonical,
1550                EntryPointSource::PackageJsonExports,
1551            );
1552            assert!(result.is_some(), "should map build/ output to src/");
1553            let ep = result.unwrap();
1554            assert!(
1555                ep.path
1556                    .to_string_lossy()
1557                    .replace('\\', "/")
1558                    .contains("src/index.tsx"),
1559                "should map ./build/index.js to src/index.tsx"
1560            );
1561        }
1562
1563        #[test]
1564        fn preserves_entry_point_source() {
1565            let dir = tempfile::tempdir().expect("create temp dir");
1566            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1567
1568            let canonical = dunce::canonicalize(dir.path()).unwrap();
1569            let result = resolve_entry_path(
1570                dir.path(),
1571                "index.ts",
1572                &canonical,
1573                EntryPointSource::PackageJsonScript,
1574            );
1575            assert!(result.is_some());
1576            assert!(
1577                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1578                "should preserve the source kind"
1579            );
1580        }
1581        #[test]
1582        fn tracks_skipped_entries_without_logging_each_repeat() {
1583            let dir = tempfile::tempdir().expect("create temp dir");
1584            let canonical = dunce::canonicalize(dir.path()).unwrap();
1585            let mut skipped_entries = FxHashMap::default();
1586
1587            let result = resolve_entry_path_with_tracking(
1588                dir.path(),
1589                "../scripts/build.js",
1590                &canonical,
1591                EntryPointSource::PackageJsonScript,
1592                Some(&mut skipped_entries),
1593            );
1594
1595            assert!(result.is_none(), "unsafe entry should be skipped");
1596            assert_eq!(
1597                skipped_entries.get("../scripts/build.js"),
1598                Some(&1),
1599                "warning tracker should count the skipped path"
1600            );
1601        }
1602
1603        #[test]
1604        fn formats_skipped_entry_warning_with_counts() {
1605            let mut skipped_entries = FxHashMap::default();
1606            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1607            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1608
1609            let warning =
1610                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1611
1612            assert_eq!(
1613                warning,
1614                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1615            );
1616        }
1617
1618        #[test]
1619        fn skipped_entry_summary_dedupes_identical_messages() {
1620            let message = format!(
1621                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
1622                std::process::id()
1623            );
1624            assert!(
1625                should_warn_skipped_entry(&message),
1626                "first occurrence of a message emits"
1627            );
1628            assert!(
1629                !should_warn_skipped_entry(&message),
1630                "identical repeat is suppressed"
1631            );
1632        }
1633
1634        #[test]
1635        fn rejects_parent_dir_escape_for_exact_file() {
1636            let sandbox = tempfile::tempdir().expect("create sandbox");
1637            let root = sandbox.path().join("project");
1638            std::fs::create_dir_all(&root).unwrap();
1639            std::fs::write(
1640                sandbox.path().join("escape.ts"),
1641                "export const escape = true;",
1642            )
1643            .unwrap();
1644
1645            let canonical = dunce::canonicalize(&root).unwrap();
1646            let result = resolve_entry_path(
1647                &root,
1648                "../escape.ts",
1649                &canonical,
1650                EntryPointSource::PackageJsonMain,
1651            );
1652
1653            assert!(
1654                result.is_none(),
1655                "should reject exact paths that escape the root"
1656            );
1657        }
1658
1659        #[test]
1660        fn rejects_parent_dir_escape_via_extension_fallback() {
1661            let sandbox = tempfile::tempdir().expect("create sandbox");
1662            let root = sandbox.path().join("project");
1663            std::fs::create_dir_all(&root).unwrap();
1664            std::fs::write(
1665                sandbox.path().join("escape.ts"),
1666                "export const escape = true;",
1667            )
1668            .unwrap();
1669
1670            let canonical = dunce::canonicalize(&root).unwrap();
1671            let result = resolve_entry_path(
1672                &root,
1673                "../escape",
1674                &canonical,
1675                EntryPointSource::PackageJsonMain,
1676            );
1677
1678            assert!(
1679                result.is_none(),
1680                "should reject extension fallback paths that escape the root"
1681            );
1682        }
1683    }
1684
1685    mod output_to_source_tests {
1686        use super::*;
1687
1688        #[test]
1689        fn maps_dist_to_src_with_ts_extension() {
1690            let dir = tempfile::tempdir().expect("create temp dir");
1691            let src = dir.path().join("src");
1692            std::fs::create_dir_all(&src).unwrap();
1693            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1694
1695            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1696            assert!(result.is_some());
1697            assert!(
1698                result
1699                    .unwrap()
1700                    .to_string_lossy()
1701                    .replace('\\', "/")
1702                    .contains("src/utils.ts")
1703            );
1704        }
1705
1706        #[test]
1707        fn returns_none_when_no_source_file_exists() {
1708            let dir = tempfile::tempdir().expect("create temp dir");
1709            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1710            assert!(result.is_none());
1711        }
1712
1713        #[test]
1714        fn ignores_non_output_directories() {
1715            let dir = tempfile::tempdir().expect("create temp dir");
1716            let src = dir.path().join("src");
1717            std::fs::create_dir_all(&src).unwrap();
1718            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1719
1720            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1721            assert!(result.is_none());
1722        }
1723
1724        #[test]
1725        fn maps_nested_output_path_preserving_prefix() {
1726            let dir = tempfile::tempdir().expect("create temp dir");
1727            let modules_src = dir.path().join("modules").join("src");
1728            std::fs::create_dir_all(&modules_src).unwrap();
1729            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1730
1731            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1732            assert!(result.is_some());
1733            assert!(
1734                result
1735                    .unwrap()
1736                    .to_string_lossy()
1737                    .replace('\\', "/")
1738                    .contains("modules/src/helper.ts")
1739            );
1740        }
1741    }
1742
1743    mod source_index_fallback_tests {
1744        use super::*;
1745
1746        #[test]
1747        fn detects_dist_entry_in_output_dir() {
1748            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1749            assert!(is_entry_in_output_dir("dist/index.js"));
1750            assert!(is_entry_in_output_dir("./build/index.js"));
1751            assert!(is_entry_in_output_dir("./out/main.js"));
1752            assert!(is_entry_in_output_dir("./esm/index.js"));
1753            assert!(is_entry_in_output_dir("./cjs/index.js"));
1754        }
1755
1756        #[test]
1757        fn rejects_non_output_entry_paths() {
1758            assert!(!is_entry_in_output_dir("./src/index.ts"));
1759            assert!(!is_entry_in_output_dir("src/main.ts"));
1760            assert!(!is_entry_in_output_dir("./index.js"));
1761            assert!(!is_entry_in_output_dir(""));
1762        }
1763
1764        #[test]
1765        fn root_index_entries_are_recognized_for_source_fallback() {
1766            assert!(is_package_root_index_entry("./index.js"));
1767            assert!(is_package_root_index_entry("index.cjs"));
1768            assert!(is_package_root_index_entry("./index.d.ts"));
1769            assert!(!is_package_root_index_entry("./src/index.js"));
1770            assert!(!is_package_root_index_entry("./main.js"));
1771            assert!(!is_package_root_index_entry(""));
1772        }
1773
1774        #[test]
1775        fn rejects_substring_match_for_output_dir() {
1776            assert!(!is_entry_in_output_dir("./distro/index.js"));
1777            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1778        }
1779
1780        #[test]
1781        fn finds_src_index_ts() {
1782            let dir = tempfile::tempdir().expect("create temp dir");
1783            let src = dir.path().join("src");
1784            std::fs::create_dir_all(&src).unwrap();
1785            let index_path = src.join("index.ts");
1786            std::fs::write(&index_path, "export const a = 1;").unwrap();
1787
1788            let result = try_source_index_fallback(dir.path());
1789            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1790        }
1791
1792        #[test]
1793        fn finds_src_index_tsx_when_ts_missing() {
1794            let dir = tempfile::tempdir().expect("create temp dir");
1795            let src = dir.path().join("src");
1796            std::fs::create_dir_all(&src).unwrap();
1797            let index_path = src.join("index.tsx");
1798            std::fs::write(&index_path, "export default 1;").unwrap();
1799
1800            let result = try_source_index_fallback(dir.path());
1801            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1802        }
1803
1804        #[test]
1805        fn prefers_src_index_over_root_index() {
1806            let dir = tempfile::tempdir().expect("create temp dir");
1807            let src = dir.path().join("src");
1808            std::fs::create_dir_all(&src).unwrap();
1809            let src_index = src.join("index.ts");
1810            std::fs::write(&src_index, "export const a = 1;").unwrap();
1811            let root_index = dir.path().join("index.ts");
1812            std::fs::write(&root_index, "export const b = 2;").unwrap();
1813
1814            let result = try_source_index_fallback(dir.path());
1815            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1816        }
1817
1818        #[test]
1819        fn falls_back_to_src_main() {
1820            let dir = tempfile::tempdir().expect("create temp dir");
1821            let src = dir.path().join("src");
1822            std::fs::create_dir_all(&src).unwrap();
1823            let main_path = src.join("main.ts");
1824            std::fs::write(&main_path, "export const a = 1;").unwrap();
1825
1826            let result = try_source_index_fallback(dir.path());
1827            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1828        }
1829
1830        #[test]
1831        fn falls_back_to_root_index_when_no_src() {
1832            let dir = tempfile::tempdir().expect("create temp dir");
1833            let index_path = dir.path().join("index.js");
1834            std::fs::write(&index_path, "module.exports = {};").unwrap();
1835
1836            let result = try_source_index_fallback(dir.path());
1837            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1838        }
1839
1840        #[test]
1841        fn returns_none_when_nothing_matches() {
1842            let dir = tempfile::tempdir().expect("create temp dir");
1843            let result = try_source_index_fallback(dir.path());
1844            assert!(result.is_none());
1845        }
1846
1847        #[test]
1848        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1849            let dir = tempfile::tempdir().expect("create temp dir");
1850            let canonical = dunce::canonicalize(dir.path()).unwrap();
1851
1852            let dist_dir = canonical.join("dist").join("esm2022");
1853            std::fs::create_dir_all(&dist_dir).unwrap();
1854            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1855
1856            let src = canonical.join("src");
1857            std::fs::create_dir_all(&src).unwrap();
1858            let src_index = src.join("index.ts");
1859            std::fs::write(&src_index, "export const x = 1;").unwrap();
1860
1861            let result = resolve_entry_path(
1862                &canonical,
1863                "./dist/esm2022/index.js",
1864                &canonical,
1865                EntryPointSource::PackageJsonMain,
1866            );
1867            assert!(result.is_some());
1868            let entry = result.unwrap();
1869            assert_eq!(entry.path, src_index);
1870        }
1871
1872        #[test]
1873        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1874            let dir = tempfile::tempdir().expect("create temp dir");
1875            let canonical = dunce::canonicalize(dir.path()).unwrap();
1876
1877            let src_mirror = canonical.join("src").join("esm2022");
1878            std::fs::create_dir_all(&src_mirror).unwrap();
1879            let mirror_index = src_mirror.join("index.ts");
1880            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1881
1882            let src_index = canonical.join("src").join("index.ts");
1883            std::fs::write(&src_index, "export const y = 2;").unwrap();
1884
1885            let result = resolve_entry_path(
1886                &canonical,
1887                "./dist/esm2022/index.js",
1888                &canonical,
1889                EntryPointSource::PackageJsonMain,
1890            );
1891            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1892        }
1893
1894        #[test]
1895        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
1896            let dir = tempfile::tempdir().expect("create temp dir");
1897            let canonical = dunce::canonicalize(dir.path()).unwrap();
1898
1899            let src = canonical.join("src");
1900            std::fs::create_dir_all(&src).unwrap();
1901            let src_index = src.join("index.ts");
1902            std::fs::write(&src_index, "export const x = 1;").unwrap();
1903
1904            let result = resolve_entry_path(
1905                &canonical,
1906                "./index.js",
1907                &canonical,
1908                EntryPointSource::PackageJsonMain,
1909            );
1910            assert_eq!(result.map(|entry| entry.path), Some(src_index));
1911        }
1912    }
1913
1914    mod default_fallback_tests {
1915        use super::*;
1916
1917        #[test]
1918        fn finds_src_index_ts_as_fallback() {
1919            let dir = tempfile::tempdir().expect("create temp dir");
1920            let src = dir.path().join("src");
1921            std::fs::create_dir_all(&src).unwrap();
1922            let index_path = src.join("index.ts");
1923            std::fs::write(&index_path, "export const a = 1;").unwrap();
1924
1925            let files = vec![DiscoveredFile {
1926                id: FileId(0),
1927                path: index_path.clone(),
1928                size_bytes: 20,
1929            }];
1930
1931            let entries = apply_default_fallback(&files, dir.path(), None);
1932            assert_eq!(entries.len(), 1);
1933            assert_eq!(entries[0].path, index_path);
1934            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1935        }
1936
1937        #[test]
1938        fn finds_root_index_js_as_fallback() {
1939            let dir = tempfile::tempdir().expect("create temp dir");
1940            let index_path = dir.path().join("index.js");
1941            std::fs::write(&index_path, "module.exports = {};").unwrap();
1942
1943            let files = vec![DiscoveredFile {
1944                id: FileId(0),
1945                path: index_path.clone(),
1946                size_bytes: 21,
1947            }];
1948
1949            let entries = apply_default_fallback(&files, dir.path(), None);
1950            assert_eq!(entries.len(), 1);
1951            assert_eq!(entries[0].path, index_path);
1952        }
1953
1954        #[test]
1955        fn returns_empty_when_no_index_file() {
1956            let dir = tempfile::tempdir().expect("create temp dir");
1957            let other_path = dir.path().join("src").join("utils.ts");
1958
1959            let files = vec![DiscoveredFile {
1960                id: FileId(0),
1961                path: other_path,
1962                size_bytes: 10,
1963            }];
1964
1965            let entries = apply_default_fallback(&files, dir.path(), None);
1966            assert!(
1967                entries.is_empty(),
1968                "non-index files should not match default fallback"
1969            );
1970        }
1971
1972        #[test]
1973        fn workspace_filter_restricts_scope() {
1974            let dir = tempfile::tempdir().expect("create temp dir");
1975            let ws_a = dir.path().join("packages").join("a").join("src");
1976            std::fs::create_dir_all(&ws_a).unwrap();
1977            let ws_b = dir.path().join("packages").join("b").join("src");
1978            std::fs::create_dir_all(&ws_b).unwrap();
1979
1980            let index_a = ws_a.join("index.ts");
1981            let index_b = ws_b.join("index.ts");
1982
1983            let files = vec![
1984                DiscoveredFile {
1985                    id: FileId(0),
1986                    path: index_a.clone(),
1987                    size_bytes: 10,
1988                },
1989                DiscoveredFile {
1990                    id: FileId(1),
1991                    path: index_b,
1992                    size_bytes: 10,
1993                },
1994            ];
1995
1996            let ws_root = dir.path().join("packages").join("a");
1997            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1998            assert_eq!(entries.len(), 1);
1999            assert_eq!(entries[0].path, index_a);
2000        }
2001    }
2002
2003    mod wildcard_entry_tests {
2004        use super::*;
2005
2006        #[test]
2007        fn expands_wildcard_css_entries() {
2008            let dir = tempfile::tempdir().expect("create temp dir");
2009            let themes = dir.path().join("src").join("themes");
2010            std::fs::create_dir_all(&themes).unwrap();
2011            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2012            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2013
2014            let canonical = dunce::canonicalize(dir.path()).unwrap();
2015            let mut entries = Vec::new();
2016            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2017
2018            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2019            let paths: Vec<String> = entries
2020                .iter()
2021                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2022                .collect();
2023            assert!(paths.contains(&"dark.css".to_string()));
2024            assert!(paths.contains(&"light.css".to_string()));
2025            assert!(
2026                entries
2027                    .iter()
2028                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2029            );
2030        }
2031
2032        #[test]
2033        fn wildcard_does_not_match_nonexistent_files() {
2034            let dir = tempfile::tempdir().expect("create temp dir");
2035            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2036
2037            let canonical = dunce::canonicalize(dir.path()).unwrap();
2038            let mut entries = Vec::new();
2039            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2040
2041            assert!(
2042                entries.is_empty(),
2043                "should return empty when no files match the wildcard"
2044            );
2045        }
2046
2047        #[test]
2048        fn wildcard_only_matches_specified_extension() {
2049            let dir = tempfile::tempdir().expect("create temp dir");
2050            let themes = dir.path().join("src").join("themes");
2051            std::fs::create_dir_all(&themes).unwrap();
2052            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2053            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2054
2055            let canonical = dunce::canonicalize(dir.path()).unwrap();
2056            let mut entries = Vec::new();
2057            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2058
2059            assert_eq!(entries.len(), 1, "should only match CSS files");
2060            assert!(
2061                entries[0]
2062                    .path
2063                    .file_name()
2064                    .unwrap()
2065                    .to_string_lossy()
2066                    .ends_with(".css")
2067            );
2068        }
2069    }
2070}