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