Skip to main content

fallow_core/discover/
entry_points.rs

1use std::path::{Path, PathBuf};
2
3use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
4use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
5
6use super::parse_scripts::extract_script_file_refs;
7use super::walk::SOURCE_EXTENSIONS;
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"];
13
14/// Entry points grouped by reachability role.
15#[derive(Debug, Clone, Default)]
16pub struct CategorizedEntryPoints {
17    pub all: Vec<EntryPoint>,
18    pub runtime: Vec<EntryPoint>,
19    pub test: Vec<EntryPoint>,
20}
21
22impl CategorizedEntryPoints {
23    pub fn push_runtime(&mut self, entry: EntryPoint) {
24        self.runtime.push(entry.clone());
25        self.all.push(entry);
26    }
27
28    pub fn push_test(&mut self, entry: EntryPoint) {
29        self.test.push(entry.clone());
30        self.all.push(entry);
31    }
32
33    pub fn push_support(&mut self, entry: EntryPoint) {
34        self.all.push(entry);
35    }
36
37    pub fn extend_runtime<I>(&mut self, entries: I)
38    where
39        I: IntoIterator<Item = EntryPoint>,
40    {
41        for entry in entries {
42            self.push_runtime(entry);
43        }
44    }
45
46    pub fn extend_test<I>(&mut self, entries: I)
47    where
48        I: IntoIterator<Item = EntryPoint>,
49    {
50        for entry in entries {
51            self.push_test(entry);
52        }
53    }
54
55    pub fn extend_support<I>(&mut self, entries: I)
56    where
57        I: IntoIterator<Item = EntryPoint>,
58    {
59        for entry in entries {
60            self.push_support(entry);
61        }
62    }
63
64    pub fn extend(&mut self, other: Self) {
65        self.all.extend(other.all);
66        self.runtime.extend(other.runtime);
67        self.test.extend(other.test);
68    }
69
70    #[must_use]
71    pub fn dedup(mut self) -> Self {
72        dedup_entry_paths(&mut self.all);
73        dedup_entry_paths(&mut self.runtime);
74        dedup_entry_paths(&mut self.test);
75        self
76    }
77}
78
79fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
80    entries.sort_by(|a, b| a.path.cmp(&b.path));
81    entries.dedup_by(|a, b| a.path == b.path);
82}
83
84/// Resolve a path relative to a base directory, with security check and extension fallback.
85///
86/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
87/// trying source extensions as fallback when the exact path doesn't exist.
88/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
89/// by trying to map back to the source file (e.g., `./src/utils.ts`).
90pub fn resolve_entry_path(
91    base: &Path,
92    entry: &str,
93    canonical_root: &Path,
94    source: EntryPointSource,
95) -> Option<EntryPoint> {
96    // Wildcard exports (e.g., `./src/themes/*.css`) can't be resolved to a single
97    // file. Return None and let the caller expand them separately.
98    if entry.contains('*') {
99        return None;
100    }
101
102    let resolved = base.join(entry);
103    // Security: ensure resolved path stays within the allowed root
104    let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
105    if !canonical_resolved.starts_with(canonical_root) {
106        tracing::warn!(path = %entry, "Skipping entry point outside project root");
107        return None;
108    }
109
110    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
111    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
112    // We check this BEFORE the exists() check because even if the dist file exists,
113    // fallow ignores dist/ by default, so we need the source file instead.
114    if let Some(source_path) = try_output_to_source_path(base, entry) {
115        // Security: ensure the mapped source path stays within the project root
116        if let Ok(canonical_source) = dunce::canonicalize(&source_path)
117            && canonical_source.starts_with(canonical_root)
118        {
119            return Some(EntryPoint {
120                path: source_path,
121                source,
122            });
123        }
124    }
125
126    if resolved.exists() {
127        return Some(EntryPoint {
128            path: resolved,
129            source,
130        });
131    }
132    // Try with source extensions
133    for ext in SOURCE_EXTENSIONS {
134        let with_ext = resolved.with_extension(ext);
135        if with_ext.exists() {
136            return Some(EntryPoint {
137                path: with_ext,
138                source,
139            });
140        }
141    }
142    None
143}
144
145/// Try to map an entry path from an output directory to its source equivalent.
146///
147/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
148/// - `/project/packages/ui/src/utils.ts`
149/// - `/project/packages/ui/src/utils.tsx`
150/// - etc. for all source extensions
151///
152/// Preserves any path prefix between the package root and the output dir,
153/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
154///
155/// Returns `Some(path)` if a source file is found.
156fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
157    let entry_path = Path::new(entry);
158    let components: Vec<_> = entry_path.components().collect();
159
160    // Find the last output directory component in the entry path
161    let output_pos = components.iter().rposition(|c| {
162        if let std::path::Component::Normal(s) = c
163            && let Some(name) = s.to_str()
164        {
165            return OUTPUT_DIRS.contains(&name);
166        }
167        false
168    })?;
169
170    // Build the relative prefix before the output dir, filtering out CurDir (".")
171    let prefix: PathBuf = components[..output_pos]
172        .iter()
173        .filter(|c| !matches!(c, std::path::Component::CurDir))
174        .collect();
175
176    // Build the relative path after the output dir (e.g., "utils.js")
177    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
178
179    // Try base + prefix + "src" + suffix-with-source-extension
180    for ext in SOURCE_EXTENSIONS {
181        let source_candidate = base
182            .join(&prefix)
183            .join("src")
184            .join(suffix.with_extension(ext));
185        if source_candidate.exists() {
186            return Some(source_candidate);
187        }
188    }
189
190    None
191}
192
193/// Default index patterns used when no other entry points are found.
194const DEFAULT_INDEX_PATTERNS: &[&str] = &[
195    "src/index.{ts,tsx,js,jsx}",
196    "src/main.{ts,tsx,js,jsx}",
197    "index.{ts,tsx,js,jsx}",
198    "main.{ts,tsx,js,jsx}",
199];
200
201/// Fall back to default index patterns if no entries were found.
202///
203/// When `ws_filter` is `Some`, only files whose path starts with the given
204/// workspace root are considered (used for workspace-scoped discovery).
205fn apply_default_fallback(
206    files: &[DiscoveredFile],
207    root: &Path,
208    ws_filter: Option<&Path>,
209) -> Vec<EntryPoint> {
210    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
211        .iter()
212        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
213        .collect();
214
215    let mut entries = Vec::new();
216    for file in files {
217        // Use strip_prefix instead of canonicalize for workspace filtering
218        if let Some(ws_root) = ws_filter
219            && file.path.strip_prefix(ws_root).is_err()
220        {
221            continue;
222        }
223        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
224        let relative_str = relative.to_string_lossy();
225        if default_matchers
226            .iter()
227            .any(|m| m.is_match(relative_str.as_ref()))
228        {
229            entries.push(EntryPoint {
230                path: file.path.clone(),
231                source: EntryPointSource::DefaultIndex,
232            });
233        }
234    }
235    entries
236}
237
238/// Discover entry points from package.json, framework rules, and defaults.
239pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
240    let _span = tracing::info_span!("discover_entry_points").entered();
241    let mut entries = Vec::new();
242
243    // Pre-compute relative paths for all files (once, not per pattern)
244    let relative_paths: Vec<String> = files
245        .iter()
246        .map(|f| {
247            f.path
248                .strip_prefix(&config.root)
249                .unwrap_or(&f.path)
250                .to_string_lossy()
251                .into_owned()
252        })
253        .collect();
254
255    // 1. Manual entries from config — batch all patterns into a single GlobSet
256    // for O(files) matching instead of O(patterns × files).
257    {
258        let mut builder = globset::GlobSetBuilder::new();
259        for pattern in &config.entry_patterns {
260            if let Ok(glob) = globset::Glob::new(pattern) {
261                builder.add(glob);
262            }
263        }
264        if let Ok(glob_set) = builder.build()
265            && !glob_set.is_empty()
266        {
267            for (idx, rel) in relative_paths.iter().enumerate() {
268                if glob_set.is_match(rel) {
269                    entries.push(EntryPoint {
270                        path: files[idx].path.clone(),
271                        source: EntryPointSource::ManualEntry,
272                    });
273                }
274            }
275        }
276    }
277
278    // 2. Package.json entries
279    // Pre-compute canonical root once for all resolve_entry_path calls
280    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
281    let pkg_path = config.root.join("package.json");
282    let root_pkg = PackageJson::load(&pkg_path).ok();
283    if let Some(pkg) = &root_pkg {
284        for entry_path in pkg.entry_points() {
285            if let Some(ep) = resolve_entry_path(
286                &config.root,
287                &entry_path,
288                &canonical_root,
289                EntryPointSource::PackageJsonMain,
290            ) {
291                entries.push(ep);
292            }
293        }
294
295        // 2b. Package.json scripts — extract file references as entry points
296        if let Some(scripts) = &pkg.scripts {
297            for script_value in scripts.values() {
298                for file_ref in extract_script_file_refs(script_value) {
299                    if let Some(ep) = resolve_entry_path(
300                        &config.root,
301                        &file_ref,
302                        &canonical_root,
303                        EntryPointSource::PackageJsonScript,
304                    ) {
305                        entries.push(ep);
306                    }
307                }
308            }
309        }
310
311        // Framework rules now flow through PluginRegistry via external_plugins.
312    }
313
314    // 4. Auto-discover nested package.json entry points
315    // For monorepo-like structures without explicit workspace config, scan for
316    // package.json files in subdirectories and use their main/exports as entries.
317    let exports_dirs = root_pkg
318        .map(|pkg| pkg.exports_subdirectories())
319        .unwrap_or_default();
320    discover_nested_package_entries(
321        &config.root,
322        files,
323        &mut entries,
324        &canonical_root,
325        &exports_dirs,
326    );
327
328    // 5. Default index files (if no other entries found)
329    if entries.is_empty() {
330        entries = apply_default_fallback(files, &config.root, None);
331    }
332
333    // Deduplicate by path
334    entries.sort_by(|a, b| a.path.cmp(&b.path));
335    entries.dedup_by(|a, b| a.path == b.path);
336
337    entries
338}
339
340/// Discover entry points from nested package.json files in subdirectories.
341///
342/// Scans two sources for sub-packages:
343/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
344/// 2. Directories derived from the root package.json `exports` map keys
345///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
346///
347/// For each discovered sub-package with a `package.json`, the `main`, `module`,
348/// `source`, `exports`, and `bin` fields are treated as entry points.
349fn discover_nested_package_entries(
350    root: &Path,
351    _files: &[DiscoveredFile],
352    entries: &mut Vec<EntryPoint>,
353    canonical_root: &Path,
354    exports_subdirectories: &[String],
355) {
356    let mut visited = rustc_hash::FxHashSet::default();
357
358    // 1. Walk common monorepo patterns
359    let search_dirs = [
360        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
361    ];
362    for dir_name in &search_dirs {
363        let search_dir = root.join(dir_name);
364        if !search_dir.is_dir() {
365            continue;
366        }
367        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
368            continue;
369        };
370        for entry in read_dir.flatten() {
371            let pkg_dir = entry.path();
372            if visited.insert(pkg_dir.clone()) {
373                collect_nested_package_entries(&pkg_dir, entries, canonical_root);
374            }
375        }
376    }
377
378    // 2. Scan directories derived from the root exports map
379    for dir_name in exports_subdirectories {
380        let pkg_dir = root.join(dir_name);
381        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
382            collect_nested_package_entries(&pkg_dir, entries, canonical_root);
383        }
384    }
385}
386
387/// Collect entry points from a single sub-package directory.
388fn collect_nested_package_entries(
389    pkg_dir: &Path,
390    entries: &mut Vec<EntryPoint>,
391    canonical_root: &Path,
392) {
393    let pkg_path = pkg_dir.join("package.json");
394    if !pkg_path.exists() {
395        return;
396    }
397    let Ok(pkg) = PackageJson::load(&pkg_path) else {
398        return;
399    };
400    for entry_path in pkg.entry_points() {
401        if entry_path.contains('*') {
402            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
403        } else if let Some(ep) = resolve_entry_path(
404            pkg_dir,
405            &entry_path,
406            canonical_root,
407            EntryPointSource::PackageJsonExports,
408        ) {
409            entries.push(ep);
410        }
411    }
412    if let Some(scripts) = &pkg.scripts {
413        for script_value in scripts.values() {
414            for file_ref in extract_script_file_refs(script_value) {
415                if let Some(ep) = resolve_entry_path(
416                    pkg_dir,
417                    &file_ref,
418                    canonical_root,
419                    EntryPointSource::PackageJsonScript,
420                ) {
421                    entries.push(ep);
422                }
423            }
424        }
425    }
426}
427
428/// Expand wildcard subpath exports to matching files on disk.
429///
430/// Handles patterns like `./src/themes/*.css` from package.json exports maps
431/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
432/// match actual files in the target directory.
433fn expand_wildcard_entries(
434    base: &Path,
435    pattern: &str,
436    canonical_root: &Path,
437    entries: &mut Vec<EntryPoint>,
438) {
439    let full_pattern = base.join(pattern).to_string_lossy().to_string();
440    let Ok(matches) = glob::glob(&full_pattern) else {
441        return;
442    };
443    for path_result in matches {
444        let Ok(path) = path_result else {
445            continue;
446        };
447        if let Ok(canonical) = dunce::canonicalize(&path)
448            && canonical.starts_with(canonical_root)
449        {
450            entries.push(EntryPoint {
451                path,
452                source: EntryPointSource::PackageJsonExports,
453            });
454        }
455    }
456}
457
458/// Discover entry points for a workspace package.
459#[must_use]
460pub fn discover_workspace_entry_points(
461    ws_root: &Path,
462    _config: &ResolvedConfig,
463    all_files: &[DiscoveredFile],
464) -> Vec<EntryPoint> {
465    let mut entries = Vec::new();
466
467    let pkg_path = ws_root.join("package.json");
468    if let Ok(pkg) = PackageJson::load(&pkg_path) {
469        let canonical_ws_root =
470            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
471        for entry_path in pkg.entry_points() {
472            if entry_path.contains('*') {
473                expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
474            } else if let Some(ep) = resolve_entry_path(
475                ws_root,
476                &entry_path,
477                &canonical_ws_root,
478                EntryPointSource::PackageJsonMain,
479            ) {
480                entries.push(ep);
481            }
482        }
483
484        // Scripts field — extract file references as entry points
485        if let Some(scripts) = &pkg.scripts {
486            for script_value in scripts.values() {
487                for file_ref in extract_script_file_refs(script_value) {
488                    if let Some(ep) = resolve_entry_path(
489                        ws_root,
490                        &file_ref,
491                        &canonical_ws_root,
492                        EntryPointSource::PackageJsonScript,
493                    ) {
494                        entries.push(ep);
495                    }
496                }
497            }
498        }
499
500        // Framework rules now flow through PluginRegistry via external_plugins.
501    }
502
503    // Fall back to default index files if no entry points found for this workspace
504    if entries.is_empty() {
505        entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
506    }
507
508    entries.sort_by(|a, b| a.path.cmp(&b.path));
509    entries.dedup_by(|a, b| a.path == b.path);
510    entries
511}
512
513/// Discover entry points from plugin results (dynamic config parsing).
514///
515/// Converts plugin-discovered patterns and setup files into concrete entry points
516/// by matching them against the discovered file list.
517#[must_use]
518pub fn discover_plugin_entry_points(
519    plugin_result: &crate::plugins::AggregatedPluginResult,
520    config: &ResolvedConfig,
521    files: &[DiscoveredFile],
522) -> Vec<EntryPoint> {
523    discover_plugin_entry_point_sets(plugin_result, config, files).all
524}
525
526/// Discover plugin-derived entry points with runtime/test/support roles preserved.
527#[must_use]
528pub fn discover_plugin_entry_point_sets(
529    plugin_result: &crate::plugins::AggregatedPluginResult,
530    config: &ResolvedConfig,
531    files: &[DiscoveredFile],
532) -> CategorizedEntryPoints {
533    let mut entries = CategorizedEntryPoints::default();
534
535    // Pre-compute relative paths
536    let relative_paths: Vec<String> = files
537        .iter()
538        .map(|f| {
539            f.path
540                .strip_prefix(&config.root)
541                .unwrap_or(&f.path)
542                .to_string_lossy()
543                .into_owned()
544        })
545        .collect();
546
547    // Match plugin entry patterns against files using a single GlobSet
548    // for O(files) matching instead of O(patterns × files).
549    // Track which plugin name and reachability role correspond to each glob index.
550    let mut builder = globset::GlobSetBuilder::new();
551    let mut glob_meta: Vec<(&str, EntryPointRole)> = Vec::new();
552    for (pattern, pname) in &plugin_result.entry_patterns {
553        if let Ok(glob) = globset::GlobBuilder::new(pattern)
554            .literal_separator(true)
555            .build()
556        {
557            builder.add(glob);
558            let role = plugin_result
559                .entry_point_roles
560                .get(pname)
561                .copied()
562                .unwrap_or(EntryPointRole::Support);
563            glob_meta.push((pname, role));
564        }
565    }
566    for (pattern, pname) in plugin_result
567        .discovered_always_used
568        .iter()
569        .chain(plugin_result.always_used.iter())
570        .chain(plugin_result.fixture_patterns.iter())
571    {
572        if let Ok(glob) = globset::GlobBuilder::new(pattern)
573            .literal_separator(true)
574            .build()
575        {
576            builder.add(glob);
577            glob_meta.push((pname, EntryPointRole::Support));
578        }
579    }
580    if let Ok(glob_set) = builder.build()
581        && !glob_set.is_empty()
582    {
583        for (idx, rel) in relative_paths.iter().enumerate() {
584            let matches = glob_set.matches(rel);
585            if !matches.is_empty() {
586                let (name, _) = glob_meta[matches[0]];
587                let entry = EntryPoint {
588                    path: files[idx].path.clone(),
589                    source: EntryPointSource::Plugin {
590                        name: name.to_string(),
591                    },
592                };
593
594                let mut has_runtime = false;
595                let mut has_test = false;
596                let mut has_support = false;
597                for match_idx in matches {
598                    match glob_meta[match_idx].1 {
599                        EntryPointRole::Runtime => has_runtime = true,
600                        EntryPointRole::Test => has_test = true,
601                        EntryPointRole::Support => has_support = true,
602                    }
603                }
604
605                if has_runtime {
606                    entries.push_runtime(entry.clone());
607                }
608                if has_test {
609                    entries.push_test(entry.clone());
610                }
611                if has_support || (!has_runtime && !has_test) {
612                    entries.push_support(entry);
613                }
614            }
615        }
616    }
617
618    // Add setup files (absolute paths from plugin config parsing)
619    for (setup_file, pname) in &plugin_result.setup_files {
620        let resolved = if setup_file.is_absolute() {
621            setup_file.clone()
622        } else {
623            config.root.join(setup_file)
624        };
625        if resolved.exists() {
626            entries.push_support(EntryPoint {
627                path: resolved,
628                source: EntryPointSource::Plugin {
629                    name: pname.clone(),
630                },
631            });
632        } else {
633            // Try with extensions
634            for ext in SOURCE_EXTENSIONS {
635                let with_ext = resolved.with_extension(ext);
636                if with_ext.exists() {
637                    entries.push_support(EntryPoint {
638                        path: with_ext,
639                        source: EntryPointSource::Plugin {
640                            name: pname.clone(),
641                        },
642                    });
643                    break;
644                }
645            }
646        }
647    }
648
649    entries.dedup()
650}
651
652/// Discover entry points from `dynamicallyLoaded` config patterns.
653///
654/// Matches the configured glob patterns against the discovered file list and
655/// marks matching files as entry points so they are never flagged as unused.
656#[must_use]
657pub fn discover_dynamically_loaded_entry_points(
658    config: &ResolvedConfig,
659    files: &[DiscoveredFile],
660) -> Vec<EntryPoint> {
661    if config.dynamically_loaded.is_empty() {
662        return Vec::new();
663    }
664
665    let mut builder = globset::GlobSetBuilder::new();
666    for pattern in &config.dynamically_loaded {
667        if let Ok(glob) = globset::Glob::new(pattern) {
668            builder.add(glob);
669        }
670    }
671    let Ok(glob_set) = builder.build() else {
672        return Vec::new();
673    };
674    if glob_set.is_empty() {
675        return Vec::new();
676    }
677
678    let mut entries = Vec::new();
679    for file in files {
680        let rel = file
681            .path
682            .strip_prefix(&config.root)
683            .unwrap_or(&file.path)
684            .to_string_lossy();
685        if glob_set.is_match(rel.as_ref()) {
686            entries.push(EntryPoint {
687                path: file.path.clone(),
688                source: EntryPointSource::DynamicallyLoaded,
689            });
690        }
691    }
692    entries
693}
694
695/// Pre-compile a set of glob patterns for efficient matching against many paths.
696#[must_use]
697pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
698    if patterns.is_empty() {
699        return None;
700    }
701    let mut builder = globset::GlobSetBuilder::new();
702    for pattern in patterns {
703        if let Ok(glob) = globset::GlobBuilder::new(pattern)
704            .literal_separator(true)
705            .build()
706        {
707            builder.add(glob);
708        }
709    }
710    builder.build().ok()
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
717    use fallow_types::discover::FileId;
718    use proptest::prelude::*;
719
720    proptest! {
721        /// Valid glob patterns should never panic when compiled via globset.
722        #[test]
723        fn glob_patterns_never_panic_on_compile(
724            prefix in "[a-zA-Z0-9_]{1,20}",
725            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
726        ) {
727            let pattern = format!("**/{prefix}*.{ext}");
728            // Should not panic — either compiles or returns Err gracefully
729            let result = globset::Glob::new(&pattern);
730            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
731        }
732
733        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
734        #[test]
735        fn non_source_extensions_not_in_list(
736            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
737        ) {
738            prop_assert!(
739                !SOURCE_EXTENSIONS.contains(&ext),
740                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
741            );
742        }
743
744        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
745        #[test]
746        fn compile_glob_set_no_panic(
747            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
748        ) {
749            // Should not panic regardless of input
750            let _ = compile_glob_set(&patterns);
751        }
752    }
753
754    // compile_glob_set unit tests
755    #[test]
756    fn compile_glob_set_empty_input() {
757        assert!(
758            compile_glob_set(&[]).is_none(),
759            "empty patterns should return None"
760        );
761    }
762
763    #[test]
764    fn compile_glob_set_valid_patterns() {
765        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
766        let set = compile_glob_set(&patterns);
767        assert!(set.is_some(), "valid patterns should compile");
768        let set = set.unwrap();
769        assert!(set.is_match("src/foo.ts"));
770        assert!(set.is_match("src/bar.js"));
771        assert!(!set.is_match("src/bar.py"));
772    }
773
774    #[test]
775    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
776        let patterns = vec!["composables/*.{ts,js}".to_string()];
777        let set = compile_glob_set(&patterns).expect("pattern should compile");
778
779        assert!(set.is_match("composables/useFoo.ts"));
780        assert!(!set.is_match("composables/nested/useFoo.ts"));
781    }
782
783    #[test]
784    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
785        let dir = tempfile::tempdir().expect("create temp dir");
786        let root = dir.path();
787        std::fs::create_dir_all(root.join("src")).unwrap();
788        std::fs::create_dir_all(root.join("tests")).unwrap();
789        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
790        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
791        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
792
793        let config = FallowConfig {
794            schema: None,
795            extends: vec![],
796            entry: vec![],
797            ignore_patterns: vec![],
798            framework: vec![],
799            workspaces: None,
800            ignore_dependencies: vec![],
801            ignore_exports: vec![],
802            duplicates: fallow_config::DuplicatesConfig::default(),
803            health: fallow_config::HealthConfig::default(),
804            rules: RulesConfig::default(),
805            boundaries: fallow_config::BoundaryConfig::default(),
806            production: false,
807            plugins: vec![],
808            dynamically_loaded: vec![],
809            overrides: vec![],
810            regression: None,
811            codeowners: None,
812            public_packages: vec![],
813        }
814        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
815
816        let files = vec![
817            DiscoveredFile {
818                id: FileId(0),
819                path: root.join("src/runtime.ts"),
820                size_bytes: 1,
821            },
822            DiscoveredFile {
823                id: FileId(1),
824                path: root.join("src/setup.ts"),
825                size_bytes: 1,
826            },
827            DiscoveredFile {
828                id: FileId(2),
829                path: root.join("tests/app.test.ts"),
830                size_bytes: 1,
831            },
832        ];
833
834        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
835        plugin_result
836            .entry_patterns
837            .push(("src/runtime.ts".to_string(), "runtime-plugin".to_string()));
838        plugin_result
839            .entry_patterns
840            .push(("tests/app.test.ts".to_string(), "test-plugin".to_string()));
841        plugin_result
842            .always_used
843            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
844        plugin_result
845            .entry_point_roles
846            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
847        plugin_result
848            .entry_point_roles
849            .insert("test-plugin".to_string(), EntryPointRole::Test);
850        plugin_result
851            .entry_point_roles
852            .insert("support-plugin".to_string(), EntryPointRole::Support);
853
854        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
855
856        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
857        assert!(
858            entries.runtime[0].path.ends_with("src/runtime.ts"),
859            "runtime entry should stay runtime-only"
860        );
861        assert_eq!(entries.test.len(), 1, "expected one test entry");
862        assert!(
863            entries.test[0].path.ends_with("tests/app.test.ts"),
864            "test entry should stay test-only"
865        );
866        assert_eq!(
867            entries.all.len(),
868            3,
869            "support entries should stay in all entries"
870        );
871        assert!(
872            entries
873                .all
874                .iter()
875                .any(|entry| entry.path.ends_with("src/setup.ts")),
876            "support entries should remain in the overall entry-point set"
877        );
878        assert!(
879            !entries
880                .runtime
881                .iter()
882                .any(|entry| entry.path.ends_with("src/setup.ts")),
883            "support entries should not bleed into runtime reachability"
884        );
885        assert!(
886            !entries
887                .test
888                .iter()
889                .any(|entry| entry.path.ends_with("src/setup.ts")),
890            "support entries should not bleed into test reachability"
891        );
892    }
893
894    // resolve_entry_path unit tests
895    mod resolve_entry_path_tests {
896        use super::*;
897
898        #[test]
899        fn resolves_existing_file() {
900            let dir = tempfile::tempdir().expect("create temp dir");
901            let src = dir.path().join("src");
902            std::fs::create_dir_all(&src).unwrap();
903            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
904
905            let canonical = dunce::canonicalize(dir.path()).unwrap();
906            let result = resolve_entry_path(
907                dir.path(),
908                "src/index.ts",
909                &canonical,
910                EntryPointSource::PackageJsonMain,
911            );
912            assert!(result.is_some(), "should resolve an existing file");
913            assert!(result.unwrap().path.ends_with("src/index.ts"));
914        }
915
916        #[test]
917        fn resolves_with_extension_fallback() {
918            let dir = tempfile::tempdir().expect("create temp dir");
919            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
920            let canonical = dunce::canonicalize(dir.path()).unwrap();
921            let src = canonical.join("src");
922            std::fs::create_dir_all(&src).unwrap();
923            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
924
925            // Provide path without extension — should try adding .ts, .tsx, etc.
926            let result = resolve_entry_path(
927                &canonical,
928                "src/index",
929                &canonical,
930                EntryPointSource::PackageJsonMain,
931            );
932            assert!(
933                result.is_some(),
934                "should resolve via extension fallback when exact path doesn't exist"
935            );
936            let ep = result.unwrap();
937            assert!(
938                ep.path.to_string_lossy().contains("index.ts"),
939                "should find index.ts via extension fallback"
940            );
941        }
942
943        #[test]
944        fn returns_none_for_nonexistent_file() {
945            let dir = tempfile::tempdir().expect("create temp dir");
946            let canonical = dunce::canonicalize(dir.path()).unwrap();
947            let result = resolve_entry_path(
948                dir.path(),
949                "does/not/exist.ts",
950                &canonical,
951                EntryPointSource::PackageJsonMain,
952            );
953            assert!(result.is_none(), "should return None for nonexistent files");
954        }
955
956        #[test]
957        fn maps_dist_output_to_src() {
958            let dir = tempfile::tempdir().expect("create temp dir");
959            let src = dir.path().join("src");
960            std::fs::create_dir_all(&src).unwrap();
961            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
962
963            // Also create the dist/ file to make sure it prefers src/
964            let dist = dir.path().join("dist");
965            std::fs::create_dir_all(&dist).unwrap();
966            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
967
968            let canonical = dunce::canonicalize(dir.path()).unwrap();
969            let result = resolve_entry_path(
970                dir.path(),
971                "./dist/utils.js",
972                &canonical,
973                EntryPointSource::PackageJsonExports,
974            );
975            assert!(result.is_some(), "should resolve dist/ path to src/");
976            let ep = result.unwrap();
977            assert!(
978                ep.path
979                    .to_string_lossy()
980                    .replace('\\', "/")
981                    .contains("src/utils.ts"),
982                "should map ./dist/utils.js to src/utils.ts"
983            );
984        }
985
986        #[test]
987        fn maps_build_output_to_src() {
988            let dir = tempfile::tempdir().expect("create temp dir");
989            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
990            let canonical = dunce::canonicalize(dir.path()).unwrap();
991            let src = canonical.join("src");
992            std::fs::create_dir_all(&src).unwrap();
993            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
994
995            let result = resolve_entry_path(
996                &canonical,
997                "./build/index.js",
998                &canonical,
999                EntryPointSource::PackageJsonExports,
1000            );
1001            assert!(result.is_some(), "should map build/ output to src/");
1002            let ep = result.unwrap();
1003            assert!(
1004                ep.path
1005                    .to_string_lossy()
1006                    .replace('\\', "/")
1007                    .contains("src/index.tsx"),
1008                "should map ./build/index.js to src/index.tsx"
1009            );
1010        }
1011
1012        #[test]
1013        fn preserves_entry_point_source() {
1014            let dir = tempfile::tempdir().expect("create temp dir");
1015            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1016
1017            let canonical = dunce::canonicalize(dir.path()).unwrap();
1018            let result = resolve_entry_path(
1019                dir.path(),
1020                "index.ts",
1021                &canonical,
1022                EntryPointSource::PackageJsonScript,
1023            );
1024            assert!(result.is_some());
1025            assert!(
1026                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1027                "should preserve the source kind"
1028            );
1029        }
1030    }
1031
1032    // try_output_to_source_path unit tests
1033    mod output_to_source_tests {
1034        use super::*;
1035
1036        #[test]
1037        fn maps_dist_to_src_with_ts_extension() {
1038            let dir = tempfile::tempdir().expect("create temp dir");
1039            let src = dir.path().join("src");
1040            std::fs::create_dir_all(&src).unwrap();
1041            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1042
1043            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1044            assert!(result.is_some());
1045            assert!(
1046                result
1047                    .unwrap()
1048                    .to_string_lossy()
1049                    .replace('\\', "/")
1050                    .contains("src/utils.ts")
1051            );
1052        }
1053
1054        #[test]
1055        fn returns_none_when_no_source_file_exists() {
1056            let dir = tempfile::tempdir().expect("create temp dir");
1057            // No src/ directory at all
1058            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1059            assert!(result.is_none());
1060        }
1061
1062        #[test]
1063        fn ignores_non_output_directories() {
1064            let dir = tempfile::tempdir().expect("create temp dir");
1065            let src = dir.path().join("src");
1066            std::fs::create_dir_all(&src).unwrap();
1067            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1068
1069            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1070            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1071            assert!(result.is_none());
1072        }
1073
1074        #[test]
1075        fn maps_nested_output_path_preserving_prefix() {
1076            let dir = tempfile::tempdir().expect("create temp dir");
1077            let modules_src = dir.path().join("modules").join("src");
1078            std::fs::create_dir_all(&modules_src).unwrap();
1079            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1080
1081            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1082            assert!(result.is_some());
1083            assert!(
1084                result
1085                    .unwrap()
1086                    .to_string_lossy()
1087                    .replace('\\', "/")
1088                    .contains("modules/src/helper.ts")
1089            );
1090        }
1091    }
1092
1093    // apply_default_fallback unit tests
1094    mod default_fallback_tests {
1095        use super::*;
1096
1097        #[test]
1098        fn finds_src_index_ts_as_fallback() {
1099            let dir = tempfile::tempdir().expect("create temp dir");
1100            let src = dir.path().join("src");
1101            std::fs::create_dir_all(&src).unwrap();
1102            let index_path = src.join("index.ts");
1103            std::fs::write(&index_path, "export const a = 1;").unwrap();
1104
1105            let files = vec![DiscoveredFile {
1106                id: FileId(0),
1107                path: index_path.clone(),
1108                size_bytes: 20,
1109            }];
1110
1111            let entries = apply_default_fallback(&files, dir.path(), None);
1112            assert_eq!(entries.len(), 1);
1113            assert_eq!(entries[0].path, index_path);
1114            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1115        }
1116
1117        #[test]
1118        fn finds_root_index_js_as_fallback() {
1119            let dir = tempfile::tempdir().expect("create temp dir");
1120            let index_path = dir.path().join("index.js");
1121            std::fs::write(&index_path, "module.exports = {};").unwrap();
1122
1123            let files = vec![DiscoveredFile {
1124                id: FileId(0),
1125                path: index_path.clone(),
1126                size_bytes: 21,
1127            }];
1128
1129            let entries = apply_default_fallback(&files, dir.path(), None);
1130            assert_eq!(entries.len(), 1);
1131            assert_eq!(entries[0].path, index_path);
1132        }
1133
1134        #[test]
1135        fn returns_empty_when_no_index_file() {
1136            let dir = tempfile::tempdir().expect("create temp dir");
1137            let other_path = dir.path().join("src").join("utils.ts");
1138
1139            let files = vec![DiscoveredFile {
1140                id: FileId(0),
1141                path: other_path,
1142                size_bytes: 10,
1143            }];
1144
1145            let entries = apply_default_fallback(&files, dir.path(), None);
1146            assert!(
1147                entries.is_empty(),
1148                "non-index files should not match default fallback"
1149            );
1150        }
1151
1152        #[test]
1153        fn workspace_filter_restricts_scope() {
1154            let dir = tempfile::tempdir().expect("create temp dir");
1155            let ws_a = dir.path().join("packages").join("a").join("src");
1156            std::fs::create_dir_all(&ws_a).unwrap();
1157            let ws_b = dir.path().join("packages").join("b").join("src");
1158            std::fs::create_dir_all(&ws_b).unwrap();
1159
1160            let index_a = ws_a.join("index.ts");
1161            let index_b = ws_b.join("index.ts");
1162
1163            let files = vec![
1164                DiscoveredFile {
1165                    id: FileId(0),
1166                    path: index_a.clone(),
1167                    size_bytes: 10,
1168                },
1169                DiscoveredFile {
1170                    id: FileId(1),
1171                    path: index_b,
1172                    size_bytes: 10,
1173                },
1174            ];
1175
1176            // Filter to workspace A only
1177            let ws_root = dir.path().join("packages").join("a");
1178            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1179            assert_eq!(entries.len(), 1);
1180            assert_eq!(entries[0].path, index_a);
1181        }
1182    }
1183}