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