Skip to main content

fallow_core/discover/
entry_points.rs

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