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