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