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