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            used_class_members: vec![],
1103            duplicates: fallow_config::DuplicatesConfig::default(),
1104            health: fallow_config::HealthConfig::default(),
1105            rules: RulesConfig::default(),
1106            boundaries: fallow_config::BoundaryConfig::default(),
1107            production: false,
1108            plugins: vec![],
1109            dynamically_loaded: vec![],
1110            overrides: vec![],
1111            regression: None,
1112            audit: fallow_config::AuditConfig::default(),
1113            codeowners: None,
1114            public_packages: vec![],
1115            flags: fallow_config::FlagsConfig::default(),
1116            resolve: fallow_config::ResolveConfig::default(),
1117            sealed: false,
1118        }
1119        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1120
1121        let files = vec![
1122            DiscoveredFile {
1123                id: FileId(0),
1124                path: root.join("src/runtime.ts"),
1125                size_bytes: 1,
1126            },
1127            DiscoveredFile {
1128                id: FileId(1),
1129                path: root.join("src/setup.ts"),
1130                size_bytes: 1,
1131            },
1132            DiscoveredFile {
1133                id: FileId(2),
1134                path: root.join("tests/app.test.ts"),
1135                size_bytes: 1,
1136            },
1137        ];
1138
1139        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1140        plugin_result.entry_patterns.push((
1141            crate::plugins::PathRule::new("src/runtime.ts"),
1142            "runtime-plugin".to_string(),
1143        ));
1144        plugin_result.entry_patterns.push((
1145            crate::plugins::PathRule::new("tests/app.test.ts"),
1146            "test-plugin".to_string(),
1147        ));
1148        plugin_result
1149            .always_used
1150            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1151        plugin_result
1152            .entry_point_roles
1153            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1154        plugin_result
1155            .entry_point_roles
1156            .insert("test-plugin".to_string(), EntryPointRole::Test);
1157        plugin_result
1158            .entry_point_roles
1159            .insert("support-plugin".to_string(), EntryPointRole::Support);
1160
1161        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1162
1163        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1164        assert!(
1165            entries.runtime[0].path.ends_with("src/runtime.ts"),
1166            "runtime entry should stay runtime-only"
1167        );
1168        assert_eq!(entries.test.len(), 1, "expected one test entry");
1169        assert!(
1170            entries.test[0].path.ends_with("tests/app.test.ts"),
1171            "test entry should stay test-only"
1172        );
1173        assert_eq!(
1174            entries.all.len(),
1175            3,
1176            "support entries should stay in all entries"
1177        );
1178        assert!(
1179            entries
1180                .all
1181                .iter()
1182                .any(|entry| entry.path.ends_with("src/setup.ts")),
1183            "support entries should remain in the overall entry-point set"
1184        );
1185        assert!(
1186            !entries
1187                .runtime
1188                .iter()
1189                .any(|entry| entry.path.ends_with("src/setup.ts")),
1190            "support entries should not bleed into runtime reachability"
1191        );
1192        assert!(
1193            !entries
1194                .test
1195                .iter()
1196                .any(|entry| entry.path.ends_with("src/setup.ts")),
1197            "support entries should not bleed into test reachability"
1198        );
1199    }
1200
1201    #[test]
1202    fn plugin_entry_point_rules_respect_exclusions() {
1203        let dir = tempfile::tempdir().expect("create temp dir");
1204        let root = dir.path();
1205        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1206        std::fs::write(
1207            root.join("app/pages/index.tsx"),
1208            "export default function Page() { return null; }",
1209        )
1210        .unwrap();
1211        std::fs::write(
1212            root.join("app/pages/-helper.ts"),
1213            "export const helper = 1;",
1214        )
1215        .unwrap();
1216
1217        let config = FallowConfig {
1218            schema: None,
1219            extends: vec![],
1220            entry: vec![],
1221            ignore_patterns: vec![],
1222            framework: vec![],
1223            workspaces: None,
1224            ignore_dependencies: vec![],
1225            ignore_exports: vec![],
1226            used_class_members: vec![],
1227            duplicates: fallow_config::DuplicatesConfig::default(),
1228            health: fallow_config::HealthConfig::default(),
1229            rules: RulesConfig::default(),
1230            boundaries: fallow_config::BoundaryConfig::default(),
1231            production: false,
1232            plugins: vec![],
1233            dynamically_loaded: vec![],
1234            overrides: vec![],
1235            regression: None,
1236            audit: fallow_config::AuditConfig::default(),
1237            codeowners: None,
1238            public_packages: vec![],
1239            flags: fallow_config::FlagsConfig::default(),
1240            resolve: fallow_config::ResolveConfig::default(),
1241            sealed: false,
1242        }
1243        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1244
1245        let files = vec![
1246            DiscoveredFile {
1247                id: FileId(0),
1248                path: root.join("app/pages/index.tsx"),
1249                size_bytes: 1,
1250            },
1251            DiscoveredFile {
1252                id: FileId(1),
1253                path: root.join("app/pages/-helper.ts"),
1254                size_bytes: 1,
1255            },
1256        ];
1257
1258        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1259        plugin_result.entry_patterns.push((
1260            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1261                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1262            "tanstack-router".to_string(),
1263        ));
1264        plugin_result
1265            .entry_point_roles
1266            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1267
1268        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1269        let entry_paths: Vec<_> = entries
1270            .all
1271            .iter()
1272            .map(|entry| {
1273                entry
1274                    .path
1275                    .strip_prefix(root)
1276                    .unwrap()
1277                    .to_string_lossy()
1278                    .into_owned()
1279            })
1280            .collect();
1281
1282        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1283        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1284    }
1285
1286    // resolve_entry_path unit tests
1287    mod resolve_entry_path_tests {
1288        use super::*;
1289
1290        #[test]
1291        fn resolves_existing_file() {
1292            let dir = tempfile::tempdir().expect("create temp dir");
1293            let src = dir.path().join("src");
1294            std::fs::create_dir_all(&src).unwrap();
1295            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1296
1297            let canonical = dunce::canonicalize(dir.path()).unwrap();
1298            let result = resolve_entry_path(
1299                dir.path(),
1300                "src/index.ts",
1301                &canonical,
1302                EntryPointSource::PackageJsonMain,
1303            );
1304            assert!(result.is_some(), "should resolve an existing file");
1305            assert!(result.unwrap().path.ends_with("src/index.ts"));
1306        }
1307
1308        #[test]
1309        fn resolves_with_extension_fallback() {
1310            let dir = tempfile::tempdir().expect("create temp dir");
1311            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1312            let canonical = dunce::canonicalize(dir.path()).unwrap();
1313            let src = canonical.join("src");
1314            std::fs::create_dir_all(&src).unwrap();
1315            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1316
1317            // Provide path without extension — should try adding .ts, .tsx, etc.
1318            let result = resolve_entry_path(
1319                &canonical,
1320                "src/index",
1321                &canonical,
1322                EntryPointSource::PackageJsonMain,
1323            );
1324            assert!(
1325                result.is_some(),
1326                "should resolve via extension fallback when exact path doesn't exist"
1327            );
1328            let ep = result.unwrap();
1329            assert!(
1330                ep.path.to_string_lossy().contains("index.ts"),
1331                "should find index.ts via extension fallback"
1332            );
1333        }
1334
1335        #[test]
1336        fn returns_none_for_nonexistent_file() {
1337            let dir = tempfile::tempdir().expect("create temp dir");
1338            let canonical = dunce::canonicalize(dir.path()).unwrap();
1339            let result = resolve_entry_path(
1340                dir.path(),
1341                "does/not/exist.ts",
1342                &canonical,
1343                EntryPointSource::PackageJsonMain,
1344            );
1345            assert!(result.is_none(), "should return None for nonexistent files");
1346        }
1347
1348        #[test]
1349        fn maps_dist_output_to_src() {
1350            let dir = tempfile::tempdir().expect("create temp dir");
1351            let src = dir.path().join("src");
1352            std::fs::create_dir_all(&src).unwrap();
1353            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1354
1355            // Also create the dist/ file to make sure it prefers src/
1356            let dist = dir.path().join("dist");
1357            std::fs::create_dir_all(&dist).unwrap();
1358            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1359
1360            let canonical = dunce::canonicalize(dir.path()).unwrap();
1361            let result = resolve_entry_path(
1362                dir.path(),
1363                "./dist/utils.js",
1364                &canonical,
1365                EntryPointSource::PackageJsonExports,
1366            );
1367            assert!(result.is_some(), "should resolve dist/ path to src/");
1368            let ep = result.unwrap();
1369            assert!(
1370                ep.path
1371                    .to_string_lossy()
1372                    .replace('\\', "/")
1373                    .contains("src/utils.ts"),
1374                "should map ./dist/utils.js to src/utils.ts"
1375            );
1376        }
1377
1378        #[test]
1379        fn maps_build_output_to_src() {
1380            let dir = tempfile::tempdir().expect("create temp dir");
1381            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1382            let canonical = dunce::canonicalize(dir.path()).unwrap();
1383            let src = canonical.join("src");
1384            std::fs::create_dir_all(&src).unwrap();
1385            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1386
1387            let result = resolve_entry_path(
1388                &canonical,
1389                "./build/index.js",
1390                &canonical,
1391                EntryPointSource::PackageJsonExports,
1392            );
1393            assert!(result.is_some(), "should map build/ output to src/");
1394            let ep = result.unwrap();
1395            assert!(
1396                ep.path
1397                    .to_string_lossy()
1398                    .replace('\\', "/")
1399                    .contains("src/index.tsx"),
1400                "should map ./build/index.js to src/index.tsx"
1401            );
1402        }
1403
1404        #[test]
1405        fn preserves_entry_point_source() {
1406            let dir = tempfile::tempdir().expect("create temp dir");
1407            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1408
1409            let canonical = dunce::canonicalize(dir.path()).unwrap();
1410            let result = resolve_entry_path(
1411                dir.path(),
1412                "index.ts",
1413                &canonical,
1414                EntryPointSource::PackageJsonScript,
1415            );
1416            assert!(result.is_some());
1417            assert!(
1418                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1419                "should preserve the source kind"
1420            );
1421        }
1422        #[test]
1423        fn tracks_skipped_entries_without_logging_each_repeat() {
1424            let dir = tempfile::tempdir().expect("create temp dir");
1425            let canonical = dunce::canonicalize(dir.path()).unwrap();
1426            let mut skipped_entries = FxHashMap::default();
1427
1428            let result = resolve_entry_path_with_tracking(
1429                dir.path(),
1430                "../scripts/build.js",
1431                &canonical,
1432                EntryPointSource::PackageJsonScript,
1433                Some(&mut skipped_entries),
1434            );
1435
1436            assert!(result.is_none(), "unsafe entry should be skipped");
1437            assert_eq!(
1438                skipped_entries.get("../scripts/build.js"),
1439                Some(&1),
1440                "warning tracker should count the skipped path"
1441            );
1442        }
1443
1444        #[test]
1445        fn formats_skipped_entry_warning_with_counts() {
1446            let mut skipped_entries = FxHashMap::default();
1447            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1448            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1449
1450            let warning =
1451                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1452
1453            assert_eq!(
1454                warning,
1455                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1456            );
1457        }
1458
1459        #[test]
1460        fn rejects_parent_dir_escape_for_exact_file() {
1461            let sandbox = tempfile::tempdir().expect("create sandbox");
1462            let root = sandbox.path().join("project");
1463            std::fs::create_dir_all(&root).unwrap();
1464            std::fs::write(
1465                sandbox.path().join("escape.ts"),
1466                "export const escape = true;",
1467            )
1468            .unwrap();
1469
1470            let canonical = dunce::canonicalize(&root).unwrap();
1471            let result = resolve_entry_path(
1472                &root,
1473                "../escape.ts",
1474                &canonical,
1475                EntryPointSource::PackageJsonMain,
1476            );
1477
1478            assert!(
1479                result.is_none(),
1480                "should reject exact paths that escape the root"
1481            );
1482        }
1483
1484        #[test]
1485        fn rejects_parent_dir_escape_via_extension_fallback() {
1486            let sandbox = tempfile::tempdir().expect("create sandbox");
1487            let root = sandbox.path().join("project");
1488            std::fs::create_dir_all(&root).unwrap();
1489            std::fs::write(
1490                sandbox.path().join("escape.ts"),
1491                "export const escape = true;",
1492            )
1493            .unwrap();
1494
1495            let canonical = dunce::canonicalize(&root).unwrap();
1496            let result = resolve_entry_path(
1497                &root,
1498                "../escape",
1499                &canonical,
1500                EntryPointSource::PackageJsonMain,
1501            );
1502
1503            assert!(
1504                result.is_none(),
1505                "should reject extension fallback paths that escape the root"
1506            );
1507        }
1508    }
1509
1510    // try_output_to_source_path unit tests
1511    mod output_to_source_tests {
1512        use super::*;
1513
1514        #[test]
1515        fn maps_dist_to_src_with_ts_extension() {
1516            let dir = tempfile::tempdir().expect("create temp dir");
1517            let src = dir.path().join("src");
1518            std::fs::create_dir_all(&src).unwrap();
1519            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1520
1521            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1522            assert!(result.is_some());
1523            assert!(
1524                result
1525                    .unwrap()
1526                    .to_string_lossy()
1527                    .replace('\\', "/")
1528                    .contains("src/utils.ts")
1529            );
1530        }
1531
1532        #[test]
1533        fn returns_none_when_no_source_file_exists() {
1534            let dir = tempfile::tempdir().expect("create temp dir");
1535            // No src/ directory at all
1536            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1537            assert!(result.is_none());
1538        }
1539
1540        #[test]
1541        fn ignores_non_output_directories() {
1542            let dir = tempfile::tempdir().expect("create temp dir");
1543            let src = dir.path().join("src");
1544            std::fs::create_dir_all(&src).unwrap();
1545            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1546
1547            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1548            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1549            assert!(result.is_none());
1550        }
1551
1552        #[test]
1553        fn maps_nested_output_path_preserving_prefix() {
1554            let dir = tempfile::tempdir().expect("create temp dir");
1555            let modules_src = dir.path().join("modules").join("src");
1556            std::fs::create_dir_all(&modules_src).unwrap();
1557            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1558
1559            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1560            assert!(result.is_some());
1561            assert!(
1562                result
1563                    .unwrap()
1564                    .to_string_lossy()
1565                    .replace('\\', "/")
1566                    .contains("modules/src/helper.ts")
1567            );
1568        }
1569    }
1570
1571    // Source index fallback unit tests (issue #102)
1572    mod source_index_fallback_tests {
1573        use super::*;
1574
1575        #[test]
1576        fn detects_dist_entry_in_output_dir() {
1577            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1578            assert!(is_entry_in_output_dir("dist/index.js"));
1579            assert!(is_entry_in_output_dir("./build/index.js"));
1580            assert!(is_entry_in_output_dir("./out/main.js"));
1581            assert!(is_entry_in_output_dir("./esm/index.js"));
1582            assert!(is_entry_in_output_dir("./cjs/index.js"));
1583        }
1584
1585        #[test]
1586        fn rejects_non_output_entry_paths() {
1587            assert!(!is_entry_in_output_dir("./src/index.ts"));
1588            assert!(!is_entry_in_output_dir("src/main.ts"));
1589            assert!(!is_entry_in_output_dir("./index.js"));
1590            assert!(!is_entry_in_output_dir(""));
1591        }
1592
1593        #[test]
1594        fn rejects_substring_match_for_output_dir() {
1595            // "distro" contains "dist" as a substring but is not an output dir
1596            assert!(!is_entry_in_output_dir("./distro/index.js"));
1597            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1598        }
1599
1600        #[test]
1601        fn finds_src_index_ts() {
1602            let dir = tempfile::tempdir().expect("create temp dir");
1603            let src = dir.path().join("src");
1604            std::fs::create_dir_all(&src).unwrap();
1605            let index_path = src.join("index.ts");
1606            std::fs::write(&index_path, "export const a = 1;").unwrap();
1607
1608            let result = try_source_index_fallback(dir.path());
1609            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1610        }
1611
1612        #[test]
1613        fn finds_src_index_tsx_when_ts_missing() {
1614            let dir = tempfile::tempdir().expect("create temp dir");
1615            let src = dir.path().join("src");
1616            std::fs::create_dir_all(&src).unwrap();
1617            let index_path = src.join("index.tsx");
1618            std::fs::write(&index_path, "export default 1;").unwrap();
1619
1620            let result = try_source_index_fallback(dir.path());
1621            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1622        }
1623
1624        #[test]
1625        fn prefers_src_index_over_root_index() {
1626            // Source index fallback must prefer `src/index.*` over root-level `index.*`
1627            // because library conventions keep source under `src/`.
1628            let dir = tempfile::tempdir().expect("create temp dir");
1629            let src = dir.path().join("src");
1630            std::fs::create_dir_all(&src).unwrap();
1631            let src_index = src.join("index.ts");
1632            std::fs::write(&src_index, "export const a = 1;").unwrap();
1633            let root_index = dir.path().join("index.ts");
1634            std::fs::write(&root_index, "export const b = 2;").unwrap();
1635
1636            let result = try_source_index_fallback(dir.path());
1637            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1638        }
1639
1640        #[test]
1641        fn falls_back_to_src_main() {
1642            let dir = tempfile::tempdir().expect("create temp dir");
1643            let src = dir.path().join("src");
1644            std::fs::create_dir_all(&src).unwrap();
1645            let main_path = src.join("main.ts");
1646            std::fs::write(&main_path, "export const a = 1;").unwrap();
1647
1648            let result = try_source_index_fallback(dir.path());
1649            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1650        }
1651
1652        #[test]
1653        fn falls_back_to_root_index_when_no_src() {
1654            let dir = tempfile::tempdir().expect("create temp dir");
1655            let index_path = dir.path().join("index.js");
1656            std::fs::write(&index_path, "module.exports = {};").unwrap();
1657
1658            let result = try_source_index_fallback(dir.path());
1659            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1660        }
1661
1662        #[test]
1663        fn returns_none_when_nothing_matches() {
1664            let dir = tempfile::tempdir().expect("create temp dir");
1665            let result = try_source_index_fallback(dir.path());
1666            assert!(result.is_none());
1667        }
1668
1669        #[test]
1670        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1671            let dir = tempfile::tempdir().expect("create temp dir");
1672            let canonical = dunce::canonicalize(dir.path()).unwrap();
1673
1674            // dist/esm2022/index.js exists but there's no src/esm2022/ mirror —
1675            // only src/index.ts. Without the fallback, resolve_entry_path would
1676            // return the dist file, which then gets filtered out by the ignore
1677            // pattern.
1678            let dist_dir = canonical.join("dist").join("esm2022");
1679            std::fs::create_dir_all(&dist_dir).unwrap();
1680            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1681
1682            let src = canonical.join("src");
1683            std::fs::create_dir_all(&src).unwrap();
1684            let src_index = src.join("index.ts");
1685            std::fs::write(&src_index, "export const x = 1;").unwrap();
1686
1687            let result = resolve_entry_path(
1688                &canonical,
1689                "./dist/esm2022/index.js",
1690                &canonical,
1691                EntryPointSource::PackageJsonMain,
1692            );
1693            assert!(result.is_some());
1694            let entry = result.unwrap();
1695            assert_eq!(entry.path, src_index);
1696        }
1697
1698        #[test]
1699        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1700            // When `src/esm2022/index.ts` exists, the existing mirror logic wins
1701            // and the fallback should not fire.
1702            let dir = tempfile::tempdir().expect("create temp dir");
1703            let canonical = dunce::canonicalize(dir.path()).unwrap();
1704
1705            let src_mirror = canonical.join("src").join("esm2022");
1706            std::fs::create_dir_all(&src_mirror).unwrap();
1707            let mirror_index = src_mirror.join("index.ts");
1708            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1709
1710            // Also create src/index.ts to confirm the mirror wins over the fallback.
1711            let src_index = canonical.join("src").join("index.ts");
1712            std::fs::write(&src_index, "export const y = 2;").unwrap();
1713
1714            let result = resolve_entry_path(
1715                &canonical,
1716                "./dist/esm2022/index.js",
1717                &canonical,
1718                EntryPointSource::PackageJsonMain,
1719            );
1720            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1721        }
1722    }
1723
1724    // apply_default_fallback unit tests
1725    mod default_fallback_tests {
1726        use super::*;
1727
1728        #[test]
1729        fn finds_src_index_ts_as_fallback() {
1730            let dir = tempfile::tempdir().expect("create temp dir");
1731            let src = dir.path().join("src");
1732            std::fs::create_dir_all(&src).unwrap();
1733            let index_path = src.join("index.ts");
1734            std::fs::write(&index_path, "export const a = 1;").unwrap();
1735
1736            let files = vec![DiscoveredFile {
1737                id: FileId(0),
1738                path: index_path.clone(),
1739                size_bytes: 20,
1740            }];
1741
1742            let entries = apply_default_fallback(&files, dir.path(), None);
1743            assert_eq!(entries.len(), 1);
1744            assert_eq!(entries[0].path, index_path);
1745            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1746        }
1747
1748        #[test]
1749        fn finds_root_index_js_as_fallback() {
1750            let dir = tempfile::tempdir().expect("create temp dir");
1751            let index_path = dir.path().join("index.js");
1752            std::fs::write(&index_path, "module.exports = {};").unwrap();
1753
1754            let files = vec![DiscoveredFile {
1755                id: FileId(0),
1756                path: index_path.clone(),
1757                size_bytes: 21,
1758            }];
1759
1760            let entries = apply_default_fallback(&files, dir.path(), None);
1761            assert_eq!(entries.len(), 1);
1762            assert_eq!(entries[0].path, index_path);
1763        }
1764
1765        #[test]
1766        fn returns_empty_when_no_index_file() {
1767            let dir = tempfile::tempdir().expect("create temp dir");
1768            let other_path = dir.path().join("src").join("utils.ts");
1769
1770            let files = vec![DiscoveredFile {
1771                id: FileId(0),
1772                path: other_path,
1773                size_bytes: 10,
1774            }];
1775
1776            let entries = apply_default_fallback(&files, dir.path(), None);
1777            assert!(
1778                entries.is_empty(),
1779                "non-index files should not match default fallback"
1780            );
1781        }
1782
1783        #[test]
1784        fn workspace_filter_restricts_scope() {
1785            let dir = tempfile::tempdir().expect("create temp dir");
1786            let ws_a = dir.path().join("packages").join("a").join("src");
1787            std::fs::create_dir_all(&ws_a).unwrap();
1788            let ws_b = dir.path().join("packages").join("b").join("src");
1789            std::fs::create_dir_all(&ws_b).unwrap();
1790
1791            let index_a = ws_a.join("index.ts");
1792            let index_b = ws_b.join("index.ts");
1793
1794            let files = vec![
1795                DiscoveredFile {
1796                    id: FileId(0),
1797                    path: index_a.clone(),
1798                    size_bytes: 10,
1799                },
1800                DiscoveredFile {
1801                    id: FileId(1),
1802                    path: index_b,
1803                    size_bytes: 10,
1804                },
1805            ];
1806
1807            // Filter to workspace A only
1808            let ws_root = dir.path().join("packages").join("a");
1809            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1810            assert_eq!(entries.len(), 1);
1811            assert_eq!(entries[0].path, index_a);
1812        }
1813    }
1814
1815    // expand_wildcard_entries unit tests
1816    mod wildcard_entry_tests {
1817        use super::*;
1818
1819        #[test]
1820        fn expands_wildcard_css_entries() {
1821            // Wildcard subpath exports like `"./themes/*": { "import": "./src/themes/*.css" }`
1822            // should expand to actual CSS files on disk.
1823            let dir = tempfile::tempdir().expect("create temp dir");
1824            let themes = dir.path().join("src").join("themes");
1825            std::fs::create_dir_all(&themes).unwrap();
1826            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1827            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1828
1829            let canonical = dunce::canonicalize(dir.path()).unwrap();
1830            let mut entries = Vec::new();
1831            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1832
1833            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1834            let paths: Vec<String> = entries
1835                .iter()
1836                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1837                .collect();
1838            assert!(paths.contains(&"dark.css".to_string()));
1839            assert!(paths.contains(&"light.css".to_string()));
1840            assert!(
1841                entries
1842                    .iter()
1843                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1844            );
1845        }
1846
1847        #[test]
1848        fn wildcard_does_not_match_nonexistent_files() {
1849            let dir = tempfile::tempdir().expect("create temp dir");
1850            // No files matching the pattern
1851            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1852
1853            let canonical = dunce::canonicalize(dir.path()).unwrap();
1854            let mut entries = Vec::new();
1855            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1856
1857            assert!(
1858                entries.is_empty(),
1859                "should return empty when no files match the wildcard"
1860            );
1861        }
1862
1863        #[test]
1864        fn wildcard_only_matches_specified_extension() {
1865            // Wildcard pattern `*.css` should not match `.ts` files
1866            let dir = tempfile::tempdir().expect("create temp dir");
1867            let themes = dir.path().join("src").join("themes");
1868            std::fs::create_dir_all(&themes).unwrap();
1869            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1870            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1871
1872            let canonical = dunce::canonicalize(dir.path()).unwrap();
1873            let mut entries = Vec::new();
1874            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1875
1876            assert_eq!(entries.len(), 1, "should only match CSS files");
1877            assert!(
1878                entries[0]
1879                    .path
1880                    .file_name()
1881                    .unwrap()
1882                    .to_string_lossy()
1883                    .ends_with(".css")
1884            );
1885        }
1886    }
1887}