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            minimum_version: None,
1500            extends: vec![],
1501            entry: vec![],
1502            ignore_patterns: vec![],
1503            ignore_findings: vec![],
1504            framework: vec![],
1505            workspaces: None,
1506            ignore_dependencies: vec![],
1507            ignore_unresolved_imports: vec![],
1508            ignore_exports: vec![],
1509            ignore_catalog_references: vec![],
1510            ignore_dependency_overrides: vec![],
1511            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1512            used_class_members: vec![],
1513            ignore_decorators: vec![],
1514            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1515            duplicates: fallow_config::DuplicatesConfig::default(),
1516            similar_code: fallow_config::SimilarCodeConfig::default(),
1517            health: fallow_config::HealthConfig::default(),
1518            rules: RulesConfig::default(),
1519            boundaries: fallow_config::BoundaryConfig::default(),
1520            production: false.into(),
1521            plugins: vec![],
1522            rule_packs: vec![],
1523            dynamically_loaded: vec![],
1524            overrides: vec![],
1525            regression: None,
1526            audit: fallow_config::AuditConfig::default(),
1527            codeowners: None,
1528            public_packages: vec![],
1529            flags: fallow_config::FlagsConfig::default(),
1530            security: fallow_config::SecurityConfig::default(),
1531            fix: fallow_config::FixConfig::default(),
1532            resolve: fallow_config::ResolveConfig::default(),
1533            sealed: false,
1534            include_entry_exports: false,
1535            auto_imports: false,
1536            cache: fallow_config::CacheConfig::default(),
1537        }
1538        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1539
1540        let files = vec![
1541            DiscoveredFile {
1542                id: FileId(0),
1543                path: root.join("src/runtime.ts"),
1544                size_bytes: 1,
1545            },
1546            DiscoveredFile {
1547                id: FileId(1),
1548                path: root.join("src/setup.ts"),
1549                size_bytes: 1,
1550            },
1551            DiscoveredFile {
1552                id: FileId(2),
1553                path: root.join("tests/app.test.ts"),
1554                size_bytes: 1,
1555            },
1556        ];
1557
1558        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1559        plugin_result.entry_patterns.push((
1560            crate::plugins::PathRule::new("src/runtime.ts"),
1561            "runtime-plugin".to_string(),
1562        ));
1563        plugin_result.entry_patterns.push((
1564            crate::plugins::PathRule::new("tests/app.test.ts"),
1565            "test-plugin".to_string(),
1566        ));
1567        plugin_result
1568            .always_used
1569            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1570        plugin_result
1571            .entry_point_roles
1572            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1573        plugin_result
1574            .entry_point_roles
1575            .insert("test-plugin".to_string(), EntryPointRole::Test);
1576        plugin_result
1577            .entry_point_roles
1578            .insert("support-plugin".to_string(), EntryPointRole::Support);
1579
1580        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1581
1582        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1583        assert!(
1584            entries.runtime[0].path.ends_with("src/runtime.ts"),
1585            "runtime entry should stay runtime-only"
1586        );
1587        assert_eq!(entries.test.len(), 1, "expected one test entry");
1588        assert!(
1589            entries.test[0].path.ends_with("tests/app.test.ts"),
1590            "test entry should stay test-only"
1591        );
1592        assert_eq!(
1593            entries.all.len(),
1594            3,
1595            "support entries should stay in all entries"
1596        );
1597        assert!(
1598            entries
1599                .all
1600                .iter()
1601                .any(|entry| entry.path.ends_with("src/setup.ts")),
1602            "support entries should remain in the overall entry-point set"
1603        );
1604        assert!(
1605            !entries
1606                .runtime
1607                .iter()
1608                .any(|entry| entry.path.ends_with("src/setup.ts")),
1609            "support entries should not bleed into runtime reachability"
1610        );
1611        assert!(
1612            !entries
1613                .test
1614                .iter()
1615                .any(|entry| entry.path.ends_with("src/setup.ts")),
1616            "support entries should not bleed into test reachability"
1617        );
1618    }
1619
1620    #[test]
1621    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1622        let root = Path::new("/workspace/project");
1623        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1624
1625        assert_eq!(
1626            resolve_plugin_setup_file(root, setup_file),
1627            setup_file.to_path_buf()
1628        );
1629    }
1630
1631    #[cfg(windows)]
1632    #[test]
1633    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1634        let root = Path::new(r"C:\workspace\project");
1635        let setup_file = Path::new(r"/workspace/project/setup.ts");
1636
1637        assert_eq!(
1638            resolve_plugin_setup_file(root, setup_file),
1639            setup_file.to_path_buf()
1640        );
1641    }
1642
1643    #[test]
1644    fn plugin_entry_point_rules_respect_exclusions() {
1645        let dir = tempfile::tempdir().expect("create temp dir");
1646        let root = dir.path();
1647        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1648        std::fs::write(
1649            root.join("app/pages/index.tsx"),
1650            "export default function Page() { return null; }",
1651        )
1652        .unwrap();
1653        std::fs::write(
1654            root.join("app/pages/-helper.ts"),
1655            "export const helper = 1;",
1656        )
1657        .unwrap();
1658
1659        let config = FallowConfig {
1660            type_aware: fallow_config::TypeAwareConfig::default(),
1661            schema: None,
1662            minimum_version: None,
1663            extends: vec![],
1664            entry: vec![],
1665            ignore_patterns: vec![],
1666            ignore_findings: vec![],
1667            framework: vec![],
1668            workspaces: None,
1669            ignore_dependencies: vec![],
1670            ignore_unresolved_imports: vec![],
1671            ignore_exports: vec![],
1672            ignore_catalog_references: vec![],
1673            ignore_dependency_overrides: vec![],
1674            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1675            used_class_members: vec![],
1676            ignore_decorators: vec![],
1677            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1678            duplicates: fallow_config::DuplicatesConfig::default(),
1679            similar_code: fallow_config::SimilarCodeConfig::default(),
1680            health: fallow_config::HealthConfig::default(),
1681            rules: RulesConfig::default(),
1682            boundaries: fallow_config::BoundaryConfig::default(),
1683            production: false.into(),
1684            plugins: vec![],
1685            rule_packs: vec![],
1686            dynamically_loaded: vec![],
1687            overrides: vec![],
1688            regression: None,
1689            audit: fallow_config::AuditConfig::default(),
1690            codeowners: None,
1691            public_packages: vec![],
1692            flags: fallow_config::FlagsConfig::default(),
1693            security: fallow_config::SecurityConfig::default(),
1694            fix: fallow_config::FixConfig::default(),
1695            resolve: fallow_config::ResolveConfig::default(),
1696            sealed: false,
1697            include_entry_exports: false,
1698            auto_imports: false,
1699            cache: fallow_config::CacheConfig::default(),
1700        }
1701        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1702
1703        let files = vec![
1704            DiscoveredFile {
1705                id: FileId(0),
1706                path: root.join("app/pages/index.tsx"),
1707                size_bytes: 1,
1708            },
1709            DiscoveredFile {
1710                id: FileId(1),
1711                path: root.join("app/pages/-helper.ts"),
1712                size_bytes: 1,
1713            },
1714        ];
1715
1716        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1717        plugin_result.entry_patterns.push((
1718            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1719                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1720            "tanstack-router".to_string(),
1721        ));
1722        plugin_result
1723            .entry_point_roles
1724            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1725
1726        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1727        let entry_paths: Vec<_> = entries
1728            .all
1729            .iter()
1730            .map(|entry| {
1731                entry
1732                    .path
1733                    .strip_prefix(root)
1734                    .unwrap()
1735                    .to_string_lossy()
1736                    .into_owned()
1737            })
1738            .collect();
1739
1740        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1741        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1742    }
1743
1744    mod resolve_entry_path_tests {
1745        use super::*;
1746
1747        #[test]
1748        fn resolves_existing_file() {
1749            let dir = tempfile::tempdir().expect("create temp dir");
1750            let src = dir.path().join("src");
1751            std::fs::create_dir_all(&src).unwrap();
1752            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1753
1754            let canonical = dunce::canonicalize(dir.path()).unwrap();
1755            let result = resolve_entry_path(
1756                dir.path(),
1757                "src/index.ts",
1758                &canonical,
1759                EntryPointSource::PackageJsonMain,
1760            );
1761            assert!(result.is_some(), "should resolve an existing file");
1762            assert!(result.unwrap().path.ends_with("src/index.ts"));
1763        }
1764
1765        #[test]
1766        fn resolves_with_extension_fallback() {
1767            let dir = tempfile::tempdir().expect("create temp dir");
1768            let canonical = dunce::canonicalize(dir.path()).unwrap();
1769            let src = canonical.join("src");
1770            std::fs::create_dir_all(&src).unwrap();
1771            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1772
1773            let result = resolve_entry_path(
1774                &canonical,
1775                "src/index",
1776                &canonical,
1777                EntryPointSource::PackageJsonMain,
1778            );
1779            assert!(
1780                result.is_some(),
1781                "should resolve via extension fallback when exact path doesn't exist"
1782            );
1783            let ep = result.unwrap();
1784            assert!(
1785                ep.path.to_string_lossy().contains("index.ts"),
1786                "should find index.ts via extension fallback"
1787            );
1788        }
1789
1790        #[test]
1791        fn exact_file_wins_before_directory_index_fallback() {
1792            let dir = tempfile::tempdir().expect("create temp dir");
1793            let canonical = dunce::canonicalize(dir.path()).unwrap();
1794            let scripts = canonical.join("scripts");
1795            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1796            std::fs::write(
1797                scripts.join("process-messages.js"),
1798                "export const direct = true;",
1799            )
1800            .unwrap();
1801            std::fs::write(
1802                scripts.join("process-messages").join("index.js"),
1803                "export const index = true;",
1804            )
1805            .unwrap();
1806
1807            let result = resolve_entry_path(
1808                &canonical,
1809                "scripts/process-messages.js",
1810                &canonical,
1811                EntryPointSource::PackageJsonScript,
1812            )
1813            .expect("exact file should resolve");
1814
1815            assert!(result.path.ends_with("scripts/process-messages.js"));
1816        }
1817
1818        #[test]
1819        fn extension_fallback_wins_before_directory_index_fallback() {
1820            let dir = tempfile::tempdir().expect("create temp dir");
1821            let canonical = dunce::canonicalize(dir.path()).unwrap();
1822            let scripts = canonical.join("scripts");
1823            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1824            std::fs::write(
1825                scripts.join("process-messages.ts"),
1826                "export const withExt = true;",
1827            )
1828            .unwrap();
1829            std::fs::write(
1830                scripts.join("process-messages").join("index.js"),
1831                "export const index = true;",
1832            )
1833            .unwrap();
1834
1835            let result = resolve_entry_path(
1836                &canonical,
1837                "scripts/process-messages",
1838                &canonical,
1839                EntryPointSource::PackageJsonScript,
1840            )
1841            .expect("extension fallback should resolve");
1842
1843            assert!(result.path.ends_with("scripts/process-messages.ts"));
1844        }
1845
1846        #[test]
1847        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1848            let dir = tempfile::tempdir().expect("create temp dir");
1849            let canonical = dunce::canonicalize(dir.path()).unwrap();
1850            let scripts = canonical.join("scripts/process-messages");
1851            std::fs::create_dir_all(&scripts).unwrap();
1852            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1853
1854            let result = resolve_entry_path(
1855                &canonical,
1856                "scripts/process-messages",
1857                &canonical,
1858                EntryPointSource::PackageJsonScript,
1859            )
1860            .expect("directory index should resolve");
1861
1862            assert!(result.path.ends_with("scripts/process-messages/index.js"));
1863        }
1864
1865        #[test]
1866        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1867            let dir = tempfile::tempdir().expect("create temp dir");
1868            let canonical = dunce::canonicalize(dir.path()).unwrap();
1869            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1870            std::fs::write(
1871                canonical.join("scripts/process-messages/index.js"),
1872                "export const index = true;",
1873            )
1874            .unwrap();
1875
1876            for entry in [
1877                "scripts/*",
1878                "https://example.com/scripts/process-messages",
1879                "@scope/package/scripts/process-messages",
1880            ] {
1881                let result = resolve_entry_path(
1882                    &canonical,
1883                    entry,
1884                    &canonical,
1885                    EntryPointSource::PackageJsonScript,
1886                );
1887                assert!(result.is_none(), "{entry} should not resolve");
1888            }
1889        }
1890
1891        #[test]
1892        fn returns_none_for_nonexistent_file() {
1893            let dir = tempfile::tempdir().expect("create temp dir");
1894            let canonical = dunce::canonicalize(dir.path()).unwrap();
1895            let result = resolve_entry_path(
1896                dir.path(),
1897                "does/not/exist.ts",
1898                &canonical,
1899                EntryPointSource::PackageJsonMain,
1900            );
1901            assert!(result.is_none(), "should return None for nonexistent files");
1902        }
1903
1904        #[test]
1905        fn maps_dist_output_to_src() {
1906            let dir = tempfile::tempdir().expect("create temp dir");
1907            let src = dir.path().join("src");
1908            std::fs::create_dir_all(&src).unwrap();
1909            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1910
1911            let dist = dir.path().join("dist");
1912            std::fs::create_dir_all(&dist).unwrap();
1913            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1914
1915            let canonical = dunce::canonicalize(dir.path()).unwrap();
1916            let result = resolve_entry_path(
1917                dir.path(),
1918                "./dist/utils.js",
1919                &canonical,
1920                EntryPointSource::PackageJsonExports,
1921            );
1922            assert!(result.is_some(), "should resolve dist/ path to src/");
1923            let ep = result.unwrap();
1924            assert!(
1925                ep.path
1926                    .to_string_lossy()
1927                    .replace('\\', "/")
1928                    .contains("src/utils.ts"),
1929                "should map ./dist/utils.js to src/utils.ts"
1930            );
1931        }
1932
1933        #[test]
1934        fn maps_build_output_to_src() {
1935            let dir = tempfile::tempdir().expect("create temp dir");
1936            let canonical = dunce::canonicalize(dir.path()).unwrap();
1937            let src = canonical.join("src");
1938            std::fs::create_dir_all(&src).unwrap();
1939            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1940
1941            let result = resolve_entry_path(
1942                &canonical,
1943                "./build/index.js",
1944                &canonical,
1945                EntryPointSource::PackageJsonExports,
1946            );
1947            assert!(result.is_some(), "should map build/ output to src/");
1948            let ep = result.unwrap();
1949            assert!(
1950                ep.path
1951                    .to_string_lossy()
1952                    .replace('\\', "/")
1953                    .contains("src/index.tsx"),
1954                "should map ./build/index.js to src/index.tsx"
1955            );
1956        }
1957
1958        #[test]
1959        fn preserves_entry_point_source() {
1960            let dir = tempfile::tempdir().expect("create temp dir");
1961            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1962
1963            let canonical = dunce::canonicalize(dir.path()).unwrap();
1964            let result = resolve_entry_path(
1965                dir.path(),
1966                "index.ts",
1967                &canonical,
1968                EntryPointSource::PackageJsonScript,
1969            );
1970            assert!(result.is_some());
1971            assert!(
1972                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1973                "should preserve the source kind"
1974            );
1975        }
1976        #[test]
1977        fn tracks_skipped_entries_without_logging_each_repeat() {
1978            let dir = tempfile::tempdir().expect("create temp dir");
1979            let canonical = dunce::canonicalize(dir.path()).unwrap();
1980            let mut skipped_entries = FxHashMap::default();
1981
1982            let result = resolve_entry_path_with_tracking(
1983                dir.path(),
1984                "../scripts/build.js",
1985                &canonical,
1986                EntryPointSource::PackageJsonScript,
1987                Some(&mut skipped_entries),
1988            );
1989
1990            assert!(result.is_none(), "unsafe entry should be skipped");
1991            assert_eq!(
1992                skipped_entries.get("../scripts/build.js"),
1993                Some(&1),
1994                "warning tracker should count the skipped path"
1995            );
1996        }
1997
1998        #[test]
1999        fn formats_skipped_entry_warning_with_counts() {
2000            let mut skipped_entries = FxHashMap::default();
2001            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
2002            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
2003
2004            let warning =
2005                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
2006
2007            assert_eq!(
2008                warning,
2009                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
2010            );
2011        }
2012
2013        #[test]
2014        fn skipped_entry_summary_dedupes_identical_messages() {
2015            let message = format!(
2016                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
2017                std::process::id()
2018            );
2019            assert!(
2020                should_warn_skipped_entry(&message),
2021                "first occurrence of a message emits"
2022            );
2023            assert!(
2024                !should_warn_skipped_entry(&message),
2025                "identical repeat is suppressed"
2026            );
2027        }
2028
2029        #[test]
2030        fn rejects_parent_dir_escape_for_exact_file() {
2031            let sandbox = tempfile::tempdir().expect("create sandbox");
2032            let root = sandbox.path().join("project");
2033            std::fs::create_dir_all(&root).unwrap();
2034            std::fs::write(
2035                sandbox.path().join("escape.ts"),
2036                "export const escape = true;",
2037            )
2038            .unwrap();
2039
2040            let canonical = dunce::canonicalize(&root).unwrap();
2041            let result = resolve_entry_path(
2042                &root,
2043                "../escape.ts",
2044                &canonical,
2045                EntryPointSource::PackageJsonMain,
2046            );
2047
2048            assert!(
2049                result.is_none(),
2050                "should reject exact paths that escape the root"
2051            );
2052        }
2053
2054        #[test]
2055        fn rejects_parent_dir_escape_via_extension_fallback() {
2056            let sandbox = tempfile::tempdir().expect("create sandbox");
2057            let root = sandbox.path().join("project");
2058            std::fs::create_dir_all(&root).unwrap();
2059            std::fs::write(
2060                sandbox.path().join("escape.ts"),
2061                "export const escape = true;",
2062            )
2063            .unwrap();
2064
2065            let canonical = dunce::canonicalize(&root).unwrap();
2066            let result = resolve_entry_path(
2067                &root,
2068                "../escape",
2069                &canonical,
2070                EntryPointSource::PackageJsonMain,
2071            );
2072
2073            assert!(
2074                result.is_none(),
2075                "should reject extension fallback paths that escape the root"
2076            );
2077        }
2078    }
2079
2080    mod output_to_source_tests {
2081        use super::*;
2082
2083        #[test]
2084        fn maps_dist_to_src_with_ts_extension() {
2085            let dir = tempfile::tempdir().expect("create temp dir");
2086            let src = dir.path().join("src");
2087            std::fs::create_dir_all(&src).unwrap();
2088            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2089
2090            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
2091            assert!(result.is_some());
2092            assert!(
2093                result
2094                    .unwrap()
2095                    .to_string_lossy()
2096                    .replace('\\', "/")
2097                    .contains("src/utils.ts")
2098            );
2099        }
2100
2101        #[test]
2102        fn returns_none_when_no_source_file_exists() {
2103            let dir = tempfile::tempdir().expect("create temp dir");
2104            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
2105            assert!(result.is_none());
2106        }
2107
2108        #[test]
2109        fn ignores_non_output_directories() {
2110            let dir = tempfile::tempdir().expect("create temp dir");
2111            let src = dir.path().join("src");
2112            std::fs::create_dir_all(&src).unwrap();
2113            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2114
2115            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
2116            assert!(result.is_none());
2117        }
2118
2119        #[test]
2120        fn maps_nested_output_path_preserving_prefix() {
2121            let dir = tempfile::tempdir().expect("create temp dir");
2122            let modules_src = dir.path().join("modules").join("src");
2123            std::fs::create_dir_all(&modules_src).unwrap();
2124            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
2125
2126            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
2127            assert!(result.is_some());
2128            assert!(
2129                result
2130                    .unwrap()
2131                    .to_string_lossy()
2132                    .replace('\\', "/")
2133                    .contains("modules/src/helper.ts")
2134            );
2135        }
2136    }
2137
2138    mod source_index_fallback_tests {
2139        use super::*;
2140
2141        #[test]
2142        fn detects_dist_entry_in_output_dir() {
2143            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
2144            assert!(is_entry_in_output_dir("dist/index.js"));
2145            assert!(is_entry_in_output_dir("./build/index.js"));
2146            assert!(is_entry_in_output_dir("./out/main.js"));
2147            assert!(is_entry_in_output_dir("./esm/index.js"));
2148            assert!(is_entry_in_output_dir("./cjs/index.js"));
2149        }
2150
2151        #[test]
2152        fn rejects_non_output_entry_paths() {
2153            assert!(!is_entry_in_output_dir("./src/index.ts"));
2154            assert!(!is_entry_in_output_dir("src/main.ts"));
2155            assert!(!is_entry_in_output_dir("./index.js"));
2156            assert!(!is_entry_in_output_dir(""));
2157        }
2158
2159        #[test]
2160        fn root_index_entries_are_recognized_for_source_fallback() {
2161            assert!(is_package_root_index_entry("./index.js"));
2162            assert!(is_package_root_index_entry("index.cjs"));
2163            assert!(is_package_root_index_entry("./index.d.ts"));
2164            assert!(!is_package_root_index_entry("./src/index.js"));
2165            assert!(!is_package_root_index_entry("./main.js"));
2166            assert!(!is_package_root_index_entry(""));
2167        }
2168
2169        #[test]
2170        fn rejects_substring_match_for_output_dir() {
2171            assert!(!is_entry_in_output_dir("./distro/index.js"));
2172            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
2173        }
2174
2175        #[test]
2176        fn finds_src_index_ts() {
2177            let dir = tempfile::tempdir().expect("create temp dir");
2178            let src = dir.path().join("src");
2179            std::fs::create_dir_all(&src).unwrap();
2180            let index_path = src.join("index.ts");
2181            std::fs::write(&index_path, "export const a = 1;").unwrap();
2182
2183            let result = try_source_index_fallback(dir.path());
2184            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2185        }
2186
2187        #[test]
2188        fn finds_src_index_tsx_when_ts_missing() {
2189            let dir = tempfile::tempdir().expect("create temp dir");
2190            let src = dir.path().join("src");
2191            std::fs::create_dir_all(&src).unwrap();
2192            let index_path = src.join("index.tsx");
2193            std::fs::write(&index_path, "export default 1;").unwrap();
2194
2195            let result = try_source_index_fallback(dir.path());
2196            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2197        }
2198
2199        #[test]
2200        fn prefers_src_index_over_root_index() {
2201            let dir = tempfile::tempdir().expect("create temp dir");
2202            let src = dir.path().join("src");
2203            std::fs::create_dir_all(&src).unwrap();
2204            let src_index = src.join("index.ts");
2205            std::fs::write(&src_index, "export const a = 1;").unwrap();
2206            let root_index = dir.path().join("index.ts");
2207            std::fs::write(&root_index, "export const b = 2;").unwrap();
2208
2209            let result = try_source_index_fallback(dir.path());
2210            assert_eq!(result.as_deref(), Some(src_index.as_path()));
2211        }
2212
2213        #[test]
2214        fn falls_back_to_src_main() {
2215            let dir = tempfile::tempdir().expect("create temp dir");
2216            let src = dir.path().join("src");
2217            std::fs::create_dir_all(&src).unwrap();
2218            let main_path = src.join("main.ts");
2219            std::fs::write(&main_path, "export const a = 1;").unwrap();
2220
2221            let result = try_source_index_fallback(dir.path());
2222            assert_eq!(result.as_deref(), Some(main_path.as_path()));
2223        }
2224
2225        #[test]
2226        fn falls_back_to_root_index_when_no_src() {
2227            let dir = tempfile::tempdir().expect("create temp dir");
2228            let index_path = dir.path().join("index.js");
2229            std::fs::write(&index_path, "module.exports = {};").unwrap();
2230
2231            let result = try_source_index_fallback(dir.path());
2232            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2233        }
2234
2235        #[test]
2236        fn returns_none_when_nothing_matches() {
2237            let dir = tempfile::tempdir().expect("create temp dir");
2238            let result = try_source_index_fallback(dir.path());
2239            assert!(result.is_none());
2240        }
2241
2242        #[test]
2243        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
2244            let dir = tempfile::tempdir().expect("create temp dir");
2245            let canonical = dunce::canonicalize(dir.path()).unwrap();
2246
2247            let dist_dir = canonical.join("dist").join("esm2022");
2248            std::fs::create_dir_all(&dist_dir).unwrap();
2249            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
2250
2251            let src = canonical.join("src");
2252            std::fs::create_dir_all(&src).unwrap();
2253            let src_index = src.join("index.ts");
2254            std::fs::write(&src_index, "export const x = 1;").unwrap();
2255
2256            let result = resolve_entry_path(
2257                &canonical,
2258                "./dist/esm2022/index.js",
2259                &canonical,
2260                EntryPointSource::PackageJsonMain,
2261            );
2262            assert!(result.is_some());
2263            let entry = result.unwrap();
2264            assert_eq!(entry.path, src_index);
2265        }
2266
2267        #[test]
2268        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2269            let dir = tempfile::tempdir().expect("create temp dir");
2270            let canonical = dunce::canonicalize(dir.path()).unwrap();
2271
2272            let src_mirror = canonical.join("src").join("esm2022");
2273            std::fs::create_dir_all(&src_mirror).unwrap();
2274            let mirror_index = src_mirror.join("index.ts");
2275            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2276
2277            let src_index = canonical.join("src").join("index.ts");
2278            std::fs::write(&src_index, "export const y = 2;").unwrap();
2279
2280            let result = resolve_entry_path(
2281                &canonical,
2282                "./dist/esm2022/index.js",
2283                &canonical,
2284                EntryPointSource::PackageJsonMain,
2285            );
2286            assert_eq!(result.map(|e| e.path), Some(mirror_index));
2287        }
2288
2289        #[test]
2290        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2291            let dir = tempfile::tempdir().expect("create temp dir");
2292            let canonical = dunce::canonicalize(dir.path()).unwrap();
2293
2294            let src = canonical.join("src");
2295            std::fs::create_dir_all(&src).unwrap();
2296            let src_index = src.join("index.ts");
2297            std::fs::write(&src_index, "export const x = 1;").unwrap();
2298
2299            let result = resolve_entry_path(
2300                &canonical,
2301                "./index.js",
2302                &canonical,
2303                EntryPointSource::PackageJsonMain,
2304            );
2305            assert_eq!(result.map(|entry| entry.path), Some(src_index));
2306        }
2307    }
2308
2309    mod default_fallback_tests {
2310        use super::*;
2311
2312        #[test]
2313        fn finds_src_index_ts_as_fallback() {
2314            let dir = tempfile::tempdir().expect("create temp dir");
2315            let src = dir.path().join("src");
2316            std::fs::create_dir_all(&src).unwrap();
2317            let index_path = src.join("index.ts");
2318            std::fs::write(&index_path, "export const a = 1;").unwrap();
2319
2320            let files = vec![DiscoveredFile {
2321                id: FileId(0),
2322                path: index_path.clone(),
2323                size_bytes: 20,
2324            }];
2325
2326            let entries = apply_default_fallback(&files, dir.path(), None);
2327            assert_eq!(entries.len(), 1);
2328            assert_eq!(entries[0].path, index_path);
2329            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2330        }
2331
2332        #[test]
2333        fn finds_root_index_js_as_fallback() {
2334            let dir = tempfile::tempdir().expect("create temp dir");
2335            let index_path = dir.path().join("index.js");
2336            std::fs::write(&index_path, "module.exports = {};").unwrap();
2337
2338            let files = vec![DiscoveredFile {
2339                id: FileId(0),
2340                path: index_path.clone(),
2341                size_bytes: 21,
2342            }];
2343
2344            let entries = apply_default_fallback(&files, dir.path(), None);
2345            assert_eq!(entries.len(), 1);
2346            assert_eq!(entries[0].path, index_path);
2347        }
2348
2349        #[test]
2350        fn returns_empty_when_no_index_file() {
2351            let dir = tempfile::tempdir().expect("create temp dir");
2352            let other_path = dir.path().join("src").join("utils.ts");
2353
2354            let files = vec![DiscoveredFile {
2355                id: FileId(0),
2356                path: other_path,
2357                size_bytes: 10,
2358            }];
2359
2360            let entries = apply_default_fallback(&files, dir.path(), None);
2361            assert!(
2362                entries.is_empty(),
2363                "non-index files should not match default fallback"
2364            );
2365        }
2366
2367        #[test]
2368        fn workspace_filter_restricts_scope() {
2369            let dir = tempfile::tempdir().expect("create temp dir");
2370            let ws_a = dir.path().join("packages").join("a").join("src");
2371            std::fs::create_dir_all(&ws_a).unwrap();
2372            let ws_b = dir.path().join("packages").join("b").join("src");
2373            std::fs::create_dir_all(&ws_b).unwrap();
2374
2375            let index_a = ws_a.join("index.ts");
2376            let index_b = ws_b.join("index.ts");
2377
2378            let files = vec![
2379                DiscoveredFile {
2380                    id: FileId(0),
2381                    path: index_a.clone(),
2382                    size_bytes: 10,
2383                },
2384                DiscoveredFile {
2385                    id: FileId(1),
2386                    path: index_b,
2387                    size_bytes: 10,
2388                },
2389            ];
2390
2391            let ws_root = dir.path().join("packages").join("a");
2392            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2393            assert_eq!(entries.len(), 1);
2394            assert_eq!(entries[0].path, index_a);
2395        }
2396    }
2397
2398    mod wildcard_entry_tests {
2399        use super::*;
2400
2401        #[test]
2402        fn expands_wildcard_css_entries() {
2403            let dir = tempfile::tempdir().expect("create temp dir");
2404            let themes = dir.path().join("src").join("themes");
2405            std::fs::create_dir_all(&themes).unwrap();
2406            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2407            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2408
2409            let canonical = dunce::canonicalize(dir.path()).unwrap();
2410            let mut entries = Vec::new();
2411            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2412
2413            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2414            let paths: Vec<String> = entries
2415                .iter()
2416                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2417                .collect();
2418            assert!(paths.contains(&"dark.css".to_string()));
2419            assert!(paths.contains(&"light.css".to_string()));
2420            assert!(
2421                entries
2422                    .iter()
2423                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2424            );
2425        }
2426
2427        #[test]
2428        fn wildcard_does_not_match_nonexistent_files() {
2429            let dir = tempfile::tempdir().expect("create temp dir");
2430            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2431
2432            let canonical = dunce::canonicalize(dir.path()).unwrap();
2433            let mut entries = Vec::new();
2434            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2435
2436            assert!(
2437                entries.is_empty(),
2438                "should return empty when no files match the wildcard"
2439            );
2440        }
2441
2442        #[test]
2443        fn wildcard_only_matches_specified_extension() {
2444            let dir = tempfile::tempdir().expect("create temp dir");
2445            let themes = dir.path().join("src").join("themes");
2446            std::fs::create_dir_all(&themes).unwrap();
2447            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2448            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2449
2450            let canonical = dunce::canonicalize(dir.path()).unwrap();
2451            let mut entries = Vec::new();
2452            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2453
2454            assert_eq!(entries.len(), 1, "should only match CSS files");
2455            assert!(
2456                entries[0]
2457                    .path
2458                    .file_name()
2459                    .unwrap()
2460                    .to_string_lossy()
2461                    .ends_with(".css")
2462            );
2463        }
2464    }
2465
2466    fn config_for(root: &Path) -> ResolvedConfig {
2467        FallowConfig::default().resolve(
2468            root.to_path_buf(),
2469            OutputFormat::Human,
2470            1,
2471            true,
2472            true,
2473            None,
2474        )
2475    }
2476
2477    fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
2478        DiscoveredFile {
2479            id: FileId(id),
2480            path: root.join(rel),
2481            size_bytes: 1,
2482        }
2483    }
2484
2485    #[test]
2486    fn root_entry_points_use_package_json_scripts() {
2487        let dir = tempfile::tempdir().expect("tempdir");
2488        let root = dir.path();
2489        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2490        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2491            .expect("script file");
2492        std::fs::write(
2493            root.join("package.json"),
2494            r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2495        )
2496        .expect("package file");
2497        let config = config_for(root);
2498        let files = [discovered(root, "scripts/build.ts", 0)];
2499
2500        let entries = discover_entry_points(&config, &files);
2501
2502        assert_eq!(entries.len(), 1);
2503        assert!(entries[0].path.ends_with("scripts/build.ts"));
2504        assert!(matches!(
2505            entries[0].source,
2506            EntryPointSource::PackageJsonScript
2507        ));
2508    }
2509
2510    #[test]
2511    fn build_script_is_support_while_default_index_stays_runtime() {
2512        let dir = tempfile::tempdir().expect("tempdir");
2513        let root = dir.path();
2514        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2515        std::fs::create_dir_all(root.join("src")).expect("src dir");
2516        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2517            .expect("script file");
2518        std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2519        std::fs::write(
2520            root.join("package.json"),
2521            r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
2522        )
2523        .expect("package file");
2524        let config = config_for(root);
2525        let files = [
2526            discovered(root, "scripts/build.ts", 0),
2527            discovered(root, "src/index.ts", 1),
2528        ];
2529
2530        let discovery = discover_entry_points_with_warnings(&config, &files);
2531
2532        assert_eq!(discovery.entries.len(), 1);
2533        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2534        assert_eq!(discovery.support_entries.len(), 1);
2535        assert!(
2536            discovery.support_entries[0]
2537                .path
2538                .ends_with("scripts/build.ts")
2539        );
2540    }
2541
2542    #[test]
2543    fn indirect_quoted_start_script_stays_runtime() {
2544        let dir = tempfile::tempdir().expect("tempdir");
2545        let root = dir.path();
2546        std::fs::create_dir_all(root.join("src")).expect("src dir");
2547        std::fs::write(root.join("src/server.ts"), "export const server = true;")
2548            .expect("server file");
2549        std::fs::write(
2550            root.join("package.json"),
2551            r#"{"scripts":{"start":"npm run serve","serve":"node 'src/server.ts'"}}"#,
2552        )
2553        .expect("package file");
2554        let config = config_for(root);
2555        let files = [discovered(root, "src/server.ts", 0)];
2556
2557        let discovery = discover_entry_points_with_warnings(&config, &files);
2558
2559        assert_eq!(discovery.entries.len(), 1);
2560        assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2561        assert!(discovery.support_entries.is_empty());
2562    }
2563
2564    #[test]
2565    fn indirect_start_script_includes_its_lifecycle_hooks() {
2566        let dir = tempfile::tempdir().expect("tempdir");
2567        let root = dir.path();
2568        std::fs::create_dir_all(root.join("src")).expect("src dir");
2569        for file in ["pre.ts", "server.ts", "post.ts"] {
2570            std::fs::write(root.join("src").join(file), "export const value = true;")
2571                .expect("runtime file");
2572        }
2573        std::fs::write(
2574            root.join("package.json"),
2575            r#"{"scripts":{"start":"npm run serve","preserve":"node src/pre.ts","serve":"node src/server.ts","postserve":"node src/post.ts"}}"#,
2576        )
2577        .expect("package file");
2578        let config = config_for(root);
2579        let files = [
2580            discovered(root, "src/pre.ts", 0),
2581            discovered(root, "src/server.ts", 1),
2582            discovered(root, "src/post.ts", 2),
2583        ];
2584
2585        let discovery = discover_entry_points_with_warnings(&config, &files);
2586
2587        assert_eq!(discovery.entries.len(), 3);
2588        assert!(discovery.support_entries.is_empty());
2589    }
2590
2591    #[test]
2592    #[expect(
2593        clippy::disallowed_types,
2594        reason = "test input matches the serde-deserialized package.json scripts map"
2595    )]
2596    fn start_does_not_recursively_invent_hooks_for_lifecycle_hooks() {
2597        let scripts = std::collections::HashMap::from([
2598            ("preprestart".to_string(), "node src/tool.ts".to_string()),
2599            ("prestart".to_string(), "node src/pre.ts".to_string()),
2600            ("start".to_string(), "node src/server.ts".to_string()),
2601            ("poststart".to_string(), "node src/post.ts".to_string()),
2602            ("postpoststart".to_string(), "node src/tool.ts".to_string()),
2603        ]);
2604
2605        let runtime = runtime_package_script_names(&scripts);
2606
2607        assert_eq!(
2608            runtime,
2609            FxHashSet::from_iter([
2610                "prestart".to_string(),
2611                "start".to_string(),
2612                "poststart".to_string(),
2613            ])
2614        );
2615    }
2616
2617    #[test]
2618    fn bun_build_script_remains_support_entry() {
2619        let dir = tempfile::tempdir().expect("tempdir");
2620        let root = dir.path();
2621        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
2622        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
2623            .expect("build file");
2624        std::fs::write(
2625            root.join("package.json"),
2626            r#"{"scripts":{"build":"bun scripts/build.ts"}}"#,
2627        )
2628        .expect("package file");
2629        let config = config_for(root);
2630        let files = [discovered(root, "scripts/build.ts", 0)];
2631
2632        let discovery = discover_entry_points_with_warnings(&config, &files);
2633
2634        assert!(discovery.entries.is_empty());
2635        assert_eq!(discovery.support_entries.len(), 1);
2636    }
2637
2638    #[test]
2639    fn start_script_config_remains_support_only() {
2640        let dir = tempfile::tempdir().expect("tempdir");
2641        let root = dir.path();
2642        std::fs::write(root.join("vite.config.ts"), "export default {};").expect("config file");
2643        std::fs::write(
2644            root.join("package.json"),
2645            r#"{"scripts":{"start":"vite --config vite.config.ts"}}"#,
2646        )
2647        .expect("package file");
2648        let config = config_for(root);
2649        let files = [discovered(root, "vite.config.ts", 0)];
2650
2651        let discovery = discover_entry_points_with_warnings(&config, &files);
2652
2653        assert!(discovery.entries.is_empty());
2654        assert_eq!(discovery.support_entries.len(), 1);
2655        assert!(
2656            discovery.support_entries[0]
2657                .path
2658                .ends_with("vite.config.ts")
2659        );
2660    }
2661
2662    #[test]
2663    fn manifest_entry_remains_runtime_when_build_script_references_same_path() {
2664        let dir = tempfile::tempdir().expect("tempdir");
2665        let root = dir.path();
2666        std::fs::create_dir_all(root.join("src")).expect("src dir");
2667        std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
2668        std::fs::write(
2669            root.join("package.json"),
2670            r#"{"main":"src/index.ts","scripts":{"build":"tsx src/index.ts"}}"#,
2671        )
2672        .expect("package file");
2673        let config = config_for(root);
2674        let files = [discovered(root, "src/index.ts", 0)];
2675
2676        let discovery = discover_entry_points_with_warnings(&config, &files);
2677
2678        assert_eq!(discovery.entries.len(), 1);
2679        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2680    }
2681
2682    #[test]
2683    fn workspace_build_script_is_support_with_runtime_fallback() {
2684        let dir = tempfile::tempdir().expect("tempdir");
2685        let root = dir.path();
2686        let workspace = root.join("packages/app");
2687        std::fs::create_dir_all(workspace.join("scripts")).expect("scripts dir");
2688        std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2689        std::fs::write(
2690            workspace.join("scripts/build.ts"),
2691            "export const build = true;",
2692        )
2693        .expect("script file");
2694        std::fs::write(workspace.join("src/index.ts"), "export const app = true;")
2695            .expect("index file");
2696        std::fs::write(
2697            workspace.join("package.json"),
2698            r#"{"name":"app","scripts":{"build":"tsx scripts/build.ts"}}"#,
2699        )
2700        .expect("package file");
2701        let files = [
2702            discovered(root, "packages/app/scripts/build.ts", 0),
2703            discovered(root, "packages/app/src/index.ts", 1),
2704        ];
2705
2706        let discovery =
2707            discover_workspace_entry_points_with_warnings(&workspace, &config_for(root), &files);
2708
2709        assert_eq!(discovery.entries.len(), 1);
2710        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
2711        assert_eq!(discovery.support_entries.len(), 1);
2712        assert!(
2713            discovery.support_entries[0]
2714                .path
2715                .ends_with("scripts/build.ts")
2716        );
2717    }
2718
2719    #[test]
2720    fn workspace_script_selected_by_root_start_is_runtime() {
2721        let dir = tempfile::tempdir().expect("tempdir");
2722        let root = dir.path();
2723        let workspace = root.join("packages/api");
2724        std::fs::create_dir_all(workspace.join("src")).expect("src dir");
2725        std::fs::write(workspace.join("src/server.ts"), "export const app = true;")
2726            .expect("server file");
2727        let workspace_pkg: PackageJson = serde_json::from_str(
2728            r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2729        )
2730        .expect("workspace package");
2731        let files = [discovered(root, "packages/api/src/server.ts", 0)];
2732        let seeds = FxHashSet::from_iter(["serve".to_string()]);
2733
2734        let discovery = discover_workspace_entry_points_with_runtime_scripts(
2735            &workspace,
2736            &files,
2737            Some(&workspace_pkg),
2738            &seeds,
2739        );
2740
2741        assert_eq!(discovery.entries.len(), 1);
2742        assert!(discovery.entries[0].path.ends_with("src/server.ts"));
2743        assert!(discovery.support_entries.is_empty());
2744    }
2745
2746    #[test]
2747    fn workspace_script_selection_does_not_promote_same_named_root_script() {
2748        let dir = tempfile::tempdir().expect("tempdir");
2749        let root = dir.path();
2750        let root_pkg: PackageJson = serde_json::from_str(
2751            r#"{
2752                "scripts": {
2753                    "start": "pnpm --filter=@scope/api run serve",
2754                    "serve": "node src/unrelated.ts"
2755                }
2756            }"#,
2757        )
2758        .expect("root package");
2759        let api_pkg: PackageJson = serde_json::from_str(
2760            r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
2761        )
2762        .expect("api package");
2763        let workspace_pkgs = vec![(
2764            fallow_config::WorkspaceInfo {
2765                root: root.join("packages/api"),
2766                name: "@scope/api".to_string(),
2767                is_internal_dependency: false,
2768            },
2769            api_pkg,
2770        )];
2771        let scripts = root_pkg.scripts.as_ref().expect("root scripts");
2772
2773        let runtime = runtime_package_script_names(scripts);
2774        let workspace_seeds =
2775            workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2776
2777        assert_eq!(runtime, FxHashSet::from_iter(["start".to_string()]));
2778        assert_eq!(
2779            workspace_seeds.get("@scope/api"),
2780            Some(&FxHashSet::from_iter(["serve".to_string()]))
2781        );
2782    }
2783
2784    #[test]
2785    fn workspace_runtime_script_seeds_follow_transitive_workspace_calls() {
2786        let dir = tempfile::tempdir().expect("tempdir");
2787        let root = dir.path();
2788        let root_pkg: PackageJson =
2789            serde_json::from_str(r#"{"scripts":{"start":"pnpm --filter @scope/api run serve"}}"#)
2790                .expect("root package");
2791        let api_pkg: PackageJson = serde_json::from_str(
2792            r#"{"name":"@scope/api","scripts":{"serve":"npm --workspace packages/worker run work"}}"#,
2793        )
2794        .expect("api package");
2795        let worker_pkg: PackageJson = serde_json::from_str(
2796            r#"{"name":"@scope/worker","scripts":{"prework":"node pre.ts","work":"node worker.ts"}}"#,
2797        )
2798        .expect("worker package");
2799        let workspace_pkgs = vec![
2800            (
2801                fallow_config::WorkspaceInfo {
2802                    root: root.join("packages/api"),
2803                    name: "@scope/api".to_string(),
2804                    is_internal_dependency: false,
2805                },
2806                api_pkg,
2807            ),
2808            (
2809                fallow_config::WorkspaceInfo {
2810                    root: root.join("packages/worker"),
2811                    name: "@scope/worker".to_string(),
2812                    is_internal_dependency: false,
2813                },
2814                worker_pkg,
2815            ),
2816        ];
2817
2818        let seeds = workspace_runtime_script_seeds(root, Some(&root_pkg), &workspace_pkgs);
2819
2820        assert_eq!(
2821            seeds.get("@scope/api"),
2822            Some(&FxHashSet::from_iter(["serve".to_string()]))
2823        );
2824        assert_eq!(
2825            seeds.get("@scope/worker"),
2826            Some(&FxHashSet::from_iter([
2827                "prework".to_string(),
2828                "work".to_string(),
2829            ]))
2830        );
2831    }
2832
2833    #[test]
2834    fn root_entry_points_fall_back_to_default_index() {
2835        let dir = tempfile::tempdir().expect("tempdir");
2836        let root = dir.path();
2837        std::fs::create_dir_all(root.join("src")).expect("src dir");
2838        std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
2839        let config = config_for(root);
2840        let files = [discovered(root, "src/index.ts", 0)];
2841
2842        let entries = discover_entry_points(&config, &files);
2843
2844        assert_eq!(entries.len(), 1);
2845        assert!(entries[0].path.ends_with("src/index.ts"));
2846        assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2847    }
2848
2849    #[test]
2850    fn default_index_matchers_preserve_patterns_and_are_shared() {
2851        let matchers = default_index_matchers();
2852        for path in [
2853            "src/index.ts",
2854            "src/index.tsx",
2855            "src/index.js",
2856            "src/index.jsx",
2857            "src/main.ts",
2858            "src/main.tsx",
2859            "src/main.js",
2860            "src/main.jsx",
2861            "index.ts",
2862            "index.tsx",
2863            "index.js",
2864            "index.jsx",
2865            "main.ts",
2866            "main.tsx",
2867            "main.js",
2868            "main.jsx",
2869        ] {
2870            assert!(
2871                matchers.is_match(path),
2872                "default index did not match {path}"
2873            );
2874        }
2875        for path in [
2876            "src/index.mts",
2877            "src/nested/index.ts",
2878            "packages/app/src/index.ts",
2879            "custom-entry.ts",
2880        ] {
2881            assert!(!matchers.is_match(path), "non-default index matched {path}");
2882        }
2883
2884        std::thread::scope(|scope| {
2885            let handles = std::iter::repeat_with(|| scope.spawn(default_index_matchers))
2886                .take(4)
2887                .collect::<Vec<_>>();
2888            for handle in handles {
2889                let from_thread = handle.join().expect("fallback matcher thread");
2890                assert!(std::ptr::eq(matchers, from_thread));
2891            }
2892        });
2893    }
2894
2895    #[test]
2896    fn workspace_entry_points_use_workspace_package_json() {
2897        let dir = tempfile::tempdir().expect("tempdir");
2898        let root = dir.path();
2899        let ws_root = root.join("packages/app");
2900        std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
2901        std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
2902            .expect("workspace main");
2903        std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
2904            .expect("workspace package file");
2905        let config = config_for(root);
2906        let files = [discovered(root, "packages/app/src/main.ts", 0)];
2907
2908        let entries = discover_workspace_entry_points(&ws_root, &config, &files);
2909
2910        assert_eq!(entries.len(), 1);
2911        assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
2912        assert!(matches!(
2913            entries[0].source,
2914            EntryPointSource::PackageJsonMain
2915        ));
2916    }
2917}