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 fallow_types::path_util::is_absolute_path_any_platform;
8use rustc_hash::{FxHashMap, FxHashSet};
9
10/// Known output directory names from exports maps.
11/// When an entry point path is inside one of these directories, we also try
12/// the `src/` equivalent to find the tracked source file.
13const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
14const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
15
16fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
17    if skipped_entries.is_empty() {
18        return None;
19    }
20
21    let mut entries = skipped_entries
22        .iter()
23        .map(|(path, count)| (path.as_str(), *count))
24        .collect::<Vec<_>>();
25    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
26
27    let preview = entries
28        .iter()
29        .take(SKIPPED_ENTRY_WARNING_PREVIEW)
30        .map(|(path, count)| {
31            if *count > 1 {
32                format!("{path} ({count}x)")
33            } else {
34                (*path).to_owned()
35            }
36        })
37        .collect::<Vec<_>>();
38
39    let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
40    let tail = if omitted > 0 {
41        format!(" (and {omitted} more)")
42    } else {
43        String::new()
44    };
45    let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
46    let noun = if total == 1 {
47        "package.json entry point"
48    } else {
49        "package.json entry points"
50    };
51
52    Some(format!(
53        "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
54        preview.join(", ")
55    ))
56}
57
58pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
59    let Some(message) = format_skipped_entry_warning(skipped_entries) else {
60        return;
61    };
62    if should_warn_skipped_entry(&message) {
63        tracing::warn!("{message}");
64    }
65}
66
67/// Process-wide dedupe for [`warn_skipped_entry_summary`]. Returns `true` when
68/// `message` was newly inserted (caller should emit). On a poisoned mutex
69/// returns `true` so over-warning beats swallowing.
70fn should_warn_skipped_entry(message: &str) -> bool {
71    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
72        std::sync::OnceLock::new();
73    let warned = WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()));
74    warned
75        .lock()
76        .map_or(true, |mut set| set.insert(message.to_owned()))
77}
78
79/// Entry points grouped by reachability role.
80#[derive(Debug, Clone, Default)]
81pub struct CategorizedEntryPoints {
82    pub(crate) all: Vec<EntryPoint>,
83    pub(crate) runtime: Vec<EntryPoint>,
84    pub(crate) test: Vec<EntryPoint>,
85}
86
87impl CategorizedEntryPoints {
88    fn push_runtime(&mut self, entry: EntryPoint) {
89        self.runtime.push(entry.clone());
90        self.all.push(entry);
91    }
92
93    fn push_test(&mut self, entry: EntryPoint) {
94        self.test.push(entry.clone());
95        self.all.push(entry);
96    }
97
98    fn push_support(&mut self, entry: EntryPoint) {
99        self.all.push(entry);
100    }
101
102    pub(crate) fn extend_runtime<I>(&mut self, entries: I)
103    where
104        I: IntoIterator<Item = EntryPoint>,
105    {
106        for entry in entries {
107            self.push_runtime(entry);
108        }
109    }
110
111    pub fn extend_test<I>(&mut self, entries: I)
112    where
113        I: IntoIterator<Item = EntryPoint>,
114    {
115        for entry in entries {
116            self.push_test(entry);
117        }
118    }
119
120    pub fn extend_support<I>(&mut self, entries: I)
121    where
122        I: IntoIterator<Item = EntryPoint>,
123    {
124        for entry in entries {
125            self.push_support(entry);
126        }
127    }
128
129    pub(crate) fn extend(&mut self, other: Self) {
130        self.all.extend(other.all);
131        self.runtime.extend(other.runtime);
132        self.test.extend(other.test);
133    }
134
135    #[must_use]
136    pub(crate) fn dedup(mut self) -> Self {
137        dedup_entry_paths(&mut self.all);
138        dedup_entry_paths(&mut self.runtime);
139        dedup_entry_paths(&mut self.test);
140        self
141    }
142}
143
144fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
145    entries.sort_by(|a, b| a.path.cmp(&b.path));
146    entries.dedup_by(|a, b| a.path == b.path);
147}
148
149#[derive(Debug, Default)]
150pub struct EntryPointDiscovery {
151    pub entries: Vec<EntryPoint>,
152    pub skipped_entries: FxHashMap<String, usize>,
153}
154
155/// Resolve a path relative to a base directory, with security check and extension fallback.
156///
157/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
158/// trying source extensions as fallback when the exact path doesn't exist.
159/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
160/// by trying to map back to the source file (e.g., `./src/utils.ts`).
161fn resolve_entry_path_with_tracking(
162    base: &Path,
163    entry: &str,
164    canonical_root: &Path,
165    source: EntryPointSource,
166    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
167) -> Option<EntryPoint> {
168    if entry.contains('*') {
169        return None;
170    }
171
172    if entry_has_parent_dir(entry) {
173        record_or_warn_skipped_entry(
174            skipped_entries.as_deref_mut(),
175            entry,
176            "Skipping entry point containing parent directory traversal",
177        );
178        return None;
179    }
180
181    if let OutputDirEntry::ShortCircuit(result) = resolve_entry_via_output_dir(
182        base,
183        entry,
184        canonical_root,
185        source.clone(),
186        skipped_entries.as_deref_mut(),
187    ) {
188        return result;
189    }
190
191    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source, skipped_entries)
192}
193
194/// Record a skipped entry in the dedup map, or warn when no map is tracking skips.
195fn record_or_warn_skipped_entry(
196    skipped_entries: Option<&mut FxHashMap<String, usize>>,
197    entry: &str,
198    warning: &str,
199) {
200    if let Some(skipped_entries) = skipped_entries {
201        *skipped_entries.entry(entry.to_owned()).or_default() += 1;
202    } else {
203        tracing::warn!(path = %entry, "{warning}");
204    }
205}
206
207/// Outcome of the output-directory mapping step.
208///
209/// `ShortCircuit` means an output-dir branch applied and carries the resolved
210/// entry (which may itself be `None` when validation rejected the candidate);
211/// `Continue` signals that filesystem probing should proceed.
212enum OutputDirEntry {
213    ShortCircuit(Option<EntryPoint>),
214    Continue,
215}
216
217/// Map an output-directory entry back to a source file, short-circuiting resolution.
218fn resolve_entry_via_output_dir(
219    base: &Path,
220    entry: &str,
221    canonical_root: &Path,
222    source: EntryPointSource,
223    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
224) -> OutputDirEntry {
225    if let Some(source_path) = try_output_to_source_path(base, entry) {
226        return OutputDirEntry::ShortCircuit(validated_entry_point(
227            &source_path,
228            canonical_root,
229            entry,
230            source,
231            skipped_entries.as_deref_mut(),
232        ));
233    }
234
235    if is_entry_in_output_dir(entry)
236        && let Some(source_path) = try_source_index_fallback(base)
237    {
238        tracing::info!(
239            entry = %entry,
240            fallback = %source_path.display(),
241            "package.json entry resolves to an ignored output directory; falling back to source index"
242        );
243        return OutputDirEntry::ShortCircuit(validated_entry_point(
244            &source_path,
245            canonical_root,
246            entry,
247            source,
248            skipped_entries,
249        ));
250    }
251
252    OutputDirEntry::Continue
253}
254
255/// Probe the filesystem for the entry: exact file, extension fallback, directory
256/// index, then a package-root source-index fallback.
257fn resolve_entry_via_filesystem_probe(
258    base: &Path,
259    entry: &str,
260    canonical_root: &Path,
261    source: EntryPointSource,
262    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
263) -> Option<EntryPoint> {
264    let resolved = base.join(entry);
265
266    if resolved.is_file() {
267        return validated_entry_point(
268            &resolved,
269            canonical_root,
270            entry,
271            source,
272            skipped_entries.as_deref_mut(),
273        );
274    }
275
276    for ext in SOURCE_EXTENSIONS {
277        let with_ext = resolved.with_extension(ext);
278        if with_ext.is_file() {
279            return validated_entry_point(
280                &with_ext,
281                canonical_root,
282                entry,
283                source,
284                skipped_entries.as_deref_mut(),
285            );
286        }
287    }
288
289    if let Some(index_entry) = try_directory_index_entry(&resolved) {
290        return validated_entry_point(
291            &index_entry,
292            canonical_root,
293            entry,
294            source,
295            skipped_entries.as_deref_mut(),
296        );
297    }
298
299    if is_package_root_index_entry(entry)
300        && let Some(source_path) = try_source_index_fallback(base)
301    {
302        tracing::info!(
303            entry = %entry,
304            fallback = %source_path.display(),
305            "package.json root index entry is missing; falling back to source index"
306        );
307        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
308    }
309    None
310}
311
312fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
313    for ext in SOURCE_EXTENSIONS {
314        let candidate = resolved.join(format!("index.{ext}"));
315        if candidate.is_file() {
316            return Some(candidate);
317        }
318    }
319    None
320}
321
322fn entry_has_parent_dir(entry: &str) -> bool {
323    Path::new(entry)
324        .components()
325        .any(|component| matches!(component, Component::ParentDir))
326}
327
328fn is_package_root_index_entry(entry: &str) -> bool {
329    let mut components = Path::new(entry)
330        .components()
331        .filter(|component| !matches!(component, Component::CurDir));
332
333    let Some(Component::Normal(file_name)) = components.next() else {
334        return false;
335    };
336    if components.next().is_some() {
337        return false;
338    }
339
340    file_name
341        .to_str()
342        .is_some_and(|name| name == "index" || name.starts_with("index."))
343}
344
345fn validated_entry_point(
346    candidate: &Path,
347    canonical_root: &Path,
348    entry: &str,
349    source: EntryPointSource,
350    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
351) -> Option<EntryPoint> {
352    let canonical_candidate = match dunce::canonicalize(candidate) {
353        Ok(path) => path,
354        Err(err) => {
355            tracing::warn!(
356                path = %candidate.display(),
357                %entry,
358                error = %err,
359                "Skipping entry point that could not be canonicalized"
360            );
361            return None;
362        }
363    };
364
365    if !canonical_candidate.starts_with(canonical_root) {
366        if let Some(skipped_entries) = skipped_entries.as_mut() {
367            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
368        } else {
369            tracing::warn!(
370                path = %candidate.display(),
371                %entry,
372                "Skipping entry point outside project root"
373            );
374        }
375        return None;
376    }
377
378    Some(EntryPoint {
379        path: candidate.to_path_buf(),
380        source,
381    })
382}
383
384pub fn resolve_entry_path(
385    base: &Path,
386    entry: &str,
387    canonical_root: &Path,
388    source: EntryPointSource,
389) -> Option<EntryPoint> {
390    resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
391}
392/// Try to map an entry path from an output directory to its source equivalent.
393///
394/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
395/// - `/project/packages/ui/src/utils.ts`
396/// - `/project/packages/ui/src/utils.tsx`
397/// - etc. for all source extensions
398///
399/// Preserves any path prefix between the package root and the output dir,
400/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
401///
402/// Returns `Some(path)` if a source file is found.
403fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
404    let entry_path = Path::new(entry);
405    let components: Vec<_> = entry_path.components().collect();
406
407    let output_pos = components.iter().rposition(|c| {
408        if let std::path::Component::Normal(s) = c
409            && let Some(name) = s.to_str()
410        {
411            return OUTPUT_DIRS.contains(&name);
412        }
413        false
414    })?;
415
416    let prefix: PathBuf = components[..output_pos]
417        .iter()
418        .filter(|c| !matches!(c, std::path::Component::CurDir))
419        .collect();
420
421    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
422
423    for ext in SOURCE_EXTENSIONS {
424        let source_candidate = base
425            .join(&prefix)
426            .join("src")
427            .join(suffix.with_extension(ext));
428        if source_candidate.exists() {
429            return Some(source_candidate);
430        }
431    }
432
433    None
434}
435
436/// Conventional source index file stems probed when a package.json entry lives
437/// in an ignored output directory. Ordered by preference.
438const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
439
440/// Return `true` when `entry` contains a known output directory component.
441///
442/// Matches any segment in `OUTPUT_DIRS`, e.g. `./dist/esm2022/index.js` →
443/// `true`, `src/main.ts` → `false`.
444fn is_entry_in_output_dir(entry: &str) -> bool {
445    Path::new(entry).components().any(|c| {
446        matches!(
447            c,
448            std::path::Component::Normal(s)
449                if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
450        )
451    })
452}
453
454/// Probe a package root for a conventional source index file.
455///
456/// Used when `package.json` points at compiled output but the canonical source
457/// entry is a standard TypeScript/JavaScript index file. Tries `src/index`,
458/// `src/main`, `index`, and `main` with each supported source extension, in
459/// that order.
460fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
461    for stem in SOURCE_INDEX_FALLBACK_STEMS {
462        for ext in SOURCE_EXTENSIONS {
463            let candidate = base.join(format!("{stem}.{ext}"));
464            if candidate.is_file() {
465                return Some(candidate);
466            }
467        }
468    }
469    None
470}
471
472/// Default index patterns used when no other entry points are found.
473const DEFAULT_INDEX_PATTERNS: &[&str] = &[
474    "src/index.{ts,tsx,js,jsx}",
475    "src/main.{ts,tsx,js,jsx}",
476    "index.{ts,tsx,js,jsx}",
477    "main.{ts,tsx,js,jsx}",
478];
479
480/// Fall back to default index patterns if no entries were found.
481///
482/// When `ws_filter` is `Some`, only files whose path starts with the given
483/// workspace root are considered (used for workspace-scoped discovery).
484fn apply_default_fallback(
485    files: &[DiscoveredFile],
486    root: &Path,
487    ws_filter: Option<&Path>,
488) -> Vec<EntryPoint> {
489    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
490        .iter()
491        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
492        .collect();
493
494    let mut entries = Vec::new();
495    for file in files {
496        if let Some(ws_root) = ws_filter
497            && file.path.strip_prefix(ws_root).is_err()
498        {
499            continue;
500        }
501        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
502        let relative_str = relative.to_string_lossy();
503        if default_matchers
504            .iter()
505            .any(|m| m.is_match(relative_str.as_ref()))
506        {
507            entries.push(EntryPoint {
508                path: file.path.clone(),
509                source: EntryPointSource::DefaultIndex,
510            });
511        }
512    }
513    entries
514}
515
516/// Compute each file's path relative to `root` as a forward-slash-lossy string.
517fn relative_paths_for(files: &[DiscoveredFile], root: &Path) -> Vec<String> {
518    files
519        .iter()
520        .map(|f| {
521            f.path
522                .strip_prefix(root)
523                .unwrap_or(&f.path)
524                .to_string_lossy()
525                .into_owned()
526        })
527        .collect()
528}
529
530/// Push entries for files matching the user-configured manual entry glob patterns.
531#[expect(
532    clippy::expect_used,
533    reason = "entry glob patterns are validated before entry point discovery"
534)]
535fn push_manual_entry_matches(
536    entries: &mut Vec<EntryPoint>,
537    config: &ResolvedConfig,
538    relative_paths: &[String],
539    files: &[DiscoveredFile],
540) {
541    let mut builder = globset::GlobSetBuilder::new();
542    for pattern in &config.entry_patterns {
543        builder.add(
544            globset::Glob::new(pattern).expect("entry pattern was validated at config load time"),
545        );
546    }
547    let Ok(glob_set) = builder.build() else {
548        return;
549    };
550    if glob_set.is_empty() {
551        return;
552    }
553    for (idx, rel) in relative_paths.iter().enumerate() {
554        if glob_set.is_match(rel) {
555            entries.push(EntryPoint {
556                path: files[idx].path.clone(),
557                source: EntryPointSource::ManualEntry,
558            });
559        }
560    }
561}
562
563/// Push entries derived from a package.json's declared entry points and scripts.
564fn push_package_json_entries(
565    discovery: &mut EntryPointDiscovery,
566    root: &Path,
567    pkg: &PackageJson,
568    canonical_root: &Path,
569) {
570    for entry_path in pkg.entry_points() {
571        if let Some(ep) = resolve_entry_path_with_tracking(
572            root,
573            &entry_path,
574            canonical_root,
575            EntryPointSource::PackageJsonMain,
576            Some(&mut discovery.skipped_entries),
577        ) {
578            discovery.entries.push(ep);
579        }
580    }
581
582    let Some(scripts) = &pkg.scripts else {
583        return;
584    };
585    for script_value in scripts.values() {
586        for file_ref in extract_script_file_refs(script_value) {
587            if let Some(ep) = resolve_entry_path_with_tracking(
588                root,
589                &file_ref,
590                canonical_root,
591                EntryPointSource::PackageJsonScript,
592                Some(&mut discovery.skipped_entries),
593            ) {
594                discovery.entries.push(ep);
595            }
596        }
597    }
598}
599
600/// Discover entry points from package.json, framework rules, and defaults.
601fn discover_entry_points_with_warnings_impl(
602    config: &ResolvedConfig,
603    files: &[DiscoveredFile],
604    root_pkg: Option<&PackageJson>,
605    include_nested_package_entries: bool,
606) -> EntryPointDiscovery {
607    let _span = tracing::info_span!("discover_entry_points").entered();
608    let mut discovery = EntryPointDiscovery::default();
609
610    let relative_paths = relative_paths_for(files, &config.root);
611    push_manual_entry_matches(&mut discovery.entries, config, &relative_paths, files);
612
613    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
614    if let Some(pkg) = root_pkg {
615        push_package_json_entries(&mut discovery, &config.root, pkg, &canonical_root);
616    }
617
618    if include_nested_package_entries {
619        let exports_dirs = root_pkg
620            .map(PackageJson::exports_subdirectories)
621            .unwrap_or_default();
622        discover_nested_package_entries(
623            &config.root,
624            files,
625            &mut discovery.entries,
626            &canonical_root,
627            &exports_dirs,
628            &mut discovery.skipped_entries,
629        );
630    }
631
632    if discovery.entries.is_empty() {
633        discovery.entries = apply_default_fallback(files, &config.root, None);
634    }
635
636    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
637    discovery.entries.dedup_by(|a, b| a.path == b.path);
638
639    discovery
640}
641
642pub fn discover_entry_points_with_warnings_from_pkg(
643    config: &ResolvedConfig,
644    files: &[DiscoveredFile],
645    root_pkg: Option<&PackageJson>,
646    include_nested_package_entries: bool,
647) -> EntryPointDiscovery {
648    discover_entry_points_with_warnings_impl(
649        config,
650        files,
651        root_pkg,
652        include_nested_package_entries,
653    )
654}
655
656pub fn discover_entry_points_with_warnings(
657    config: &ResolvedConfig,
658    files: &[DiscoveredFile],
659) -> EntryPointDiscovery {
660    let pkg_path = config.root.join("package.json");
661    let root_pkg = PackageJson::load(&pkg_path).ok();
662    discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
663}
664
665pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
666    let discovery = discover_entry_points_with_warnings(config, files);
667    warn_skipped_entry_summary(&discovery.skipped_entries);
668    discovery.entries
669}
670
671/// Discover entry points from nested package.json files in subdirectories.
672///
673/// Scans two sources for sub-packages:
674/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
675/// 2. Directories derived from the root package.json `exports` map keys
676///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
677///
678/// For each discovered sub-package with a `package.json`, the `main`, `module`,
679/// `source`, `exports`, and `bin` fields are treated as entry points.
680fn discover_nested_package_entries(
681    root: &Path,
682    _files: &[DiscoveredFile],
683    entries: &mut Vec<EntryPoint>,
684    canonical_root: &Path,
685    exports_subdirectories: &[String],
686    skipped_entries: &mut FxHashMap<String, usize>,
687) {
688    let mut visited = rustc_hash::FxHashSet::default();
689
690    let search_dirs = [
691        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
692    ];
693    for dir_name in &search_dirs {
694        let search_dir = root.join(dir_name);
695        if !search_dir.is_dir() {
696            continue;
697        }
698        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
699            continue;
700        };
701        for entry in read_dir.flatten() {
702            let pkg_dir = entry.path();
703            if visited.insert(pkg_dir.clone()) {
704                collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
705            }
706        }
707    }
708
709    for dir_name in exports_subdirectories {
710        let pkg_dir = root.join(dir_name);
711        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
712            collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
713        }
714    }
715}
716
717/// Collect entry points from a single sub-package directory.
718fn collect_nested_package_entries(
719    pkg_dir: &Path,
720    entries: &mut Vec<EntryPoint>,
721    canonical_root: &Path,
722    skipped_entries: &mut FxHashMap<String, usize>,
723) {
724    let pkg_path = pkg_dir.join("package.json");
725    if !pkg_path.exists() {
726        return;
727    }
728    let Ok(pkg) = PackageJson::load(&pkg_path) else {
729        return;
730    };
731    for entry_path in pkg.entry_points() {
732        if entry_path.contains('*') {
733            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
734        } else if let Some(ep) = resolve_entry_path_with_tracking(
735            pkg_dir,
736            &entry_path,
737            canonical_root,
738            EntryPointSource::PackageJsonExports,
739            Some(&mut *skipped_entries),
740        ) {
741            entries.push(ep);
742        }
743    }
744    if let Some(scripts) = &pkg.scripts {
745        for script_value in scripts.values() {
746            for file_ref in extract_script_file_refs(script_value) {
747                if let Some(ep) = resolve_entry_path_with_tracking(
748                    pkg_dir,
749                    &file_ref,
750                    canonical_root,
751                    EntryPointSource::PackageJsonScript,
752                    Some(&mut *skipped_entries),
753                ) {
754                    entries.push(ep);
755                }
756            }
757        }
758    }
759}
760
761/// Expand wildcard subpath exports to matching files on disk.
762///
763/// Handles patterns like `./src/themes/*.css` from package.json exports maps
764/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
765/// match actual files in the target directory.
766fn expand_wildcard_entries(
767    base: &Path,
768    pattern: &str,
769    canonical_root: &Path,
770    entries: &mut Vec<EntryPoint>,
771) {
772    let full_pattern = base.join(pattern).to_string_lossy().to_string();
773    let Ok(matches) = glob::glob(&full_pattern) else {
774        return;
775    };
776    for path_result in matches {
777        let Ok(path) = path_result else {
778            continue;
779        };
780        if let Ok(canonical) = dunce::canonicalize(&path)
781            && canonical.starts_with(canonical_root)
782        {
783            entries.push(EntryPoint {
784                path,
785                source: EntryPointSource::PackageJsonExports,
786            });
787        }
788    }
789}
790
791/// Discover entry points for a workspace package.
792#[must_use]
793fn discover_workspace_entry_points_with_warnings_impl(
794    ws_root: &Path,
795    all_files: &[DiscoveredFile],
796    pkg: Option<&PackageJson>,
797) -> EntryPointDiscovery {
798    let mut discovery = EntryPointDiscovery::default();
799
800    if let Some(pkg) = pkg {
801        let canonical_ws_root =
802            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
803        for entry_path in pkg.entry_points() {
804            if entry_path.contains('*') {
805                expand_wildcard_entries(
806                    ws_root,
807                    &entry_path,
808                    &canonical_ws_root,
809                    &mut discovery.entries,
810                );
811            } else if let Some(ep) = resolve_entry_path_with_tracking(
812                ws_root,
813                &entry_path,
814                &canonical_ws_root,
815                EntryPointSource::PackageJsonMain,
816                Some(&mut discovery.skipped_entries),
817            ) {
818                discovery.entries.push(ep);
819            }
820        }
821
822        if let Some(scripts) = &pkg.scripts {
823            for script_value in scripts.values() {
824                for file_ref in extract_script_file_refs(script_value) {
825                    if let Some(ep) = resolve_entry_path_with_tracking(
826                        ws_root,
827                        &file_ref,
828                        &canonical_ws_root,
829                        EntryPointSource::PackageJsonScript,
830                        Some(&mut discovery.skipped_entries),
831                    ) {
832                        discovery.entries.push(ep);
833                    }
834                }
835            }
836        }
837    }
838
839    if discovery.entries.is_empty() {
840        discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
841    }
842
843    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
844    discovery.entries.dedup_by(|a, b| a.path == b.path);
845    discovery
846}
847
848pub fn discover_workspace_entry_points_with_warnings_from_pkg(
849    ws_root: &Path,
850    all_files: &[DiscoveredFile],
851    pkg: Option<&PackageJson>,
852) -> EntryPointDiscovery {
853    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
854}
855
856#[must_use]
857pub fn discover_workspace_entry_points_with_warnings(
858    ws_root: &Path,
859    _config: &ResolvedConfig,
860    all_files: &[DiscoveredFile],
861) -> EntryPointDiscovery {
862    let pkg_path = ws_root.join("package.json");
863    let pkg = PackageJson::load(&pkg_path).ok();
864    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
865}
866
867#[must_use]
868pub fn discover_workspace_entry_points(
869    ws_root: &Path,
870    config: &ResolvedConfig,
871    all_files: &[DiscoveredFile],
872) -> Vec<EntryPoint> {
873    let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
874    warn_skipped_entry_summary(&discovery.skipped_entries);
875    discovery.entries
876}
877
878/// Discover entry points from plugin results (dynamic config parsing).
879///
880/// Converts plugin-discovered patterns and setup files into concrete entry points
881/// by matching them against the discovered file list.
882#[must_use]
883pub fn discover_plugin_entry_points(
884    plugin_result: &crate::plugins::AggregatedPluginResult,
885    config: &ResolvedConfig,
886    files: &[DiscoveredFile],
887) -> Vec<EntryPoint> {
888    discover_plugin_entry_point_sets(plugin_result, config, files).all
889}
890
891/// Discover plugin-derived entry points with runtime/test/support roles preserved.
892#[must_use]
893pub fn discover_plugin_entry_point_sets(
894    plugin_result: &crate::plugins::AggregatedPluginResult,
895    config: &ResolvedConfig,
896    files: &[DiscoveredFile],
897) -> CategorizedEntryPoints {
898    let mut entries = CategorizedEntryPoints::default();
899
900    let relative_paths = relative_paths_for(files, &config.root);
901    let (glob_set, glob_meta) = build_plugin_glob_meta(plugin_result);
902    if let Some(glob_set) = glob_set.filter(|set| !set.is_empty()) {
903        match_plugin_entry_files(&mut entries, &glob_set, &glob_meta, &relative_paths, files);
904    }
905
906    push_plugin_setup_files(&mut entries, &config.root, plugin_result);
907
908    entries.dedup()
909}
910
911/// Compile plugin entry-pattern and support globs into a glob set plus per-glob metadata.
912fn build_plugin_glob_meta(
913    plugin_result: &crate::plugins::AggregatedPluginResult,
914) -> (Option<globset::GlobSet>, Vec<CompiledEntryRule<'_>>) {
915    let mut builder = globset::GlobSetBuilder::new();
916    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
917    for (rule, pname) in &plugin_result.entry_patterns {
918        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
919            builder.add(include);
920            glob_meta.push(compiled);
921        }
922    }
923    for (pattern, pname) in plugin_result
924        .discovered_always_used
925        .iter()
926        .chain(plugin_result.always_used.iter())
927        .chain(plugin_result.fixture_patterns.iter())
928    {
929        if let Ok(glob) = globset::GlobBuilder::new(pattern)
930            .literal_separator(true)
931            .build()
932        {
933            builder.add(glob);
934            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
935                &crate::plugins::PathRule::new(pattern.clone()),
936                "support entry pattern",
937            ) {
938                glob_meta.push(CompiledEntryRule {
939                    path,
940                    plugin_name: pname,
941                    role: EntryPointRole::Support,
942                });
943            }
944        }
945    }
946    (builder.build().ok(), glob_meta)
947}
948
949/// Match each file against the compiled plugin glob set, categorizing hits by role.
950fn match_plugin_entry_files(
951    entries: &mut CategorizedEntryPoints,
952    glob_set: &globset::GlobSet,
953    glob_meta: &[CompiledEntryRule<'_>],
954    relative_paths: &[String],
955    files: &[DiscoveredFile],
956) {
957    for (idx, rel) in relative_paths.iter().enumerate() {
958        let matches: Vec<usize> = glob_set
959            .matches(rel)
960            .into_iter()
961            .filter(|match_idx| glob_meta[*match_idx].matches(rel))
962            .collect();
963        if matches.is_empty() {
964            continue;
965        }
966        let name = glob_meta[matches[0]].plugin_name;
967        let entry = EntryPoint {
968            path: files[idx].path.clone(),
969            source: EntryPointSource::Plugin {
970                name: name.to_string(),
971            },
972        };
973        categorize_plugin_match(entries, entry, glob_meta, &matches);
974    }
975}
976
977/// Push a matched entry into the runtime/test/support buckets implied by its roles.
978fn categorize_plugin_match(
979    entries: &mut CategorizedEntryPoints,
980    entry: EntryPoint,
981    glob_meta: &[CompiledEntryRule<'_>],
982    matches: &[usize],
983) {
984    let mut has_runtime = false;
985    let mut has_test = false;
986    let mut has_support = false;
987    for &match_idx in matches {
988        match glob_meta[match_idx].role {
989            EntryPointRole::Runtime => has_runtime = true,
990            EntryPointRole::Test => has_test = true,
991            EntryPointRole::Support => has_support = true,
992        }
993    }
994
995    if has_runtime {
996        entries.push_runtime(entry.clone());
997    }
998    if has_test {
999        entries.push_test(entry.clone());
1000    }
1001    if has_support || (!has_runtime && !has_test) {
1002        entries.push_support(entry);
1003    }
1004}
1005
1006/// Resolve plugin-declared setup files (with source-extension fallback) into support entries.
1007fn push_plugin_setup_files(
1008    entries: &mut CategorizedEntryPoints,
1009    root: &Path,
1010    plugin_result: &crate::plugins::AggregatedPluginResult,
1011) {
1012    for (setup_file, pname) in &plugin_result.setup_files {
1013        let resolved = resolve_plugin_setup_file(root, setup_file);
1014        if resolved.exists() {
1015            entries.push_support(EntryPoint {
1016                path: resolved,
1017                source: EntryPointSource::Plugin {
1018                    name: pname.clone(),
1019                },
1020            });
1021            continue;
1022        }
1023        for ext in SOURCE_EXTENSIONS {
1024            let with_ext = resolved.with_extension(ext);
1025            if with_ext.exists() {
1026                entries.push_support(EntryPoint {
1027                    path: with_ext,
1028                    source: EntryPointSource::Plugin {
1029                        name: pname.clone(),
1030                    },
1031                });
1032                break;
1033            }
1034        }
1035    }
1036}
1037
1038fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
1039    if is_absolute_path_any_platform(setup_file) {
1040        setup_file.to_path_buf()
1041    } else {
1042        root.join(setup_file)
1043    }
1044}
1045
1046/// Discover entry points from `dynamicallyLoaded` config patterns.
1047///
1048/// Matches the configured glob patterns against the discovered file list and
1049/// marks matching files as entry points so they are never flagged as unused.
1050#[expect(
1051    clippy::expect_used,
1052    reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
1053)]
1054#[must_use]
1055pub fn discover_dynamically_loaded_entry_points(
1056    config: &ResolvedConfig,
1057    files: &[DiscoveredFile],
1058) -> Vec<EntryPoint> {
1059    if config.dynamically_loaded.is_empty() {
1060        return Vec::new();
1061    }
1062
1063    let mut builder = globset::GlobSetBuilder::new();
1064    for pattern in &config.dynamically_loaded {
1065        builder.add(
1066            globset::Glob::new(pattern)
1067                .expect("dynamicallyLoaded pattern was validated at config load time"),
1068        );
1069    }
1070    let Ok(glob_set) = builder.build() else {
1071        return Vec::new();
1072    };
1073    if glob_set.is_empty() {
1074        return Vec::new();
1075    }
1076
1077    let mut entries = Vec::new();
1078    for file in files {
1079        let rel = file
1080            .path
1081            .strip_prefix(&config.root)
1082            .unwrap_or(&file.path)
1083            .to_string_lossy();
1084        if glob_set.is_match(rel.as_ref()) {
1085            entries.push(EntryPoint {
1086                path: file.path.clone(),
1087                source: EntryPointSource::DynamicallyLoaded,
1088            });
1089        }
1090    }
1091    entries
1092}
1093
1094struct CompiledEntryRule<'a> {
1095    path: crate::plugins::CompiledPathRule,
1096    plugin_name: &'a str,
1097    role: EntryPointRole,
1098}
1099
1100impl CompiledEntryRule<'_> {
1101    fn matches(&self, path: &str) -> bool {
1102        self.path.matches(path)
1103    }
1104}
1105
1106fn compile_entry_rule<'a>(
1107    rule: &'a crate::plugins::PathRule,
1108    plugin_name: &'a str,
1109    plugin_result: &'a crate::plugins::AggregatedPluginResult,
1110) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1111    let include = match globset::GlobBuilder::new(&rule.pattern)
1112        .literal_separator(true)
1113        .build()
1114    {
1115        Ok(glob) => glob,
1116        Err(err) => {
1117            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1118            return None;
1119        }
1120    };
1121    let role = plugin_result
1122        .entry_point_roles
1123        .get(plugin_name)
1124        .copied()
1125        .unwrap_or(EntryPointRole::Support);
1126    Some((
1127        include,
1128        CompiledEntryRule {
1129            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1130            plugin_name,
1131            role,
1132        },
1133    ))
1134}
1135
1136/// Pre-compile a set of glob patterns for efficient matching against many paths.
1137#[must_use]
1138pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1139    if patterns.is_empty() {
1140        return None;
1141    }
1142    let mut builder = globset::GlobSetBuilder::new();
1143    for pattern in patterns {
1144        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1145            .literal_separator(true)
1146            .build()
1147        {
1148            builder.add(glob);
1149        }
1150    }
1151    builder.build().ok()
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1158    use fallow_types::discover::FileId;
1159    use proptest::prelude::*;
1160
1161    proptest! {
1162        /// Valid glob patterns should never panic when compiled via globset.
1163        #[test]
1164        fn glob_patterns_never_panic_on_compile(
1165            prefix in "[a-zA-Z0-9_]{1,20}",
1166            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1167        ) {
1168            let pattern = format!("**/{prefix}*.{ext}");
1169            let result = globset::Glob::new(&pattern);
1170            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1171        }
1172
1173        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
1174        #[test]
1175        fn non_source_extensions_not_in_list(
1176            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1177        ) {
1178            prop_assert!(
1179                !SOURCE_EXTENSIONS.contains(&ext),
1180                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1181            );
1182        }
1183
1184        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
1185        #[test]
1186        fn compile_glob_set_no_panic(
1187            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1188        ) {
1189            let _ = compile_glob_set(&patterns);
1190        }
1191    }
1192
1193    #[test]
1194    fn compile_glob_set_empty_input() {
1195        assert!(
1196            compile_glob_set(&[]).is_none(),
1197            "empty patterns should return None"
1198        );
1199    }
1200
1201    #[test]
1202    fn compile_glob_set_valid_patterns() {
1203        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1204        let set = compile_glob_set(&patterns);
1205        assert!(set.is_some(), "valid patterns should compile");
1206        let set = set.unwrap();
1207        assert!(set.is_match("src/foo.ts"));
1208        assert!(set.is_match("src/bar.js"));
1209        assert!(!set.is_match("src/bar.py"));
1210    }
1211
1212    #[test]
1213    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1214        let patterns = vec!["composables/*.{ts,js}".to_string()];
1215        let set = compile_glob_set(&patterns).expect("pattern should compile");
1216
1217        assert!(set.is_match("composables/useFoo.ts"));
1218        assert!(!set.is_match("composables/nested/useFoo.ts"));
1219    }
1220
1221    #[test]
1222    #[expect(
1223        clippy::too_many_lines,
1224        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
1225    )]
1226    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1227        let dir = tempfile::tempdir().expect("create temp dir");
1228        let root = dir.path();
1229        std::fs::create_dir_all(root.join("src")).unwrap();
1230        std::fs::create_dir_all(root.join("tests")).unwrap();
1231        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1232        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1233        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1234
1235        let config = FallowConfig {
1236            type_aware: fallow_config::TypeAwareConfig::default(),
1237            schema: None,
1238            extends: vec![],
1239            entry: vec![],
1240            ignore_patterns: vec![],
1241            ignore_findings: vec![],
1242            framework: vec![],
1243            workspaces: None,
1244            ignore_dependencies: vec![],
1245            ignore_unresolved_imports: vec![],
1246            ignore_exports: vec![],
1247            ignore_catalog_references: vec![],
1248            ignore_dependency_overrides: vec![],
1249            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1250            used_class_members: vec![],
1251            ignore_decorators: vec![],
1252            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1253            duplicates: fallow_config::DuplicatesConfig::default(),
1254            health: fallow_config::HealthConfig::default(),
1255            rules: RulesConfig::default(),
1256            boundaries: fallow_config::BoundaryConfig::default(),
1257            production: false.into(),
1258            plugins: vec![],
1259            rule_packs: vec![],
1260            dynamically_loaded: vec![],
1261            overrides: vec![],
1262            regression: None,
1263            audit: fallow_config::AuditConfig::default(),
1264            codeowners: None,
1265            public_packages: vec![],
1266            flags: fallow_config::FlagsConfig::default(),
1267            security: fallow_config::SecurityConfig::default(),
1268            fix: fallow_config::FixConfig::default(),
1269            resolve: fallow_config::ResolveConfig::default(),
1270            sealed: false,
1271            include_entry_exports: false,
1272            auto_imports: false,
1273            cache: fallow_config::CacheConfig::default(),
1274        }
1275        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1276
1277        let files = vec![
1278            DiscoveredFile {
1279                id: FileId(0),
1280                path: root.join("src/runtime.ts"),
1281                size_bytes: 1,
1282            },
1283            DiscoveredFile {
1284                id: FileId(1),
1285                path: root.join("src/setup.ts"),
1286                size_bytes: 1,
1287            },
1288            DiscoveredFile {
1289                id: FileId(2),
1290                path: root.join("tests/app.test.ts"),
1291                size_bytes: 1,
1292            },
1293        ];
1294
1295        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1296        plugin_result.entry_patterns.push((
1297            crate::plugins::PathRule::new("src/runtime.ts"),
1298            "runtime-plugin".to_string(),
1299        ));
1300        plugin_result.entry_patterns.push((
1301            crate::plugins::PathRule::new("tests/app.test.ts"),
1302            "test-plugin".to_string(),
1303        ));
1304        plugin_result
1305            .always_used
1306            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1307        plugin_result
1308            .entry_point_roles
1309            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1310        plugin_result
1311            .entry_point_roles
1312            .insert("test-plugin".to_string(), EntryPointRole::Test);
1313        plugin_result
1314            .entry_point_roles
1315            .insert("support-plugin".to_string(), EntryPointRole::Support);
1316
1317        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1318
1319        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1320        assert!(
1321            entries.runtime[0].path.ends_with("src/runtime.ts"),
1322            "runtime entry should stay runtime-only"
1323        );
1324        assert_eq!(entries.test.len(), 1, "expected one test entry");
1325        assert!(
1326            entries.test[0].path.ends_with("tests/app.test.ts"),
1327            "test entry should stay test-only"
1328        );
1329        assert_eq!(
1330            entries.all.len(),
1331            3,
1332            "support entries should stay in all entries"
1333        );
1334        assert!(
1335            entries
1336                .all
1337                .iter()
1338                .any(|entry| entry.path.ends_with("src/setup.ts")),
1339            "support entries should remain in the overall entry-point set"
1340        );
1341        assert!(
1342            !entries
1343                .runtime
1344                .iter()
1345                .any(|entry| entry.path.ends_with("src/setup.ts")),
1346            "support entries should not bleed into runtime reachability"
1347        );
1348        assert!(
1349            !entries
1350                .test
1351                .iter()
1352                .any(|entry| entry.path.ends_with("src/setup.ts")),
1353            "support entries should not bleed into test reachability"
1354        );
1355    }
1356
1357    #[test]
1358    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1359        let root = Path::new("/workspace/project");
1360        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1361
1362        assert_eq!(
1363            resolve_plugin_setup_file(root, setup_file),
1364            setup_file.to_path_buf()
1365        );
1366    }
1367
1368    #[cfg(windows)]
1369    #[test]
1370    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1371        let root = Path::new(r"C:\workspace\project");
1372        let setup_file = Path::new(r"/workspace/project/setup.ts");
1373
1374        assert_eq!(
1375            resolve_plugin_setup_file(root, setup_file),
1376            setup_file.to_path_buf()
1377        );
1378    }
1379
1380    #[test]
1381    fn plugin_entry_point_rules_respect_exclusions() {
1382        let dir = tempfile::tempdir().expect("create temp dir");
1383        let root = dir.path();
1384        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1385        std::fs::write(
1386            root.join("app/pages/index.tsx"),
1387            "export default function Page() { return null; }",
1388        )
1389        .unwrap();
1390        std::fs::write(
1391            root.join("app/pages/-helper.ts"),
1392            "export const helper = 1;",
1393        )
1394        .unwrap();
1395
1396        let config = FallowConfig {
1397            type_aware: fallow_config::TypeAwareConfig::default(),
1398            schema: None,
1399            extends: vec![],
1400            entry: vec![],
1401            ignore_patterns: vec![],
1402            ignore_findings: vec![],
1403            framework: vec![],
1404            workspaces: None,
1405            ignore_dependencies: vec![],
1406            ignore_unresolved_imports: vec![],
1407            ignore_exports: vec![],
1408            ignore_catalog_references: vec![],
1409            ignore_dependency_overrides: vec![],
1410            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1411            used_class_members: vec![],
1412            ignore_decorators: vec![],
1413            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1414            duplicates: fallow_config::DuplicatesConfig::default(),
1415            health: fallow_config::HealthConfig::default(),
1416            rules: RulesConfig::default(),
1417            boundaries: fallow_config::BoundaryConfig::default(),
1418            production: false.into(),
1419            plugins: vec![],
1420            rule_packs: vec![],
1421            dynamically_loaded: vec![],
1422            overrides: vec![],
1423            regression: None,
1424            audit: fallow_config::AuditConfig::default(),
1425            codeowners: None,
1426            public_packages: vec![],
1427            flags: fallow_config::FlagsConfig::default(),
1428            security: fallow_config::SecurityConfig::default(),
1429            fix: fallow_config::FixConfig::default(),
1430            resolve: fallow_config::ResolveConfig::default(),
1431            sealed: false,
1432            include_entry_exports: false,
1433            auto_imports: false,
1434            cache: fallow_config::CacheConfig::default(),
1435        }
1436        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1437
1438        let files = vec![
1439            DiscoveredFile {
1440                id: FileId(0),
1441                path: root.join("app/pages/index.tsx"),
1442                size_bytes: 1,
1443            },
1444            DiscoveredFile {
1445                id: FileId(1),
1446                path: root.join("app/pages/-helper.ts"),
1447                size_bytes: 1,
1448            },
1449        ];
1450
1451        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1452        plugin_result.entry_patterns.push((
1453            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1454                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1455            "tanstack-router".to_string(),
1456        ));
1457        plugin_result
1458            .entry_point_roles
1459            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1460
1461        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1462        let entry_paths: Vec<_> = entries
1463            .all
1464            .iter()
1465            .map(|entry| {
1466                entry
1467                    .path
1468                    .strip_prefix(root)
1469                    .unwrap()
1470                    .to_string_lossy()
1471                    .into_owned()
1472            })
1473            .collect();
1474
1475        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1476        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1477    }
1478
1479    mod resolve_entry_path_tests {
1480        use super::*;
1481
1482        #[test]
1483        fn resolves_existing_file() {
1484            let dir = tempfile::tempdir().expect("create temp dir");
1485            let src = dir.path().join("src");
1486            std::fs::create_dir_all(&src).unwrap();
1487            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1488
1489            let canonical = dunce::canonicalize(dir.path()).unwrap();
1490            let result = resolve_entry_path(
1491                dir.path(),
1492                "src/index.ts",
1493                &canonical,
1494                EntryPointSource::PackageJsonMain,
1495            );
1496            assert!(result.is_some(), "should resolve an existing file");
1497            assert!(result.unwrap().path.ends_with("src/index.ts"));
1498        }
1499
1500        #[test]
1501        fn resolves_with_extension_fallback() {
1502            let dir = tempfile::tempdir().expect("create temp dir");
1503            let canonical = dunce::canonicalize(dir.path()).unwrap();
1504            let src = canonical.join("src");
1505            std::fs::create_dir_all(&src).unwrap();
1506            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1507
1508            let result = resolve_entry_path(
1509                &canonical,
1510                "src/index",
1511                &canonical,
1512                EntryPointSource::PackageJsonMain,
1513            );
1514            assert!(
1515                result.is_some(),
1516                "should resolve via extension fallback when exact path doesn't exist"
1517            );
1518            let ep = result.unwrap();
1519            assert!(
1520                ep.path.to_string_lossy().contains("index.ts"),
1521                "should find index.ts via extension fallback"
1522            );
1523        }
1524
1525        #[test]
1526        fn exact_file_wins_before_directory_index_fallback() {
1527            let dir = tempfile::tempdir().expect("create temp dir");
1528            let canonical = dunce::canonicalize(dir.path()).unwrap();
1529            let scripts = canonical.join("scripts");
1530            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1531            std::fs::write(
1532                scripts.join("process-messages.js"),
1533                "export const direct = true;",
1534            )
1535            .unwrap();
1536            std::fs::write(
1537                scripts.join("process-messages").join("index.js"),
1538                "export const index = true;",
1539            )
1540            .unwrap();
1541
1542            let result = resolve_entry_path(
1543                &canonical,
1544                "scripts/process-messages.js",
1545                &canonical,
1546                EntryPointSource::PackageJsonScript,
1547            )
1548            .expect("exact file should resolve");
1549
1550            assert!(result.path.ends_with("scripts/process-messages.js"));
1551        }
1552
1553        #[test]
1554        fn extension_fallback_wins_before_directory_index_fallback() {
1555            let dir = tempfile::tempdir().expect("create temp dir");
1556            let canonical = dunce::canonicalize(dir.path()).unwrap();
1557            let scripts = canonical.join("scripts");
1558            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1559            std::fs::write(
1560                scripts.join("process-messages.ts"),
1561                "export const withExt = true;",
1562            )
1563            .unwrap();
1564            std::fs::write(
1565                scripts.join("process-messages").join("index.js"),
1566                "export const index = true;",
1567            )
1568            .unwrap();
1569
1570            let result = resolve_entry_path(
1571                &canonical,
1572                "scripts/process-messages",
1573                &canonical,
1574                EntryPointSource::PackageJsonScript,
1575            )
1576            .expect("extension fallback should resolve");
1577
1578            assert!(result.path.ends_with("scripts/process-messages.ts"));
1579        }
1580
1581        #[test]
1582        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1583            let dir = tempfile::tempdir().expect("create temp dir");
1584            let canonical = dunce::canonicalize(dir.path()).unwrap();
1585            let scripts = canonical.join("scripts/process-messages");
1586            std::fs::create_dir_all(&scripts).unwrap();
1587            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1588
1589            let result = resolve_entry_path(
1590                &canonical,
1591                "scripts/process-messages",
1592                &canonical,
1593                EntryPointSource::PackageJsonScript,
1594            )
1595            .expect("directory index should resolve");
1596
1597            assert!(result.path.ends_with("scripts/process-messages/index.js"));
1598        }
1599
1600        #[test]
1601        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1602            let dir = tempfile::tempdir().expect("create temp dir");
1603            let canonical = dunce::canonicalize(dir.path()).unwrap();
1604            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1605            std::fs::write(
1606                canonical.join("scripts/process-messages/index.js"),
1607                "export const index = true;",
1608            )
1609            .unwrap();
1610
1611            for entry in [
1612                "scripts/*",
1613                "https://example.com/scripts/process-messages",
1614                "@scope/package/scripts/process-messages",
1615            ] {
1616                let result = resolve_entry_path(
1617                    &canonical,
1618                    entry,
1619                    &canonical,
1620                    EntryPointSource::PackageJsonScript,
1621                );
1622                assert!(result.is_none(), "{entry} should not resolve");
1623            }
1624        }
1625
1626        #[test]
1627        fn returns_none_for_nonexistent_file() {
1628            let dir = tempfile::tempdir().expect("create temp dir");
1629            let canonical = dunce::canonicalize(dir.path()).unwrap();
1630            let result = resolve_entry_path(
1631                dir.path(),
1632                "does/not/exist.ts",
1633                &canonical,
1634                EntryPointSource::PackageJsonMain,
1635            );
1636            assert!(result.is_none(), "should return None for nonexistent files");
1637        }
1638
1639        #[test]
1640        fn maps_dist_output_to_src() {
1641            let dir = tempfile::tempdir().expect("create temp dir");
1642            let src = dir.path().join("src");
1643            std::fs::create_dir_all(&src).unwrap();
1644            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1645
1646            let dist = dir.path().join("dist");
1647            std::fs::create_dir_all(&dist).unwrap();
1648            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1649
1650            let canonical = dunce::canonicalize(dir.path()).unwrap();
1651            let result = resolve_entry_path(
1652                dir.path(),
1653                "./dist/utils.js",
1654                &canonical,
1655                EntryPointSource::PackageJsonExports,
1656            );
1657            assert!(result.is_some(), "should resolve dist/ path to src/");
1658            let ep = result.unwrap();
1659            assert!(
1660                ep.path
1661                    .to_string_lossy()
1662                    .replace('\\', "/")
1663                    .contains("src/utils.ts"),
1664                "should map ./dist/utils.js to src/utils.ts"
1665            );
1666        }
1667
1668        #[test]
1669        fn maps_build_output_to_src() {
1670            let dir = tempfile::tempdir().expect("create temp dir");
1671            let canonical = dunce::canonicalize(dir.path()).unwrap();
1672            let src = canonical.join("src");
1673            std::fs::create_dir_all(&src).unwrap();
1674            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1675
1676            let result = resolve_entry_path(
1677                &canonical,
1678                "./build/index.js",
1679                &canonical,
1680                EntryPointSource::PackageJsonExports,
1681            );
1682            assert!(result.is_some(), "should map build/ output to src/");
1683            let ep = result.unwrap();
1684            assert!(
1685                ep.path
1686                    .to_string_lossy()
1687                    .replace('\\', "/")
1688                    .contains("src/index.tsx"),
1689                "should map ./build/index.js to src/index.tsx"
1690            );
1691        }
1692
1693        #[test]
1694        fn preserves_entry_point_source() {
1695            let dir = tempfile::tempdir().expect("create temp dir");
1696            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1697
1698            let canonical = dunce::canonicalize(dir.path()).unwrap();
1699            let result = resolve_entry_path(
1700                dir.path(),
1701                "index.ts",
1702                &canonical,
1703                EntryPointSource::PackageJsonScript,
1704            );
1705            assert!(result.is_some());
1706            assert!(
1707                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1708                "should preserve the source kind"
1709            );
1710        }
1711        #[test]
1712        fn tracks_skipped_entries_without_logging_each_repeat() {
1713            let dir = tempfile::tempdir().expect("create temp dir");
1714            let canonical = dunce::canonicalize(dir.path()).unwrap();
1715            let mut skipped_entries = FxHashMap::default();
1716
1717            let result = resolve_entry_path_with_tracking(
1718                dir.path(),
1719                "../scripts/build.js",
1720                &canonical,
1721                EntryPointSource::PackageJsonScript,
1722                Some(&mut skipped_entries),
1723            );
1724
1725            assert!(result.is_none(), "unsafe entry should be skipped");
1726            assert_eq!(
1727                skipped_entries.get("../scripts/build.js"),
1728                Some(&1),
1729                "warning tracker should count the skipped path"
1730            );
1731        }
1732
1733        #[test]
1734        fn formats_skipped_entry_warning_with_counts() {
1735            let mut skipped_entries = FxHashMap::default();
1736            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1737            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1738
1739            let warning =
1740                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1741
1742            assert_eq!(
1743                warning,
1744                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1745            );
1746        }
1747
1748        #[test]
1749        fn skipped_entry_summary_dedupes_identical_messages() {
1750            let message = format!(
1751                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
1752                std::process::id()
1753            );
1754            assert!(
1755                should_warn_skipped_entry(&message),
1756                "first occurrence of a message emits"
1757            );
1758            assert!(
1759                !should_warn_skipped_entry(&message),
1760                "identical repeat is suppressed"
1761            );
1762        }
1763
1764        #[test]
1765        fn rejects_parent_dir_escape_for_exact_file() {
1766            let sandbox = tempfile::tempdir().expect("create sandbox");
1767            let root = sandbox.path().join("project");
1768            std::fs::create_dir_all(&root).unwrap();
1769            std::fs::write(
1770                sandbox.path().join("escape.ts"),
1771                "export const escape = true;",
1772            )
1773            .unwrap();
1774
1775            let canonical = dunce::canonicalize(&root).unwrap();
1776            let result = resolve_entry_path(
1777                &root,
1778                "../escape.ts",
1779                &canonical,
1780                EntryPointSource::PackageJsonMain,
1781            );
1782
1783            assert!(
1784                result.is_none(),
1785                "should reject exact paths that escape the root"
1786            );
1787        }
1788
1789        #[test]
1790        fn rejects_parent_dir_escape_via_extension_fallback() {
1791            let sandbox = tempfile::tempdir().expect("create sandbox");
1792            let root = sandbox.path().join("project");
1793            std::fs::create_dir_all(&root).unwrap();
1794            std::fs::write(
1795                sandbox.path().join("escape.ts"),
1796                "export const escape = true;",
1797            )
1798            .unwrap();
1799
1800            let canonical = dunce::canonicalize(&root).unwrap();
1801            let result = resolve_entry_path(
1802                &root,
1803                "../escape",
1804                &canonical,
1805                EntryPointSource::PackageJsonMain,
1806            );
1807
1808            assert!(
1809                result.is_none(),
1810                "should reject extension fallback paths that escape the root"
1811            );
1812        }
1813    }
1814
1815    mod output_to_source_tests {
1816        use super::*;
1817
1818        #[test]
1819        fn maps_dist_to_src_with_ts_extension() {
1820            let dir = tempfile::tempdir().expect("create temp dir");
1821            let src = dir.path().join("src");
1822            std::fs::create_dir_all(&src).unwrap();
1823            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1824
1825            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1826            assert!(result.is_some());
1827            assert!(
1828                result
1829                    .unwrap()
1830                    .to_string_lossy()
1831                    .replace('\\', "/")
1832                    .contains("src/utils.ts")
1833            );
1834        }
1835
1836        #[test]
1837        fn returns_none_when_no_source_file_exists() {
1838            let dir = tempfile::tempdir().expect("create temp dir");
1839            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1840            assert!(result.is_none());
1841        }
1842
1843        #[test]
1844        fn ignores_non_output_directories() {
1845            let dir = tempfile::tempdir().expect("create temp dir");
1846            let src = dir.path().join("src");
1847            std::fs::create_dir_all(&src).unwrap();
1848            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1849
1850            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1851            assert!(result.is_none());
1852        }
1853
1854        #[test]
1855        fn maps_nested_output_path_preserving_prefix() {
1856            let dir = tempfile::tempdir().expect("create temp dir");
1857            let modules_src = dir.path().join("modules").join("src");
1858            std::fs::create_dir_all(&modules_src).unwrap();
1859            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1860
1861            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1862            assert!(result.is_some());
1863            assert!(
1864                result
1865                    .unwrap()
1866                    .to_string_lossy()
1867                    .replace('\\', "/")
1868                    .contains("modules/src/helper.ts")
1869            );
1870        }
1871    }
1872
1873    mod source_index_fallback_tests {
1874        use super::*;
1875
1876        #[test]
1877        fn detects_dist_entry_in_output_dir() {
1878            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1879            assert!(is_entry_in_output_dir("dist/index.js"));
1880            assert!(is_entry_in_output_dir("./build/index.js"));
1881            assert!(is_entry_in_output_dir("./out/main.js"));
1882            assert!(is_entry_in_output_dir("./esm/index.js"));
1883            assert!(is_entry_in_output_dir("./cjs/index.js"));
1884        }
1885
1886        #[test]
1887        fn rejects_non_output_entry_paths() {
1888            assert!(!is_entry_in_output_dir("./src/index.ts"));
1889            assert!(!is_entry_in_output_dir("src/main.ts"));
1890            assert!(!is_entry_in_output_dir("./index.js"));
1891            assert!(!is_entry_in_output_dir(""));
1892        }
1893
1894        #[test]
1895        fn root_index_entries_are_recognized_for_source_fallback() {
1896            assert!(is_package_root_index_entry("./index.js"));
1897            assert!(is_package_root_index_entry("index.cjs"));
1898            assert!(is_package_root_index_entry("./index.d.ts"));
1899            assert!(!is_package_root_index_entry("./src/index.js"));
1900            assert!(!is_package_root_index_entry("./main.js"));
1901            assert!(!is_package_root_index_entry(""));
1902        }
1903
1904        #[test]
1905        fn rejects_substring_match_for_output_dir() {
1906            assert!(!is_entry_in_output_dir("./distro/index.js"));
1907            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1908        }
1909
1910        #[test]
1911        fn finds_src_index_ts() {
1912            let dir = tempfile::tempdir().expect("create temp dir");
1913            let src = dir.path().join("src");
1914            std::fs::create_dir_all(&src).unwrap();
1915            let index_path = src.join("index.ts");
1916            std::fs::write(&index_path, "export const a = 1;").unwrap();
1917
1918            let result = try_source_index_fallback(dir.path());
1919            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1920        }
1921
1922        #[test]
1923        fn finds_src_index_tsx_when_ts_missing() {
1924            let dir = tempfile::tempdir().expect("create temp dir");
1925            let src = dir.path().join("src");
1926            std::fs::create_dir_all(&src).unwrap();
1927            let index_path = src.join("index.tsx");
1928            std::fs::write(&index_path, "export default 1;").unwrap();
1929
1930            let result = try_source_index_fallback(dir.path());
1931            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1932        }
1933
1934        #[test]
1935        fn prefers_src_index_over_root_index() {
1936            let dir = tempfile::tempdir().expect("create temp dir");
1937            let src = dir.path().join("src");
1938            std::fs::create_dir_all(&src).unwrap();
1939            let src_index = src.join("index.ts");
1940            std::fs::write(&src_index, "export const a = 1;").unwrap();
1941            let root_index = dir.path().join("index.ts");
1942            std::fs::write(&root_index, "export const b = 2;").unwrap();
1943
1944            let result = try_source_index_fallback(dir.path());
1945            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1946        }
1947
1948        #[test]
1949        fn falls_back_to_src_main() {
1950            let dir = tempfile::tempdir().expect("create temp dir");
1951            let src = dir.path().join("src");
1952            std::fs::create_dir_all(&src).unwrap();
1953            let main_path = src.join("main.ts");
1954            std::fs::write(&main_path, "export const a = 1;").unwrap();
1955
1956            let result = try_source_index_fallback(dir.path());
1957            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1958        }
1959
1960        #[test]
1961        fn falls_back_to_root_index_when_no_src() {
1962            let dir = tempfile::tempdir().expect("create temp dir");
1963            let index_path = dir.path().join("index.js");
1964            std::fs::write(&index_path, "module.exports = {};").unwrap();
1965
1966            let result = try_source_index_fallback(dir.path());
1967            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1968        }
1969
1970        #[test]
1971        fn returns_none_when_nothing_matches() {
1972            let dir = tempfile::tempdir().expect("create temp dir");
1973            let result = try_source_index_fallback(dir.path());
1974            assert!(result.is_none());
1975        }
1976
1977        #[test]
1978        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1979            let dir = tempfile::tempdir().expect("create temp dir");
1980            let canonical = dunce::canonicalize(dir.path()).unwrap();
1981
1982            let dist_dir = canonical.join("dist").join("esm2022");
1983            std::fs::create_dir_all(&dist_dir).unwrap();
1984            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1985
1986            let src = canonical.join("src");
1987            std::fs::create_dir_all(&src).unwrap();
1988            let src_index = src.join("index.ts");
1989            std::fs::write(&src_index, "export const x = 1;").unwrap();
1990
1991            let result = resolve_entry_path(
1992                &canonical,
1993                "./dist/esm2022/index.js",
1994                &canonical,
1995                EntryPointSource::PackageJsonMain,
1996            );
1997            assert!(result.is_some());
1998            let entry = result.unwrap();
1999            assert_eq!(entry.path, src_index);
2000        }
2001
2002        #[test]
2003        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2004            let dir = tempfile::tempdir().expect("create temp dir");
2005            let canonical = dunce::canonicalize(dir.path()).unwrap();
2006
2007            let src_mirror = canonical.join("src").join("esm2022");
2008            std::fs::create_dir_all(&src_mirror).unwrap();
2009            let mirror_index = src_mirror.join("index.ts");
2010            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2011
2012            let src_index = canonical.join("src").join("index.ts");
2013            std::fs::write(&src_index, "export const y = 2;").unwrap();
2014
2015            let result = resolve_entry_path(
2016                &canonical,
2017                "./dist/esm2022/index.js",
2018                &canonical,
2019                EntryPointSource::PackageJsonMain,
2020            );
2021            assert_eq!(result.map(|e| e.path), Some(mirror_index));
2022        }
2023
2024        #[test]
2025        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2026            let dir = tempfile::tempdir().expect("create temp dir");
2027            let canonical = dunce::canonicalize(dir.path()).unwrap();
2028
2029            let src = canonical.join("src");
2030            std::fs::create_dir_all(&src).unwrap();
2031            let src_index = src.join("index.ts");
2032            std::fs::write(&src_index, "export const x = 1;").unwrap();
2033
2034            let result = resolve_entry_path(
2035                &canonical,
2036                "./index.js",
2037                &canonical,
2038                EntryPointSource::PackageJsonMain,
2039            );
2040            assert_eq!(result.map(|entry| entry.path), Some(src_index));
2041        }
2042    }
2043
2044    mod default_fallback_tests {
2045        use super::*;
2046
2047        #[test]
2048        fn finds_src_index_ts_as_fallback() {
2049            let dir = tempfile::tempdir().expect("create temp dir");
2050            let src = dir.path().join("src");
2051            std::fs::create_dir_all(&src).unwrap();
2052            let index_path = src.join("index.ts");
2053            std::fs::write(&index_path, "export const a = 1;").unwrap();
2054
2055            let files = vec![DiscoveredFile {
2056                id: FileId(0),
2057                path: index_path.clone(),
2058                size_bytes: 20,
2059            }];
2060
2061            let entries = apply_default_fallback(&files, dir.path(), None);
2062            assert_eq!(entries.len(), 1);
2063            assert_eq!(entries[0].path, index_path);
2064            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2065        }
2066
2067        #[test]
2068        fn finds_root_index_js_as_fallback() {
2069            let dir = tempfile::tempdir().expect("create temp dir");
2070            let index_path = dir.path().join("index.js");
2071            std::fs::write(&index_path, "module.exports = {};").unwrap();
2072
2073            let files = vec![DiscoveredFile {
2074                id: FileId(0),
2075                path: index_path.clone(),
2076                size_bytes: 21,
2077            }];
2078
2079            let entries = apply_default_fallback(&files, dir.path(), None);
2080            assert_eq!(entries.len(), 1);
2081            assert_eq!(entries[0].path, index_path);
2082        }
2083
2084        #[test]
2085        fn returns_empty_when_no_index_file() {
2086            let dir = tempfile::tempdir().expect("create temp dir");
2087            let other_path = dir.path().join("src").join("utils.ts");
2088
2089            let files = vec![DiscoveredFile {
2090                id: FileId(0),
2091                path: other_path,
2092                size_bytes: 10,
2093            }];
2094
2095            let entries = apply_default_fallback(&files, dir.path(), None);
2096            assert!(
2097                entries.is_empty(),
2098                "non-index files should not match default fallback"
2099            );
2100        }
2101
2102        #[test]
2103        fn workspace_filter_restricts_scope() {
2104            let dir = tempfile::tempdir().expect("create temp dir");
2105            let ws_a = dir.path().join("packages").join("a").join("src");
2106            std::fs::create_dir_all(&ws_a).unwrap();
2107            let ws_b = dir.path().join("packages").join("b").join("src");
2108            std::fs::create_dir_all(&ws_b).unwrap();
2109
2110            let index_a = ws_a.join("index.ts");
2111            let index_b = ws_b.join("index.ts");
2112
2113            let files = vec![
2114                DiscoveredFile {
2115                    id: FileId(0),
2116                    path: index_a.clone(),
2117                    size_bytes: 10,
2118                },
2119                DiscoveredFile {
2120                    id: FileId(1),
2121                    path: index_b,
2122                    size_bytes: 10,
2123                },
2124            ];
2125
2126            let ws_root = dir.path().join("packages").join("a");
2127            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2128            assert_eq!(entries.len(), 1);
2129            assert_eq!(entries[0].path, index_a);
2130        }
2131    }
2132
2133    mod wildcard_entry_tests {
2134        use super::*;
2135
2136        #[test]
2137        fn expands_wildcard_css_entries() {
2138            let dir = tempfile::tempdir().expect("create temp dir");
2139            let themes = dir.path().join("src").join("themes");
2140            std::fs::create_dir_all(&themes).unwrap();
2141            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2142            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2143
2144            let canonical = dunce::canonicalize(dir.path()).unwrap();
2145            let mut entries = Vec::new();
2146            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2147
2148            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2149            let paths: Vec<String> = entries
2150                .iter()
2151                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2152                .collect();
2153            assert!(paths.contains(&"dark.css".to_string()));
2154            assert!(paths.contains(&"light.css".to_string()));
2155            assert!(
2156                entries
2157                    .iter()
2158                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2159            );
2160        }
2161
2162        #[test]
2163        fn wildcard_does_not_match_nonexistent_files() {
2164            let dir = tempfile::tempdir().expect("create temp dir");
2165            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2166
2167            let canonical = dunce::canonicalize(dir.path()).unwrap();
2168            let mut entries = Vec::new();
2169            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2170
2171            assert!(
2172                entries.is_empty(),
2173                "should return empty when no files match the wildcard"
2174            );
2175        }
2176
2177        #[test]
2178        fn wildcard_only_matches_specified_extension() {
2179            let dir = tempfile::tempdir().expect("create temp dir");
2180            let themes = dir.path().join("src").join("themes");
2181            std::fs::create_dir_all(&themes).unwrap();
2182            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2183            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2184
2185            let canonical = dunce::canonicalize(dir.path()).unwrap();
2186            let mut entries = Vec::new();
2187            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2188
2189            assert_eq!(entries.len(), 1, "should only match CSS files");
2190            assert!(
2191                entries[0]
2192                    .path
2193                    .file_name()
2194                    .unwrap()
2195                    .to_string_lossy()
2196                    .ends_with(".css")
2197            );
2198        }
2199    }
2200}