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