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