Skip to main content

fallow_core/discover/
entry_points.rs

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