Skip to main content

fallow_core/discover/
entry_points.rs

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