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::{
5    EntryPointRole, PackageJson, ResolvedConfig, TsconfigOutputMap, TsconfigOutputResolution,
6};
7use fallow_graph::resolve::{OUTPUT_DIRS, directory_index_entry, output_entry_to_source_path};
8use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
9use fallow_types::path_util::is_absolute_path_any_platform;
10use rustc_hash::{FxHashMap, FxHashSet};
11
12use crate::plugins::config_parser::lexical_normalize;
13use crate::scripts::{IgnoredCommandEntries, ScriptCatalog, WorkspacePackages};
14
15const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
16
17fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
18    if skipped_entries.is_empty() {
19        return None;
20    }
21
22    let mut entries = skipped_entries
23        .iter()
24        .map(|(path, count)| (path.as_str(), *count))
25        .collect::<Vec<_>>();
26    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
27
28    let preview = entries
29        .iter()
30        .take(SKIPPED_ENTRY_WARNING_PREVIEW)
31        .map(|(path, count)| {
32            if *count > 1 {
33                format!("{path} ({count}x)")
34            } else {
35                (*path).to_owned()
36            }
37        })
38        .collect::<Vec<_>>();
39
40    let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
41    let tail = if omitted > 0 {
42        format!(" (and {omitted} more)")
43    } else {
44        String::new()
45    };
46    let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
47    let noun = if total == 1 {
48        "package.json entry point"
49    } else {
50        "package.json entry points"
51    };
52
53    Some(format!(
54        "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
55        preview.join(", ")
56    ))
57}
58
59pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
60    let Some(message) = format_skipped_entry_warning(skipped_entries) else {
61        return;
62    };
63    if should_warn_skipped_entry(&message) {
64        tracing::warn!("{message}");
65    }
66}
67
68/// Process-wide dedupe for [`warn_skipped_entry_summary`]. Returns `true` when
69/// `message` was newly inserted (caller should emit). On a poisoned mutex
70/// returns `true` so over-warning beats swallowing.
71fn should_warn_skipped_entry(message: &str) -> bool {
72    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
73        std::sync::OnceLock::new();
74    let warned = WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()));
75    warned
76        .lock()
77        .map_or(true, |mut set| set.insert(message.to_owned()))
78}
79
80/// Entry points grouped by reachability role.
81#[derive(Debug, Clone, Default)]
82pub struct CategorizedEntryPoints {
83    pub(crate) all: Vec<EntryPoint>,
84    pub(crate) runtime: Vec<EntryPoint>,
85    pub(crate) test: Vec<EntryPoint>,
86}
87
88impl CategorizedEntryPoints {
89    fn push_runtime(&mut self, entry: EntryPoint) {
90        self.runtime.push(entry.clone());
91        self.all.push(entry);
92    }
93
94    fn push_test(&mut self, entry: EntryPoint) {
95        self.test.push(entry.clone());
96        self.all.push(entry);
97    }
98
99    fn push_support(&mut self, entry: EntryPoint) {
100        self.all.push(entry);
101    }
102
103    pub(crate) fn extend_runtime<I>(&mut self, entries: I)
104    where
105        I: IntoIterator<Item = EntryPoint>,
106    {
107        for entry in entries {
108            self.push_runtime(entry);
109        }
110    }
111
112    pub fn extend_support<I>(&mut self, entries: I)
113    where
114        I: IntoIterator<Item = EntryPoint>,
115    {
116        for entry in entries {
117            self.push_support(entry);
118        }
119    }
120
121    pub(crate) fn extend(&mut self, other: Self) {
122        self.all.extend(other.all);
123        self.runtime.extend(other.runtime);
124        self.test.extend(other.test);
125    }
126
127    #[must_use]
128    pub(crate) fn dedup(mut self) -> Self {
129        dedup_entry_paths(&mut self.all);
130        dedup_entry_paths(&mut self.runtime);
131        dedup_entry_paths(&mut self.test);
132        self
133    }
134}
135
136fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
137    entries.sort_by(|a, b| a.path.cmp(&b.path));
138    entries.dedup_by(|a, b| a.path == b.path);
139}
140
141#[derive(Debug, Default)]
142pub struct EntryPointDiscovery {
143    /// Runtime entry points declared by package metadata or inferred defaults.
144    pub entries: Vec<EntryPoint>,
145    /// Tooling entry points referenced only from non-runtime package scripts.
146    pub support_entries: Vec<EntryPoint>,
147    pub skipped_entries: FxHashMap<String, usize>,
148}
149
150impl EntryPointDiscovery {
151    fn into_all_entries(mut self) -> Vec<EntryPoint> {
152        self.entries.append(&mut self.support_entries);
153        dedup_entry_paths(&mut self.entries);
154        self.entries
155    }
156}
157
158/// Resolve a path relative to a base directory, with security check and extension fallback.
159///
160/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
161/// trying source extensions as fallback when the exact path doesn't exist.
162/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
163/// by trying to map back to the source file (e.g., `./src/utils.ts`).
164fn resolve_entry_path_with_tracking(
165    base: &Path,
166    entry: &str,
167    canonical_root: &Path,
168    source: EntryPointSource,
169    skipped_entries: Option<&mut FxHashMap<String, usize>>,
170    discovered: Option<IsDiscovered<'_>>,
171) -> Option<EntryPoint> {
172    let output_map = TsconfigOutputMap::from_project(base);
173    resolve_entry_path_with_output_map(
174        base,
175        entry,
176        canonical_root,
177        source,
178        skipped_entries,
179        EntryTargets {
180            output_map: &output_map,
181            discovered,
182        },
183    )
184}
185
186/// Return `true` when a path is part of the analyzed files. A `lib/` package
187/// entry whose file is on disk but not analyzed maps to `src/`.
188pub type IsDiscovered<'a> = &'a (dyn Fn(&Path) -> bool + Sync);
189
190/// The discovered files that a package entry must belong to.
191///
192/// A `lib/` entry whose file is on disk but outside this set (because of
193/// `.gitignore` or `ignorePatterns`) maps to `src/`, the same as a missing
194/// `lib/` entry. The path set is built on first use, so a project without
195/// such an entry does not pay for it.
196pub struct DiscoveredPaths<'a> {
197    files: &'a [DiscoveredFile],
198    /// Indexes into `files`, sorted by path. The index holds no borrow, so
199    /// `DiscoveredPaths` stays covariant in `'a`.
200    by_path: std::sync::OnceLock<Vec<usize>>,
201}
202
203impl<'a> DiscoveredPaths<'a> {
204    /// Wrap the discovered files of a project.
205    #[must_use]
206    pub const fn new(files: &'a [DiscoveredFile]) -> Self {
207        Self {
208            files,
209            by_path: std::sync::OnceLock::new(),
210        }
211    }
212
213    /// Return `true` when `path` is a discovered file. The lookup matches
214    /// the graph entry lookup: the path as given, then its canonical form.
215    #[must_use]
216    pub fn contains(&self, path: &Path) -> bool {
217        let by_path = self.by_path.get_or_init(|| {
218            let mut indexes: Vec<usize> = (0..self.files.len()).collect();
219            indexes.sort_unstable_by(|a, b| self.files[*a].path.cmp(&self.files[*b].path));
220            indexes
221        });
222        let has = |candidate: &Path| {
223            by_path
224                .binary_search_by(|index| self.files[*index].path.as_path().cmp(candidate))
225                .is_ok()
226        };
227        has(path) || dunce::canonicalize(path).is_ok_and(|canonical| has(&canonical))
228    }
229}
230
231/// What a package entry resolves against: the tsconfig output map of the
232/// package and, when known, the discovered files of the project.
233#[derive(Clone, Copy)]
234struct EntryTargets<'a> {
235    output_map: &'a TsconfigOutputMap,
236    /// `None` keeps the disk check: a `lib/` file on disk stays the entry.
237    discovered: Option<IsDiscovered<'a>>,
238}
239
240fn resolve_entry_path_with_output_map(
241    base: &Path,
242    entry: &str,
243    canonical_root: &Path,
244    source: EntryPointSource,
245    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
246    targets: EntryTargets<'_>,
247) -> Option<EntryPoint> {
248    if entry.contains('*') {
249        return None;
250    }
251
252    if entry_has_parent_dir(entry) {
253        record_or_warn_skipped_entry(
254            skipped_entries.as_deref_mut(),
255            entry,
256            "Skipping entry point containing parent directory traversal",
257        );
258        return None;
259    }
260
261    if let OutputDirEntry::ShortCircuit(result) = resolve_entry_via_output_dir(
262        base,
263        entry,
264        canonical_root,
265        source.clone(),
266        skipped_entries.as_deref_mut(),
267        targets,
268    ) {
269        return result;
270    }
271
272    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source, skipped_entries)
273}
274
275/// Record a skipped entry in the dedup map, or warn when no map is tracking skips.
276fn record_or_warn_skipped_entry(
277    skipped_entries: Option<&mut FxHashMap<String, usize>>,
278    entry: &str,
279    warning: &str,
280) {
281    if let Some(skipped_entries) = skipped_entries {
282        *skipped_entries.entry(entry.to_owned()).or_default() += 1;
283    } else {
284        tracing::warn!(path = %entry, "{warning}");
285    }
286}
287
288/// Outcome of the output-directory mapping step.
289///
290/// `ShortCircuit` means an output-dir branch applied and carries the resolved
291/// entry (which may itself be `None` when validation rejected the candidate);
292/// `Continue` signals that filesystem probing should proceed.
293enum OutputDirEntry {
294    ShortCircuit(Option<EntryPoint>),
295    Continue,
296}
297
298/// Map an output-directory entry back to a source file, short-circuiting resolution.
299fn resolve_entry_via_output_dir(
300    base: &Path,
301    entry: &str,
302    canonical_root: &Path,
303    source: EntryPointSource,
304    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
305    targets: EntryTargets<'_>,
306) -> OutputDirEntry {
307    let resolution = targets
308        .output_map
309        .resolve_source_for_entry(entry, SOURCE_EXTENSIONS);
310    match &resolution {
311        TsconfigOutputResolution::Resolved(source_path) => {
312            return OutputDirEntry::ShortCircuit(validated_entry_point(
313                source_path,
314                canonical_root,
315                entry,
316                source,
317                skipped_entries.as_deref_mut(),
318            ));
319        }
320        TsconfigOutputResolution::ConfiguredButUnresolved => return OutputDirEntry::Continue,
321        TsconfigOutputResolution::ConfiguredWithoutSource
322        | TsconfigOutputResolution::Unconfigured => {}
323    }
324
325    let is_discovered = |path: &Path| {
326        targets
327            .discovered
328            .is_none_or(|is_discovered| is_discovered(path))
329    };
330    if matches!(
331        resolution,
332        TsconfigOutputResolution::ConfiguredWithoutSource
333    ) {
334        // The tsconfig claims the output but names no file, so a bundler can
335        // own it. Only an analyzed source file under `src/` counts.
336        return match output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, is_discovered)
337            .filter(|source_path| is_discovered(source_path))
338        {
339            Some(source_path) => OutputDirEntry::ShortCircuit(validated_entry_point(
340                &source_path,
341                canonical_root,
342                entry,
343                source,
344                skipped_entries,
345            )),
346            None => OutputDirEntry::Continue,
347        };
348    }
349    if let Some(source_path) =
350        output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, is_discovered)
351    {
352        return OutputDirEntry::ShortCircuit(validated_entry_point(
353            &source_path,
354            canonical_root,
355            entry,
356            source,
357            skipped_entries.as_deref_mut(),
358        ));
359    }
360
361    if is_entry_in_output_dir(entry)
362        && let Some(source_path) = try_source_index_fallback(base)
363    {
364        tracing::info!(
365            entry = %entry,
366            fallback = %source_path.display(),
367            "package.json entry resolves to an ignored output directory; falling back to source index"
368        );
369        return OutputDirEntry::ShortCircuit(validated_entry_point(
370            &source_path,
371            canonical_root,
372            entry,
373            source,
374            skipped_entries,
375        ));
376    }
377
378    OutputDirEntry::Continue
379}
380
381/// Probe the filesystem for the entry: exact file, extension fallback, directory
382/// index, then a package-root source-index fallback.
383fn resolve_entry_via_filesystem_probe(
384    base: &Path,
385    entry: &str,
386    canonical_root: &Path,
387    source: EntryPointSource,
388    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
389) -> Option<EntryPoint> {
390    let resolved = base.join(entry);
391
392    if resolved.is_file() {
393        return validated_entry_point(
394            &resolved,
395            canonical_root,
396            entry,
397            source,
398            skipped_entries.as_deref_mut(),
399        );
400    }
401
402    for ext in SOURCE_EXTENSIONS {
403        let with_ext = resolved.with_extension(ext);
404        if with_ext.is_file() {
405            return validated_entry_point(
406                &with_ext,
407                canonical_root,
408                entry,
409                source,
410                skipped_entries.as_deref_mut(),
411            );
412        }
413    }
414
415    if let Some(index_entry) = directory_index_entry(&resolved, SOURCE_EXTENSIONS) {
416        return validated_entry_point(
417            &index_entry,
418            canonical_root,
419            entry,
420            source,
421            skipped_entries.as_deref_mut(),
422        );
423    }
424
425    if is_package_root_index_entry(entry)
426        && let Some(source_path) = try_source_index_fallback(base)
427    {
428        tracing::info!(
429            entry = %entry,
430            fallback = %source_path.display(),
431            "package.json root index entry is missing; falling back to source index"
432        );
433        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
434    }
435    None
436}
437
438fn entry_has_parent_dir(entry: &str) -> bool {
439    Path::new(entry)
440        .components()
441        .any(|component| matches!(component, Component::ParentDir))
442}
443
444fn is_package_root_index_entry(entry: &str) -> bool {
445    let mut components = Path::new(entry)
446        .components()
447        .filter(|component| !matches!(component, Component::CurDir));
448
449    let Some(Component::Normal(file_name)) = components.next() else {
450        return false;
451    };
452    if components.next().is_some() {
453        return false;
454    }
455
456    file_name
457        .to_str()
458        .is_some_and(|name| name == "index" || name.starts_with("index."))
459}
460
461fn validated_entry_point(
462    candidate: &Path,
463    canonical_root: &Path,
464    entry: &str,
465    source: EntryPointSource,
466    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
467) -> Option<EntryPoint> {
468    let canonical_candidate = match dunce::canonicalize(candidate) {
469        Ok(path) => path,
470        Err(err) => {
471            tracing::warn!(
472                path = %candidate.display(),
473                %entry,
474                error = %err,
475                "Skipping entry point that could not be canonicalized"
476            );
477            return None;
478        }
479    };
480
481    if !canonical_candidate.starts_with(canonical_root) {
482        if let Some(skipped_entries) = skipped_entries.as_mut() {
483            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
484        } else {
485            tracing::warn!(
486                path = %candidate.display(),
487                %entry,
488                "Skipping entry point outside project root"
489            );
490        }
491        return None;
492    }
493
494    Some(EntryPoint {
495        path: candidate.to_path_buf(),
496        source,
497    })
498}
499
500/// Resolve a package entry path with the disk check: a `lib/` file on disk
501/// stays the entry, also when the analysis does not include it.
502///
503/// Use [`resolve_entry_path_with_discovered`] when the analyzed files are
504/// known.
505pub fn resolve_entry_path(
506    base: &Path,
507    entry: &str,
508    canonical_root: &Path,
509    source: EntryPointSource,
510) -> Option<EntryPoint> {
511    resolve_entry_path_with_tracking(base, entry, canonical_root, source, None, None)
512}
513
514/// Resolve a package entry path against the analyzed files.
515///
516/// A `lib/` file on disk that `is_discovered` rejects counts as absent, so
517/// the entry maps to the same-stem file under `src/`.
518pub fn resolve_entry_path_with_discovered(
519    base: &Path,
520    entry: &str,
521    canonical_root: &Path,
522    source: EntryPointSource,
523    is_discovered: IsDiscovered<'_>,
524) -> Option<EntryPoint> {
525    resolve_entry_path_with_tracking(
526        base,
527        entry,
528        canonical_root,
529        source,
530        None,
531        Some(is_discovered),
532    )
533}
534/// Conventional source index file stems probed when a package.json entry lives
535/// in an ignored output directory. Ordered by preference.
536const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
537
538/// Return `true` when `entry` contains a known output directory component.
539///
540/// Matches any segment in `OUTPUT_DIRS`, e.g. `./dist/esm2022/index.js` →
541/// `true`, `src/main.ts` → `false`.
542fn is_entry_in_output_dir(entry: &str) -> bool {
543    Path::new(entry).components().any(|c| {
544        matches!(
545            c,
546            std::path::Component::Normal(s)
547                if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
548        )
549    })
550}
551
552/// Probe a package root for a conventional source index file.
553///
554/// Used when `package.json` points at compiled output but the canonical source
555/// entry is a standard TypeScript/JavaScript index file. Tries `src/index`,
556/// `src/main`, `index`, and `main` with each supported source extension, in
557/// that order.
558fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
559    for stem in SOURCE_INDEX_FALLBACK_STEMS {
560        for ext in SOURCE_EXTENSIONS {
561            let candidate = base.join(format!("{stem}.{ext}"));
562            if candidate.is_file() {
563                return Some(candidate);
564            }
565        }
566    }
567    None
568}
569
570/// Default index patterns used when no other entry points are found.
571const DEFAULT_INDEX_PATTERNS: &[&str] = &[
572    "src/index.{ts,tsx,js,jsx}",
573    "src/main.{ts,tsx,js,jsx}",
574    "index.{ts,tsx,js,jsx}",
575    "main.{ts,tsx,js,jsx}",
576];
577
578/// Process-wide matcher for the compile-time default index patterns.
579fn default_index_matchers() -> &'static globset::GlobSet {
580    static MATCHERS: std::sync::OnceLock<globset::GlobSet> = std::sync::OnceLock::new();
581    MATCHERS.get_or_init(|| {
582        let mut builder = globset::GlobSetBuilder::new();
583        for pattern in DEFAULT_INDEX_PATTERNS {
584            if let Ok(glob) = globset::Glob::new(pattern) {
585                builder.add(glob);
586            }
587        }
588        builder.build().unwrap_or_default()
589    })
590}
591
592/// Fall back to default index patterns if no entries were found.
593///
594/// When `ws_filter` is `Some`, only files whose path starts with the given
595/// workspace root are considered (used for workspace-scoped discovery).
596fn apply_default_fallback(
597    files: &[DiscoveredFile],
598    root: &Path,
599    ws_filter: Option<&Path>,
600) -> Vec<EntryPoint> {
601    let default_matchers = default_index_matchers();
602
603    let mut entries = Vec::new();
604    for file in files {
605        if let Some(ws_root) = ws_filter
606            && file.path.strip_prefix(ws_root).is_err()
607        {
608            continue;
609        }
610        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
611        let relative_str = relative.to_string_lossy();
612        if default_matchers.is_match(relative_str.as_ref()) {
613            entries.push(EntryPoint {
614                path: file.path.clone(),
615                source: EntryPointSource::DefaultIndex,
616            });
617        }
618    }
619    entries
620}
621
622/// Compute each file's path relative to `root` as a forward-slash-lossy string.
623fn relative_paths_for(files: &[DiscoveredFile], root: &Path) -> Vec<String> {
624    files
625        .iter()
626        .map(|f| {
627            f.path
628                .strip_prefix(root)
629                .unwrap_or(&f.path)
630                .to_string_lossy()
631                .into_owned()
632        })
633        .collect()
634}
635
636/// Push entries for files matching the user-configured manual entry glob patterns.
637#[expect(
638    clippy::expect_used,
639    reason = "entry glob patterns are validated before entry point discovery"
640)]
641fn push_manual_entry_matches(
642    entries: &mut Vec<EntryPoint>,
643    config: &ResolvedConfig,
644    relative_paths: &[String],
645    files: &[DiscoveredFile],
646) {
647    let mut builder = globset::GlobSetBuilder::new();
648    for pattern in &config.entry_patterns {
649        builder.add(
650            fallow_config::compile_path_glob(pattern)
651                .expect("entry pattern was validated at config load time"),
652        );
653    }
654    let Ok(glob_set) = builder.build() else {
655        return;
656    };
657    if glob_set.is_empty() {
658        return;
659    }
660    for (idx, rel) in relative_paths.iter().enumerate() {
661        if glob_set.is_match(rel) {
662            entries.push(EntryPoint {
663                path: files[idx].path.clone(),
664                source: EntryPointSource::ManualEntry,
665            });
666        }
667    }
668}
669
670/// Push entries derived from a package.json's declared entry points and scripts.
671fn push_package_json_entries(
672    discovery: &mut EntryPointDiscovery,
673    root: &Path,
674    pkg: &PackageJson,
675    canonical_root: &Path,
676    ignored: IgnoredCommandEntries<'_>,
677    workspaces: ScriptWorkspaces<'_>,
678) {
679    let output_map = TsconfigOutputMap::from_project(root);
680    for entry_path in pkg.entry_points() {
681        if let Some(ep) = resolve_entry_path_with_output_map(
682            root,
683            &entry_path,
684            canonical_root,
685            EntryPointSource::PackageJsonMain,
686            Some(&mut discovery.skipped_entries),
687            EntryTargets {
688                output_map: &output_map,
689                discovered: workspaces.discovered,
690            },
691        ) {
692            discovery.entries.push(ep);
693        }
694    }
695
696    let Some(scripts) = &pkg.scripts else {
697        return;
698    };
699    let package = ScriptPackage {
700        root,
701        canonical_root,
702        output_map: &output_map,
703        workspaces,
704    };
705    push_script_entries(
706        &package,
707        scripts,
708        ignored,
709        &mut PackageEntryBuckets {
710            runtime: &mut discovery.entries,
711            support: &mut discovery.support_entries,
712        },
713        &mut discovery.skipped_entries,
714    );
715}
716
717/// The workspace packages that a script command selects by name, by
718/// directory, or all of them (`yarn workspace web node scripts/a.ts`,
719/// `pnpm -r exec tsx scripts/a.ts`), and the project root that a file
720/// outside the calling package resolves against. Also the discovered files
721/// that the package entries of every package resolve against.
722#[derive(Clone, Copy)]
723pub struct ScriptWorkspaces<'a> {
724    /// The workspace packages of the project, the root package included.
725    pub packages: &'a std::sync::Arc<WorkspacePackages>,
726    /// The project root.
727    pub project_root: &'a Path,
728    /// The scripts of each package, by package directory, that a runtime
729    /// script of another package calls ([`workspace_runtime_script_seeds`]).
730    pub runtime_seeds: &'a RuntimeScriptSeeds,
731    /// Return `true` for a file of the analyzed file set. `None` keeps the
732    /// disk check for `lib/` entries.
733    ///
734    /// The predicate must cover the full analyzed file set of the project:
735    /// the same set that the graph later matches entries against. Workspace
736    /// scope and `--changed-since` filter findings after the analysis, so
737    /// they do not narrow this set.
738    pub discovered: Option<IsDiscovered<'a>>,
739}
740
741/// The scripts of each package, by package directory relative to the
742/// project root (empty for the root package), that a runtime script of
743/// another package calls.
744pub type RuntimeScriptSeeds = FxHashMap<String, FxHashSet<String>>;
745
746/// The package whose scripts add entry points.
747struct ScriptPackage<'a> {
748    root: &'a Path,
749    canonical_root: &'a Path,
750    output_map: &'a TsconfigOutputMap,
751    workspaces: ScriptWorkspaces<'a>,
752}
753
754impl ScriptPackage<'_> {
755    /// The directory of the package relative to the project root, with `/`
756    /// separators. `None` for a package outside the project root.
757    fn dir(&self) -> Option<String> {
758        self.root
759            .strip_prefix(self.workspaces.project_root)
760            .ok()
761            .map(|dir| dir.to_string_lossy().replace('\\', "/"))
762    }
763
764    /// Resolve a file reference of a script of the package. A reference
765    /// outside the package, such as `../web/scripts/a.ts` from
766    /// `yarn workspace web node scripts/a.ts`, resolves from the project
767    /// root.
768    fn resolve(
769        &self,
770        file_ref: &str,
771        dir: Option<&str>,
772        skipped_entries: &mut FxHashMap<String, usize>,
773    ) -> Option<EntryPoint> {
774        if entry_has_parent_dir(file_ref)
775            && let Some(project_ref) =
776                dir.and_then(|dir| crate::scripts::normalize_script_entry_pattern(dir, file_ref))
777        {
778            let project_root = self.workspaces.project_root;
779            let canonical_project_root =
780                dunce::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
781            return resolve_entry_path_with_tracking(
782                project_root,
783                &project_ref,
784                &canonical_project_root,
785                EntryPointSource::PackageJsonScript,
786                Some(skipped_entries),
787                self.workspaces.discovered,
788            );
789        }
790        resolve_entry_path_with_output_map(
791            self.root,
792            file_ref,
793            self.canonical_root,
794            EntryPointSource::PackageJsonScript,
795            Some(skipped_entries),
796            EntryTargets {
797                output_map: self.output_map,
798                discovered: self.workspaces.discovered,
799            },
800        )
801    }
802}
803
804/// Push the entries that the scripts of `package` reference. A file that a
805/// runtime script runs is a runtime entry: the `start` lifecycle, and the
806/// scripts that a runtime script of another package calls. Other script
807/// files and every config file are support entries.
808#[expect(
809    clippy::disallowed_types,
810    reason = "API matches serde-deserialized package.json scripts map"
811)]
812fn push_script_entries(
813    package: &ScriptPackage<'_>,
814    scripts: &std::collections::HashMap<String, String>,
815    ignored: IgnoredCommandEntries<'_>,
816    entries: &mut PackageEntryBuckets<'_>,
817    skipped_entries: &mut FxHashMap<String, usize>,
818) {
819    let dir = package.dir();
820    let no_seeds = FxHashSet::default();
821    let seeds = dir
822        .as_deref()
823        .and_then(|dir| package.workspaces.runtime_seeds.get(dir))
824        .unwrap_or(&no_seeds);
825    let runtime_scripts = runtime_package_script_names_with_seeds(scripts, seeds);
826    let catalog = ScriptCatalog::from_scripts(scripts).with_workspaces(
827        std::sync::Arc::clone(package.workspaces.packages),
828        dir.as_deref().unwrap_or_default(),
829    );
830    for (script_name, script_value) in scripts {
831        let refs = package_script_refs(script_value, &catalog, ignored);
832        for file_ref in refs.inheritable {
833            if let Some(ep) = package.resolve(&file_ref, dir.as_deref(), skipped_entries) {
834                if runtime_scripts.contains(script_name) {
835                    entries.runtime.push(ep);
836                } else {
837                    entries.support.push(ep);
838                }
839            }
840        }
841        for config_ref in refs.support {
842            if let Some(ep) = package.resolve(&config_ref, dir.as_deref(), skipped_entries) {
843                entries.support.push(ep);
844            }
845        }
846    }
847}
848
849struct PackageScriptRefs {
850    inheritable: Vec<String>,
851    support: Vec<String>,
852}
853
854/// Collect the file references of one script. `catalog` holds the scripts of
855/// the same package, so a call such as `npm run lint -- src/a.ts` resolves to
856/// the command that the package manager runs.
857fn package_script_refs(
858    script: &str,
859    catalog: &ScriptCatalog,
860    ignored: IgnoredCommandEntries<'_>,
861) -> PackageScriptRefs {
862    let mut inheritable = Vec::new();
863    let mut support = Vec::new();
864    for command in crate::scripts::parse_script_with_catalog(script, catalog) {
865        inheritable.extend_from_slice(command.entry_files(ignored));
866        support.extend(command.config_args);
867    }
868    inheritable.extend(super::parse_scripts::extract_script_file_refs(
869        script,
870        super::parse_scripts::CommandRefContext {
871            ignored,
872            scripts: catalog,
873        },
874    ));
875    support.sort_unstable();
876    support.dedup();
877    inheritable.retain(|path| support.binary_search(path).is_err());
878    inheritable.sort_unstable();
879    inheritable.dedup();
880    PackageScriptRefs {
881        inheritable,
882        support,
883    }
884}
885
886/// npm's start lifecycle is a production execution surface. Other package
887/// scripts are conservatively treated as tooling: their files stay reachable,
888/// but do not prove that a devDependency ships with the application.
889#[expect(
890    clippy::disallowed_types,
891    reason = "API matches serde-deserialized package.json scripts map"
892)]
893fn runtime_package_script_names(
894    scripts: &std::collections::HashMap<String, String>,
895) -> FxHashSet<String> {
896    runtime_package_script_names_with_seeds(scripts, &FxHashSet::default())
897}
898
899#[expect(
900    clippy::disallowed_types,
901    reason = "API matches serde-deserialized package.json scripts map"
902)]
903fn runtime_package_script_names_with_seeds(
904    scripts: &std::collections::HashMap<String, String>,
905    seeds: &FxHashSet<String>,
906) -> FxHashSet<String> {
907    let catalog = ScriptCatalog::from_scripts(scripts);
908    let mut runtime = FxHashSet::default();
909    let mut pending = Vec::new();
910
911    enqueue_runtime_script("start", scripts, &mut runtime, &mut pending);
912    for seed in seeds {
913        enqueue_runtime_script(seed, scripts, &mut runtime, &mut pending);
914    }
915
916    while let Some(name) = pending.pop() {
917        let Some(body) = scripts.get(&name) else {
918            continue;
919        };
920        for referenced in crate::scripts::referenced_package_scripts(body, &catalog) {
921            enqueue_runtime_script(&referenced, scripts, &mut runtime, &mut pending);
922        }
923    }
924
925    runtime
926}
927
928/// The workspace packages of a project for script analysis: the root
929/// package and every workspace package, with their directories relative to
930/// `project_root` and their scripts.
931pub fn collect_workspace_packages(
932    project_root: &Path,
933    root_pkg: Option<&PackageJson>,
934    workspace_pkgs: &[(fallow_config::WorkspaceInfo, PackageJson)],
935) -> std::sync::Arc<WorkspacePackages> {
936    let mut packages = WorkspacePackages::default();
937    if let Some(pkg) = root_pkg {
938        packages.add_root(
939            pkg.name.as_deref().unwrap_or_default(),
940            pkg.scripts.as_ref(),
941        );
942    }
943    for (ws, ws_pkg) in workspace_pkgs {
944        let dir = ws
945            .root
946            .strip_prefix(project_root)
947            .unwrap_or(&ws.root)
948            .to_string_lossy()
949            .replace('\\', "/");
950        packages.add(&ws.name, &dir, ws_pkg.scripts.as_ref());
951    }
952    std::sync::Arc::new(packages)
953}
954
955/// The scripts of each package that a runtime script of another package
956/// calls, such as `serve` for a root `start` script
957/// `pnpm --filter api run serve`, `pnpm -r run serve`, or
958/// `pnpm -C packages/api run serve`. The calls are followed transitively.
959pub fn workspace_runtime_script_seeds(
960    packages: &std::sync::Arc<WorkspacePackages>,
961) -> RuntimeScriptSeeds {
962    let mut seeds = RuntimeScriptSeeds::default();
963    let mut pending: Vec<(String, String)> = packages
964        .iter()
965        .flat_map(|package| {
966            runtime_package_script_names(package.scripts())
967                .into_iter()
968                .map(|name| (package.dir().to_string(), name))
969        })
970        .collect();
971
972    let mut visited = FxHashSet::default();
973    while let Some((dir, name)) = pending.pop() {
974        if !visited.insert((dir.clone(), name.clone())) {
975            continue;
976        }
977        let Some(package) = packages.find_dir(&dir) else {
978            continue;
979        };
980        let Some(body) = package.scripts().get(&name) else {
981            continue;
982        };
983        let catalog = ScriptCatalog::from_scripts(package.scripts())
984            .with_workspaces(std::sync::Arc::clone(packages), &dir);
985        for (target_dir, script) in crate::scripts::referenced_workspace_scripts(body, &catalog) {
986            let Some(target) = packages.find_dir(&target_dir) else {
987                continue;
988            };
989            let target_seeds = FxHashSet::from_iter([script]);
990            for runtime_name in
991                runtime_package_script_names_with_seeds(target.scripts(), &target_seeds)
992            {
993                if seeds
994                    .entry(target_dir.clone())
995                    .or_default()
996                    .insert(runtime_name.clone())
997                {
998                    pending.push((target_dir.clone(), runtime_name));
999                }
1000            }
1001        }
1002    }
1003    seeds
1004}
1005
1006#[expect(
1007    clippy::disallowed_types,
1008    reason = "API matches serde-deserialized package.json scripts map"
1009)]
1010fn enqueue_runtime_script(
1011    name: &str,
1012    scripts: &std::collections::HashMap<String, String>,
1013    runtime: &mut FxHashSet<String>,
1014    pending: &mut Vec<String>,
1015) {
1016    for candidate in [
1017        format!("pre{name}"),
1018        name.to_string(),
1019        format!("post{name}"),
1020    ] {
1021        if scripts.contains_key(&candidate) && runtime.insert(candidate.clone()) {
1022            pending.push(candidate);
1023        }
1024    }
1025}
1026
1027/// Discover the entry points of the root package: manual entries,
1028/// package.json fields and scripts, nested packages when
1029/// `include_nested_package_entries` is set, and the default fallback.
1030pub fn discover_root_entry_points(
1031    config: &ResolvedConfig,
1032    files: &[DiscoveredFile],
1033    root_pkg: Option<&PackageJson>,
1034    include_nested_package_entries: bool,
1035    workspaces: ScriptWorkspaces<'_>,
1036) -> EntryPointDiscovery {
1037    let _span = tracing::info_span!("discover_entry_points").entered();
1038    let mut discovery = EntryPointDiscovery::default();
1039
1040    let relative_paths = relative_paths_for(files, &config.root);
1041    push_manual_entry_matches(&mut discovery.entries, config, &relative_paths, files);
1042
1043    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
1044    if let Some(pkg) = root_pkg {
1045        push_package_json_entries(
1046            &mut discovery,
1047            &config.root,
1048            pkg,
1049            &canonical_root,
1050            IgnoredCommandEntries::new(&config.ignore_command_entries),
1051            workspaces,
1052        );
1053    }
1054
1055    if include_nested_package_entries {
1056        let exports_dirs = root_pkg
1057            .map(PackageJson::exports_subdirectories)
1058            .unwrap_or_default();
1059        let mut nested_entries = PackageEntryBuckets {
1060            runtime: &mut discovery.entries,
1061            support: &mut discovery.support_entries,
1062        };
1063        discover_nested_package_entries(
1064            &mut nested_entries,
1065            &NestedPackageSearch {
1066                canonical_root: &canonical_root,
1067                files,
1068                exports_subdirectories: &exports_dirs,
1069                ignored: IgnoredCommandEntries::new(&config.ignore_command_entries),
1070                workspaces,
1071            },
1072            &mut discovery.skipped_entries,
1073        );
1074    }
1075
1076    if discovery.entries.is_empty() {
1077        discovery.entries = apply_default_fallback(files, &config.root, None);
1078    }
1079
1080    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
1081    discovery.entries.dedup_by(|a, b| a.path == b.path);
1082    discovery
1083        .support_entries
1084        .sort_by(|a, b| a.path.cmp(&b.path));
1085    discovery.support_entries.dedup_by(|a, b| a.path == b.path);
1086
1087    discovery
1088}
1089
1090/// Discover the entry points of every package of a project: the root
1091/// package and each workspace package in `workspaces`, without plugin
1092/// entries. The script entries resolve package selections against the same
1093/// workspace packages and runtime script seeds as the analysis, so this
1094/// returns the package entries that the analysis finds.
1095#[must_use]
1096pub fn discover_entry_points(
1097    config: &ResolvedConfig,
1098    files: &[DiscoveredFile],
1099    workspaces: &[fallow_config::WorkspaceInfo],
1100) -> Vec<EntryPoint> {
1101    use rayon::prelude::*;
1102
1103    let root_pkg = fallow_config::load_dir_package_json(&config.root);
1104    let workspace_pkgs: Vec<(fallow_config::WorkspaceInfo, PackageJson)> = workspaces
1105        .iter()
1106        .filter_map(|ws| {
1107            fallow_config::load_dir_package_json(&ws.root).map(|pkg| (ws.clone(), pkg))
1108        })
1109        .collect();
1110    let packages = collect_workspace_packages(&config.root, root_pkg.as_ref(), &workspace_pkgs);
1111    let runtime_seeds = workspace_runtime_script_seeds(&packages);
1112    let discovered = DiscoveredPaths::new(files);
1113    let is_discovered = |path: &Path| discovered.contains(path);
1114    let script_workspaces = ScriptWorkspaces {
1115        packages: &packages,
1116        project_root: &config.root,
1117        runtime_seeds: &runtime_seeds,
1118        discovered: Some(&is_discovered),
1119    };
1120    let mut discovery = discover_root_entry_points(
1121        config,
1122        files,
1123        root_pkg.as_ref(),
1124        workspaces.is_empty(),
1125        script_workspaces,
1126    );
1127    let pkg_by_root: FxHashMap<&Path, &PackageJson> = workspace_pkgs
1128        .iter()
1129        .map(|(ws, pkg)| (ws.root.as_path(), pkg))
1130        .collect();
1131    let workspace_discovery: Vec<EntryPointDiscovery> = workspaces
1132        .par_iter()
1133        .map(|ws| {
1134            discover_workspace_package_entry_points(
1135                &ws.root,
1136                files,
1137                pkg_by_root.get(ws.root.as_path()).copied(),
1138                IgnoredCommandEntries::new(&config.ignore_command_entries),
1139                script_workspaces,
1140            )
1141        })
1142        .collect();
1143    for workspace in workspace_discovery {
1144        discovery.entries.extend(workspace.entries);
1145        discovery.support_entries.extend(workspace.support_entries);
1146        for (path, count) in workspace.skipped_entries {
1147            *discovery.skipped_entries.entry(path).or_insert(0) += count;
1148        }
1149    }
1150    warn_skipped_entry_summary(&discovery.skipped_entries);
1151    discovery.into_all_entries()
1152}
1153
1154/// Discover entry points from nested package.json files in subdirectories.
1155///
1156/// Scans two sources for sub-packages:
1157/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
1158/// 2. Directories derived from the root package.json `exports` map keys
1159///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
1160///
1161/// For each discovered sub-package with a `package.json`, the `main`, `module`,
1162/// `source`, `exports`, and `bin` fields are treated as entry points.
1163struct PackageEntryBuckets<'a> {
1164    runtime: &'a mut Vec<EntryPoint>,
1165    support: &'a mut Vec<EntryPoint>,
1166}
1167
1168/// The inputs of the nested package search that every package shares.
1169struct NestedPackageSearch<'a> {
1170    canonical_root: &'a Path,
1171    files: &'a [DiscoveredFile],
1172    exports_subdirectories: &'a [String],
1173    ignored: IgnoredCommandEntries<'a>,
1174    workspaces: ScriptWorkspaces<'a>,
1175}
1176
1177fn discover_nested_package_entries(
1178    entries: &mut PackageEntryBuckets<'_>,
1179    search: &NestedPackageSearch<'_>,
1180    skipped_entries: &mut FxHashMap<String, usize>,
1181) {
1182    let root = search.workspaces.project_root;
1183    let mut visited = rustc_hash::FxHashSet::default();
1184
1185    let search_dirs = [
1186        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
1187    ];
1188    for dir_name in &search_dirs {
1189        let search_dir = root.join(dir_name);
1190        if !search_dir.is_dir() {
1191            continue;
1192        }
1193        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
1194            continue;
1195        };
1196        for entry in read_dir.flatten() {
1197            let pkg_dir = entry.path();
1198            if visited.insert(pkg_dir.clone()) {
1199                collect_nested_package_entries(&pkg_dir, entries, search, skipped_entries);
1200            }
1201        }
1202    }
1203
1204    for dir_name in search.exports_subdirectories {
1205        let pkg_dir = root.join(dir_name);
1206        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
1207            collect_nested_package_entries(&pkg_dir, entries, search, skipped_entries);
1208        }
1209    }
1210}
1211
1212/// Collect entry points from a single sub-package directory.
1213fn collect_nested_package_entries(
1214    pkg_dir: &Path,
1215    entries: &mut PackageEntryBuckets<'_>,
1216    search: &NestedPackageSearch<'_>,
1217    skipped_entries: &mut FxHashMap<String, usize>,
1218) {
1219    let Some(pkg) = fallow_config::load_dir_package_json(pkg_dir) else {
1220        return;
1221    };
1222    let output_map = TsconfigOutputMap::from_project(pkg_dir);
1223    for entry_path in pkg.entry_points() {
1224        if entry_path.contains('*') {
1225            expand_wildcard_entries(pkg_dir, &entry_path, search.files, entries.runtime);
1226        } else if let Some(ep) = resolve_entry_path_with_output_map(
1227            pkg_dir,
1228            &entry_path,
1229            search.canonical_root,
1230            EntryPointSource::PackageJsonExports,
1231            Some(&mut *skipped_entries),
1232            EntryTargets {
1233                output_map: &output_map,
1234                discovered: search.workspaces.discovered,
1235            },
1236        ) {
1237            entries.runtime.push(ep);
1238        }
1239    }
1240    if let Some(scripts) = &pkg.scripts {
1241        let package = ScriptPackage {
1242            root: pkg_dir,
1243            canonical_root: search.canonical_root,
1244            output_map: &output_map,
1245            workspaces: search.workspaces,
1246        };
1247        push_script_entries(&package, scripts, search.ignored, entries, skipped_entries);
1248    }
1249}
1250
1251/// Expand a wildcard subpath export target to the discovered files it matches.
1252///
1253/// Follows the Node subpath pattern rule: every `*` in the target stands for
1254/// the same substring, and that substring can contain `/`. So the target
1255/// `./src/*.ts` matches `src/top.ts` and also `src/nested/deep.ts`.
1256fn expand_wildcard_entries(
1257    base: &Path,
1258    pattern: &str,
1259    files: &[DiscoveredFile],
1260    entries: &mut Vec<EntryPoint>,
1261) {
1262    let target = lexical_normalize(&base.join(pattern));
1263    let literal_dir: PathBuf = target
1264        .components()
1265        .take_while(|component| !component.as_os_str().to_string_lossy().contains('*'))
1266        .collect();
1267    let target = target.to_string_lossy().replace('\\', "/");
1268    let parts: Vec<&str> = target.split('*').collect();
1269    for file in files {
1270        if !file.path.starts_with(&literal_dir) {
1271            continue;
1272        }
1273        let path = file.path.to_string_lossy().replace('\\', "/");
1274        if matches_subpath_pattern(&path, &parts) {
1275            entries.push(EntryPoint {
1276                path: file.path.clone(),
1277                source: EntryPointSource::PackageJsonExports,
1278            });
1279        }
1280    }
1281}
1282
1283/// Whether `path` matches a target split at its `*` characters, with one
1284/// non-empty substring in place of every `*`.
1285fn matches_subpath_pattern(path: &str, parts: &[&str]) -> bool {
1286    let [prefix, .., suffix] = parts else {
1287        return false;
1288    };
1289    if !path.starts_with(prefix) || !path.ends_with(suffix) {
1290        return false;
1291    }
1292    let literal_len: usize = parts.iter().map(|part| part.len()).sum();
1293    let stars = parts.len() - 1;
1294    let Some(captured_len) = path.len().checked_sub(literal_len) else {
1295        return false;
1296    };
1297    if captured_len == 0 || captured_len % stars != 0 {
1298        return false;
1299    }
1300    let Some(capture) = path.get(prefix.len()..prefix.len() + captured_len / stars) else {
1301        return false;
1302    };
1303    stars == 1 || parts.join(capture) == path
1304}
1305
1306/// Discover the entry points of a workspace package.
1307#[must_use]
1308pub fn discover_workspace_package_entry_points(
1309    ws_root: &Path,
1310    all_files: &[DiscoveredFile],
1311    pkg: Option<&PackageJson>,
1312    ignored: IgnoredCommandEntries<'_>,
1313    workspaces: ScriptWorkspaces<'_>,
1314) -> EntryPointDiscovery {
1315    let mut discovery = EntryPointDiscovery::default();
1316
1317    if let Some(pkg) = pkg {
1318        let canonical_ws_root =
1319            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
1320        let output_map = TsconfigOutputMap::from_project(ws_root);
1321        for entry_path in pkg.entry_points() {
1322            if entry_path.contains('*') {
1323                expand_wildcard_entries(ws_root, &entry_path, all_files, &mut discovery.entries);
1324            } else if let Some(ep) = resolve_entry_path_with_output_map(
1325                ws_root,
1326                &entry_path,
1327                &canonical_ws_root,
1328                EntryPointSource::PackageJsonMain,
1329                Some(&mut discovery.skipped_entries),
1330                EntryTargets {
1331                    output_map: &output_map,
1332                    discovered: workspaces.discovered,
1333                },
1334            ) {
1335                discovery.entries.push(ep);
1336            }
1337        }
1338
1339        if let Some(scripts) = &pkg.scripts {
1340            let package = ScriptPackage {
1341                root: ws_root,
1342                canonical_root: &canonical_ws_root,
1343                output_map: &output_map,
1344                workspaces,
1345            };
1346            push_script_entries(
1347                &package,
1348                scripts,
1349                ignored,
1350                &mut PackageEntryBuckets {
1351                    runtime: &mut discovery.entries,
1352                    support: &mut discovery.support_entries,
1353                },
1354                &mut discovery.skipped_entries,
1355            );
1356        }
1357    }
1358
1359    if discovery.entries.is_empty() {
1360        discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
1361    }
1362
1363    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
1364    discovery.entries.dedup_by(|a, b| a.path == b.path);
1365    discovery
1366        .support_entries
1367        .sort_by(|a, b| a.path.cmp(&b.path));
1368    discovery.support_entries.dedup_by(|a, b| a.path == b.path);
1369    discovery
1370}
1371
1372/// Discover entry points from plugin results (dynamic config parsing).
1373///
1374/// Converts plugin-discovered patterns and setup files into concrete entry points
1375/// by matching them against the discovered file list.
1376#[must_use]
1377pub fn discover_plugin_entry_points(
1378    plugin_result: &crate::plugins::AggregatedPluginResult,
1379    config: &ResolvedConfig,
1380    files: &[DiscoveredFile],
1381) -> Vec<EntryPoint> {
1382    discover_plugin_entry_point_sets(plugin_result, config, files).all
1383}
1384
1385/// Discover plugin-derived entry points with runtime/test/support roles preserved.
1386#[must_use]
1387pub fn discover_plugin_entry_point_sets(
1388    plugin_result: &crate::plugins::AggregatedPluginResult,
1389    config: &ResolvedConfig,
1390    files: &[DiscoveredFile],
1391) -> CategorizedEntryPoints {
1392    discover_plugin_entry_point_sets_timed(plugin_result, config, files).entries
1393}
1394
1395/// Plugin entry discovery split into the two halves that scale differently:
1396/// compiling the glob set once, and matching it against every discovered file.
1397pub struct TimedPluginEntryPoints {
1398    pub entries: CategorizedEntryPoints,
1399    /// Compiling plugin patterns into a glob set plus per-glob rules. Scales
1400    /// with the number of active plugin patterns, not with project size.
1401    pub build_ms: f64,
1402    /// Matching the compiled set against every discovered file. Scales with
1403    /// file count times pattern count.
1404    pub match_ms: f64,
1405}
1406
1407pub fn discover_plugin_entry_point_sets_timed(
1408    plugin_result: &crate::plugins::AggregatedPluginResult,
1409    config: &ResolvedConfig,
1410    files: &[DiscoveredFile],
1411) -> TimedPluginEntryPoints {
1412    let mut entries = CategorizedEntryPoints::default();
1413
1414    let build_start = std::time::Instant::now();
1415    let relative_paths = relative_paths_for(files, &config.root);
1416    let (glob_set, glob_meta) = build_plugin_glob_meta(plugin_result);
1417    let build_ms = build_start.elapsed().as_secs_f64() * 1000.0;
1418
1419    let match_start = std::time::Instant::now();
1420    if let Some(glob_set) = glob_set.filter(|set| !set.is_empty()) {
1421        match_plugin_entry_files(&mut entries, &glob_set, &glob_meta, &relative_paths, files);
1422    }
1423    let match_ms = match_start.elapsed().as_secs_f64() * 1000.0;
1424
1425    push_plugin_setup_files(&mut entries, &config.root, plugin_result);
1426
1427    TimedPluginEntryPoints {
1428        entries: entries.dedup(),
1429        build_ms,
1430        match_ms,
1431    }
1432}
1433
1434/// Compile plugin entry-pattern and support globs into a glob set plus per-glob metadata.
1435fn build_plugin_glob_meta(
1436    plugin_result: &crate::plugins::AggregatedPluginResult,
1437) -> (Option<globset::GlobSet>, Vec<CompiledEntryRule<'_>>) {
1438    let mut builder = globset::GlobSetBuilder::new();
1439    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
1440    for (rule, pname) in &plugin_result.entry_patterns {
1441        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
1442            builder.add(include);
1443            glob_meta.push(compiled);
1444        }
1445    }
1446    for (pattern, pname) in plugin_result
1447        .discovered_always_used
1448        .iter()
1449        .chain(plugin_result.always_used.iter())
1450        .chain(plugin_result.fixture_patterns.iter())
1451    {
1452        if let Ok(glob) = fallow_config::compile_path_glob(pattern) {
1453            builder.add(glob);
1454            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
1455                &crate::plugins::PathRule::new(pattern.clone()),
1456                "support entry pattern",
1457            ) {
1458                glob_meta.push(CompiledEntryRule {
1459                    path,
1460                    plugin_name: pname,
1461                    role: EntryPointRole::Support,
1462                });
1463            }
1464        }
1465    }
1466    (builder.build().ok(), glob_meta)
1467}
1468
1469/// Match each file against the compiled plugin glob set, categorizing hits by role.
1470fn match_plugin_entry_files(
1471    entries: &mut CategorizedEntryPoints,
1472    glob_set: &globset::GlobSet,
1473    glob_meta: &[CompiledEntryRule<'_>],
1474    relative_paths: &[String],
1475    files: &[DiscoveredFile],
1476) {
1477    for (idx, rel) in relative_paths.iter().enumerate() {
1478        let matches: Vec<usize> = glob_set
1479            .matches(rel)
1480            .into_iter()
1481            .filter(|match_idx| glob_meta[*match_idx].matches(rel))
1482            .collect();
1483        if matches.is_empty() {
1484            continue;
1485        }
1486        let name = glob_meta[matches[0]].plugin_name;
1487        let entry = EntryPoint {
1488            path: files[idx].path.clone(),
1489            source: EntryPointSource::Plugin {
1490                name: name.to_string(),
1491            },
1492        };
1493        categorize_plugin_match(entries, entry, glob_meta, &matches);
1494    }
1495}
1496
1497/// Push a matched entry into the runtime/test/support buckets implied by its roles.
1498fn categorize_plugin_match(
1499    entries: &mut CategorizedEntryPoints,
1500    entry: EntryPoint,
1501    glob_meta: &[CompiledEntryRule<'_>],
1502    matches: &[usize],
1503) {
1504    let mut has_runtime = false;
1505    let mut has_test = false;
1506    let mut has_support = false;
1507    for &match_idx in matches {
1508        match glob_meta[match_idx].role {
1509            EntryPointRole::Runtime => has_runtime = true,
1510            EntryPointRole::Test => has_test = true,
1511            EntryPointRole::Support => has_support = true,
1512        }
1513    }
1514
1515    if has_runtime {
1516        entries.push_runtime(entry.clone());
1517    }
1518    if has_test {
1519        entries.push_test(entry.clone());
1520    }
1521    if has_support || (!has_runtime && !has_test) {
1522        entries.push_support(entry);
1523    }
1524}
1525
1526/// Resolve plugin-declared setup files (with source-extension fallback) into support entries.
1527fn push_plugin_setup_files(
1528    entries: &mut CategorizedEntryPoints,
1529    root: &Path,
1530    plugin_result: &crate::plugins::AggregatedPluginResult,
1531) {
1532    for (setup_file, pname) in &plugin_result.setup_files {
1533        let resolved = resolve_plugin_setup_file(root, setup_file);
1534        if resolved.exists() {
1535            entries.push_support(EntryPoint {
1536                path: resolved,
1537                source: EntryPointSource::Plugin {
1538                    name: pname.clone(),
1539                },
1540            });
1541            continue;
1542        }
1543        for ext in SOURCE_EXTENSIONS {
1544            let with_ext = resolved.with_extension(ext);
1545            if with_ext.exists() {
1546                entries.push_support(EntryPoint {
1547                    path: with_ext,
1548                    source: EntryPointSource::Plugin {
1549                        name: pname.clone(),
1550                    },
1551                });
1552                break;
1553            }
1554        }
1555    }
1556}
1557
1558fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
1559    if is_absolute_path_any_platform(setup_file) {
1560        setup_file.to_path_buf()
1561    } else {
1562        root.join(setup_file)
1563    }
1564}
1565
1566/// Discover entry points from `dynamicallyLoaded` config patterns.
1567///
1568/// Matches the configured glob patterns against the discovered file list and
1569/// marks matching files as entry points so they are never flagged as unused.
1570///
1571/// # Panics
1572///
1573/// Panics if a `dynamicallyLoaded` glob pattern is invalid. Config loading
1574/// validates these patterns first.
1575#[expect(
1576    clippy::expect_used,
1577    reason = "dynamicallyLoaded glob patterns are validated before entry point discovery"
1578)]
1579#[must_use]
1580pub fn discover_dynamically_loaded_entry_points(
1581    config: &ResolvedConfig,
1582    files: &[DiscoveredFile],
1583) -> Vec<EntryPoint> {
1584    if config.dynamically_loaded.is_empty() {
1585        return Vec::new();
1586    }
1587
1588    let mut builder = globset::GlobSetBuilder::new();
1589    for pattern in &config.dynamically_loaded {
1590        builder.add(
1591            fallow_config::compile_path_glob(pattern)
1592                .expect("dynamicallyLoaded pattern was validated at config load time"),
1593        );
1594    }
1595    let Ok(glob_set) = builder.build() else {
1596        return Vec::new();
1597    };
1598    if glob_set.is_empty() {
1599        return Vec::new();
1600    }
1601
1602    let mut entries = Vec::new();
1603    for file in files {
1604        let rel = file
1605            .path
1606            .strip_prefix(&config.root)
1607            .unwrap_or(&file.path)
1608            .to_string_lossy();
1609        if glob_set.is_match(rel.as_ref()) {
1610            entries.push(EntryPoint {
1611                path: file.path.clone(),
1612                source: EntryPointSource::DynamicallyLoaded,
1613            });
1614        }
1615    }
1616    entries
1617}
1618
1619struct CompiledEntryRule<'a> {
1620    path: crate::plugins::CompiledPathRule,
1621    plugin_name: &'a str,
1622    role: EntryPointRole,
1623}
1624
1625impl CompiledEntryRule<'_> {
1626    fn matches(&self, path: &str) -> bool {
1627        self.path.matches(path)
1628    }
1629}
1630
1631fn compile_entry_rule<'a>(
1632    rule: &'a crate::plugins::PathRule,
1633    plugin_name: &'a str,
1634    plugin_result: &'a crate::plugins::AggregatedPluginResult,
1635) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1636    let include = match crate::plugins::entry_rule_glob(&rule.pattern) {
1637        Ok(glob) => glob,
1638        Err(err) => {
1639            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1640            return None;
1641        }
1642    };
1643    let role = plugin_result
1644        .entry_point_roles
1645        .get(plugin_name)
1646        .copied()
1647        .unwrap_or(EntryPointRole::Support);
1648    Some((
1649        include,
1650        CompiledEntryRule {
1651            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1652            plugin_name,
1653            role,
1654        },
1655    ))
1656}
1657
1658/// Pre-compile a set of glob patterns for efficient matching against many paths.
1659#[must_use]
1660pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1661    if patterns.is_empty() {
1662        return None;
1663    }
1664    let mut builder = globset::GlobSetBuilder::new();
1665    for pattern in patterns {
1666        if let Ok(glob) = fallow_config::compile_path_glob(pattern) {
1667            builder.add(glob);
1668        }
1669    }
1670    builder.build().ok()
1671}
1672
1673#[cfg(test)]
1674mod tests {
1675    use super::*;
1676    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1677    use fallow_types::discover::FileId;
1678    use proptest::prelude::*;
1679
1680    proptest! {
1681        /// Valid glob patterns should never panic when compiled via globset.
1682        #[test]
1683        fn glob_patterns_never_panic_on_compile(
1684            prefix in "[a-zA-Z0-9_]{1,20}",
1685            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1686        ) {
1687            let pattern = format!("**/{prefix}*.{ext}");
1688            let result = globset::Glob::new(&pattern);
1689            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1690        }
1691
1692        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
1693        #[test]
1694        fn non_source_extensions_not_in_list(
1695            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1696        ) {
1697            prop_assert!(
1698                !SOURCE_EXTENSIONS.contains(&ext),
1699                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1700            );
1701        }
1702
1703        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
1704        #[test]
1705        fn compile_glob_set_no_panic(
1706            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1707        ) {
1708            let _ = compile_glob_set(&patterns);
1709        }
1710    }
1711
1712    #[test]
1713    fn compile_glob_set_empty_input() {
1714        assert!(
1715            compile_glob_set(&[]).is_none(),
1716            "empty patterns should return None"
1717        );
1718    }
1719
1720    #[test]
1721    fn compile_glob_set_valid_patterns() {
1722        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1723        let set = compile_glob_set(&patterns);
1724        assert!(set.is_some(), "valid patterns should compile");
1725        let set = set.unwrap();
1726        assert!(set.is_match("src/foo.ts"));
1727        assert!(set.is_match("src/bar.js"));
1728        assert!(!set.is_match("src/bar.py"));
1729    }
1730
1731    #[test]
1732    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1733        let patterns = vec!["composables/*.{ts,js}".to_string()];
1734        let set = compile_glob_set(&patterns).expect("pattern should compile");
1735
1736        assert!(set.is_match("composables/useFoo.ts"));
1737        assert!(!set.is_match("composables/nested/useFoo.ts"));
1738    }
1739
1740    #[test]
1741    #[expect(
1742        clippy::too_many_lines,
1743        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
1744    )]
1745    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1746        let dir = tempfile::tempdir().expect("create temp dir");
1747        let root = dir.path();
1748        std::fs::create_dir_all(root.join("src")).unwrap();
1749        std::fs::create_dir_all(root.join("tests")).unwrap();
1750        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1751        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1752        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1753
1754        let config = FallowConfig {
1755            type_aware: fallow_config::TypeAwareConfig::default(),
1756            schema: None,
1757            minimum_version: None,
1758            extends: vec![],
1759            entry: vec![],
1760            ignore_patterns: vec![],
1761            ignore_findings: vec![],
1762            framework: vec![],
1763            workspaces: None,
1764            ignore_dependencies: vec![],
1765            ignore_command_entries: vec![],
1766            ignore_unresolved_imports: vec![],
1767            ignore_exports: vec![],
1768            ignore_catalog_references: vec![],
1769            ignore_dependency_overrides: vec![],
1770            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1771            used_class_members: vec![],
1772            ignore_decorators: vec![],
1773            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1774            circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
1775            duplicates: fallow_config::DuplicatesConfig::default(),
1776            similar_code: fallow_config::SimilarCodeConfig::default(),
1777            mutation: fallow_config::MutationConfig::default(),
1778            health: fallow_config::HealthConfig::default(),
1779            rules: RulesConfig::default(),
1780            boundaries: fallow_config::BoundaryConfig::default(),
1781            production: false.into(),
1782            plugins: vec![],
1783            rule_packs: vec![],
1784            dynamically_loaded: vec![],
1785            overrides: vec![],
1786            regression: None,
1787            audit: fallow_config::AuditConfig::default(),
1788            codeowners: None,
1789            public_packages: vec![],
1790            flags: fallow_config::FlagsConfig::default(),
1791            security: fallow_config::SecurityConfig::default(),
1792            fix: fallow_config::FixConfig::default(),
1793            resolve: fallow_config::ResolveConfig::default(),
1794            sealed: false,
1795            include_entry_exports: false,
1796            show_cascade: false,
1797            auto_imports: false,
1798            fail_on_parse_error: false,
1799            cache: fallow_config::CacheConfig::default(),
1800        }
1801        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1802
1803        let files = vec![
1804            DiscoveredFile {
1805                id: FileId(0),
1806                path: root.join("src/runtime.ts"),
1807                size_bytes: 1,
1808            },
1809            DiscoveredFile {
1810                id: FileId(1),
1811                path: root.join("src/setup.ts"),
1812                size_bytes: 1,
1813            },
1814            DiscoveredFile {
1815                id: FileId(2),
1816                path: root.join("tests/app.test.ts"),
1817                size_bytes: 1,
1818            },
1819        ];
1820
1821        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1822        plugin_result.entry_patterns.push((
1823            crate::plugins::PathRule::new("src/runtime.ts"),
1824            "runtime-plugin".to_string(),
1825        ));
1826        plugin_result.entry_patterns.push((
1827            crate::plugins::PathRule::new("tests/app.test.ts"),
1828            "test-plugin".to_string(),
1829        ));
1830        plugin_result
1831            .always_used
1832            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1833        plugin_result
1834            .entry_point_roles
1835            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1836        plugin_result
1837            .entry_point_roles
1838            .insert("test-plugin".to_string(), EntryPointRole::Test);
1839        plugin_result
1840            .entry_point_roles
1841            .insert("support-plugin".to_string(), EntryPointRole::Support);
1842
1843        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1844
1845        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1846        assert!(
1847            entries.runtime[0].path.ends_with("src/runtime.ts"),
1848            "runtime entry should stay runtime-only"
1849        );
1850        assert_eq!(entries.test.len(), 1, "expected one test entry");
1851        assert!(
1852            entries.test[0].path.ends_with("tests/app.test.ts"),
1853            "test entry should stay test-only"
1854        );
1855        assert_eq!(
1856            entries.all.len(),
1857            3,
1858            "support entries should stay in all entries"
1859        );
1860        assert!(
1861            entries
1862                .all
1863                .iter()
1864                .any(|entry| entry.path.ends_with("src/setup.ts")),
1865            "support entries should remain in the overall entry-point set"
1866        );
1867        assert!(
1868            !entries
1869                .runtime
1870                .iter()
1871                .any(|entry| entry.path.ends_with("src/setup.ts")),
1872            "support entries should not bleed into runtime reachability"
1873        );
1874        assert!(
1875            !entries
1876                .test
1877                .iter()
1878                .any(|entry| entry.path.ends_with("src/setup.ts")),
1879            "support entries should not bleed into test reachability"
1880        );
1881    }
1882
1883    #[test]
1884    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1885        let root = Path::new("/workspace/project");
1886        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1887
1888        assert_eq!(
1889            resolve_plugin_setup_file(root, setup_file),
1890            setup_file.to_path_buf()
1891        );
1892    }
1893
1894    #[cfg(windows)]
1895    #[test]
1896    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1897        let root = Path::new(r"C:\workspace\project");
1898        let setup_file = Path::new(r"/workspace/project/setup.ts");
1899
1900        assert_eq!(
1901            resolve_plugin_setup_file(root, setup_file),
1902            setup_file.to_path_buf()
1903        );
1904    }
1905
1906    #[test]
1907    fn plugin_entry_point_rules_respect_exclusions() {
1908        let dir = tempfile::tempdir().expect("create temp dir");
1909        let root = dir.path();
1910        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1911        std::fs::write(
1912            root.join("app/pages/index.tsx"),
1913            "export default function Page() { return null; }",
1914        )
1915        .unwrap();
1916        std::fs::write(
1917            root.join("app/pages/-helper.ts"),
1918            "export const helper = 1;",
1919        )
1920        .unwrap();
1921
1922        let config = FallowConfig {
1923            type_aware: fallow_config::TypeAwareConfig::default(),
1924            schema: None,
1925            minimum_version: None,
1926            extends: vec![],
1927            entry: vec![],
1928            ignore_patterns: vec![],
1929            ignore_findings: vec![],
1930            framework: vec![],
1931            workspaces: None,
1932            ignore_dependencies: vec![],
1933            ignore_command_entries: vec![],
1934            ignore_unresolved_imports: vec![],
1935            ignore_exports: vec![],
1936            ignore_catalog_references: vec![],
1937            ignore_dependency_overrides: vec![],
1938            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1939            used_class_members: vec![],
1940            ignore_decorators: vec![],
1941            unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1942            circular_dependencies: fallow_config::CircularDependenciesConfig::default(),
1943            duplicates: fallow_config::DuplicatesConfig::default(),
1944            similar_code: fallow_config::SimilarCodeConfig::default(),
1945            mutation: fallow_config::MutationConfig::default(),
1946            health: fallow_config::HealthConfig::default(),
1947            rules: RulesConfig::default(),
1948            boundaries: fallow_config::BoundaryConfig::default(),
1949            production: false.into(),
1950            plugins: vec![],
1951            rule_packs: vec![],
1952            dynamically_loaded: vec![],
1953            overrides: vec![],
1954            regression: None,
1955            audit: fallow_config::AuditConfig::default(),
1956            codeowners: None,
1957            public_packages: vec![],
1958            flags: fallow_config::FlagsConfig::default(),
1959            security: fallow_config::SecurityConfig::default(),
1960            fix: fallow_config::FixConfig::default(),
1961            resolve: fallow_config::ResolveConfig::default(),
1962            sealed: false,
1963            include_entry_exports: false,
1964            show_cascade: false,
1965            auto_imports: false,
1966            fail_on_parse_error: false,
1967            cache: fallow_config::CacheConfig::default(),
1968        }
1969        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1970
1971        let files = vec![
1972            DiscoveredFile {
1973                id: FileId(0),
1974                path: root.join("app/pages/index.tsx"),
1975                size_bytes: 1,
1976            },
1977            DiscoveredFile {
1978                id: FileId(1),
1979                path: root.join("app/pages/-helper.ts"),
1980                size_bytes: 1,
1981            },
1982        ];
1983
1984        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1985        plugin_result.entry_patterns.push((
1986            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1987                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1988            "tanstack-router".to_string(),
1989        ));
1990        plugin_result
1991            .entry_point_roles
1992            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1993
1994        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1995        let entry_paths: Vec<_> = entries
1996            .all
1997            .iter()
1998            .map(|entry| {
1999                entry
2000                    .path
2001                    .strip_prefix(root)
2002                    .unwrap()
2003                    .to_string_lossy()
2004                    .into_owned()
2005            })
2006            .collect();
2007
2008        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
2009        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
2010    }
2011
2012    mod resolve_entry_path_tests {
2013        use super::*;
2014
2015        fn resolve_main(root: &Path, entry: &str) -> Option<PathBuf> {
2016            let canonical = dunce::canonicalize(root).unwrap();
2017            resolve_entry_path(
2018                &canonical,
2019                entry,
2020                &canonical,
2021                EntryPointSource::PackageJsonMain,
2022            )
2023            .map(|entry_point| entry_point.path)
2024        }
2025
2026        #[test]
2027        fn hand_written_lib_entry_wins_over_unrelated_source_index() {
2028            let dir = tempfile::tempdir().expect("create temp dir");
2029            let root = dunce::canonicalize(dir.path()).unwrap();
2030            std::fs::create_dir_all(root.join("lib")).unwrap();
2031            std::fs::create_dir_all(root.join("src")).unwrap();
2032            std::fs::write(root.join("lib/index.js"), "module.exports = {};").unwrap();
2033            std::fs::write(root.join("src/index.ts"), "export {};").unwrap();
2034
2035            assert_eq!(
2036                resolve_main(&root, "./lib/index.js"),
2037                Some(root.join("./lib/index.js"))
2038            );
2039        }
2040
2041        #[test]
2042        fn hand_written_lib_entry_does_not_fall_back_to_source_index() {
2043            let dir = tempfile::tempdir().expect("create temp dir");
2044            let root = dunce::canonicalize(dir.path()).unwrap();
2045            std::fs::create_dir_all(root.join("lib")).unwrap();
2046            std::fs::create_dir_all(root.join("src")).unwrap();
2047            std::fs::write(root.join("lib/foo.js"), "module.exports = {};").unwrap();
2048            std::fs::write(root.join("src/index.ts"), "export {};").unwrap();
2049            std::fs::write(root.join("index.js"), "module.exports = {};").unwrap();
2050
2051            assert_eq!(
2052                resolve_main(&root, "./lib/foo.js"),
2053                Some(root.join("./lib/foo.js"))
2054            );
2055        }
2056
2057        #[test]
2058        fn missing_lib_entry_without_same_stem_source_stays_unresolved() {
2059            let dir = tempfile::tempdir().expect("create temp dir");
2060            let root = dunce::canonicalize(dir.path()).unwrap();
2061            std::fs::create_dir_all(root.join("src")).unwrap();
2062            std::fs::write(root.join("src/index.ts"), "export {};").unwrap();
2063
2064            assert_eq!(resolve_main(&root, "./lib/foo.js"), None);
2065        }
2066
2067        #[test]
2068        fn missing_lib_entry_maps_to_same_stem_source() {
2069            let dir = tempfile::tempdir().expect("create temp dir");
2070            let root = dunce::canonicalize(dir.path()).unwrap();
2071            std::fs::create_dir_all(root.join("src")).unwrap();
2072            std::fs::write(root.join("src/index.ts"), "export {};").unwrap();
2073
2074            assert_eq!(
2075                resolve_main(&root, "./lib/index.mjs"),
2076                Some(root.join("src/index.ts"))
2077            );
2078        }
2079
2080        #[test]
2081        fn resolves_existing_file() {
2082            let dir = tempfile::tempdir().expect("create temp dir");
2083            let src = dir.path().join("src");
2084            std::fs::create_dir_all(&src).unwrap();
2085            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
2086
2087            let canonical = dunce::canonicalize(dir.path()).unwrap();
2088            let result = resolve_entry_path(
2089                dir.path(),
2090                "src/index.ts",
2091                &canonical,
2092                EntryPointSource::PackageJsonMain,
2093            );
2094            assert!(result.is_some(), "should resolve an existing file");
2095            assert!(result.unwrap().path.ends_with("src/index.ts"));
2096        }
2097
2098        #[test]
2099        fn resolves_with_extension_fallback() {
2100            let dir = tempfile::tempdir().expect("create temp dir");
2101            let canonical = dunce::canonicalize(dir.path()).unwrap();
2102            let src = canonical.join("src");
2103            std::fs::create_dir_all(&src).unwrap();
2104            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
2105
2106            let result = resolve_entry_path(
2107                &canonical,
2108                "src/index",
2109                &canonical,
2110                EntryPointSource::PackageJsonMain,
2111            );
2112            assert!(
2113                result.is_some(),
2114                "should resolve via extension fallback when exact path doesn't exist"
2115            );
2116            let ep = result.unwrap();
2117            assert!(
2118                ep.path.to_string_lossy().contains("index.ts"),
2119                "should find index.ts via extension fallback"
2120            );
2121        }
2122
2123        #[test]
2124        fn exact_file_wins_before_directory_index_fallback() {
2125            let dir = tempfile::tempdir().expect("create temp dir");
2126            let canonical = dunce::canonicalize(dir.path()).unwrap();
2127            let scripts = canonical.join("scripts");
2128            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
2129            std::fs::write(
2130                scripts.join("process-messages.js"),
2131                "export const direct = true;",
2132            )
2133            .unwrap();
2134            std::fs::write(
2135                scripts.join("process-messages").join("index.js"),
2136                "export const index = true;",
2137            )
2138            .unwrap();
2139
2140            let result = resolve_entry_path(
2141                &canonical,
2142                "scripts/process-messages.js",
2143                &canonical,
2144                EntryPointSource::PackageJsonScript,
2145            )
2146            .expect("exact file should resolve");
2147
2148            assert!(result.path.ends_with("scripts/process-messages.js"));
2149        }
2150
2151        #[test]
2152        fn extension_fallback_wins_before_directory_index_fallback() {
2153            let dir = tempfile::tempdir().expect("create temp dir");
2154            let canonical = dunce::canonicalize(dir.path()).unwrap();
2155            let scripts = canonical.join("scripts");
2156            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
2157            std::fs::write(
2158                scripts.join("process-messages.ts"),
2159                "export const withExt = true;",
2160            )
2161            .unwrap();
2162            std::fs::write(
2163                scripts.join("process-messages").join("index.js"),
2164                "export const index = true;",
2165            )
2166            .unwrap();
2167
2168            let result = resolve_entry_path(
2169                &canonical,
2170                "scripts/process-messages",
2171                &canonical,
2172                EntryPointSource::PackageJsonScript,
2173            )
2174            .expect("extension fallback should resolve");
2175
2176            assert!(result.path.ends_with("scripts/process-messages.ts"));
2177        }
2178
2179        #[test]
2180        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
2181            let dir = tempfile::tempdir().expect("create temp dir");
2182            let canonical = dunce::canonicalize(dir.path()).unwrap();
2183            let scripts = canonical.join("scripts/process-messages");
2184            std::fs::create_dir_all(&scripts).unwrap();
2185            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
2186
2187            let result = resolve_entry_path(
2188                &canonical,
2189                "scripts/process-messages",
2190                &canonical,
2191                EntryPointSource::PackageJsonScript,
2192            )
2193            .expect("directory index should resolve");
2194
2195            assert!(result.path.ends_with("scripts/process-messages/index.js"));
2196        }
2197
2198        #[test]
2199        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
2200            let dir = tempfile::tempdir().expect("create temp dir");
2201            let canonical = dunce::canonicalize(dir.path()).unwrap();
2202            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
2203            std::fs::write(
2204                canonical.join("scripts/process-messages/index.js"),
2205                "export const index = true;",
2206            )
2207            .unwrap();
2208
2209            for entry in [
2210                "scripts/*",
2211                "https://example.com/scripts/process-messages",
2212                "@scope/package/scripts/process-messages",
2213            ] {
2214                let result = resolve_entry_path(
2215                    &canonical,
2216                    entry,
2217                    &canonical,
2218                    EntryPointSource::PackageJsonScript,
2219                );
2220                assert!(result.is_none(), "{entry} should not resolve");
2221            }
2222        }
2223
2224        #[test]
2225        fn returns_none_for_nonexistent_file() {
2226            let dir = tempfile::tempdir().expect("create temp dir");
2227            let canonical = dunce::canonicalize(dir.path()).unwrap();
2228            let result = resolve_entry_path(
2229                dir.path(),
2230                "does/not/exist.ts",
2231                &canonical,
2232                EntryPointSource::PackageJsonMain,
2233            );
2234            assert!(result.is_none(), "should return None for nonexistent files");
2235        }
2236
2237        #[test]
2238        fn maps_dist_output_to_src() {
2239            let dir = tempfile::tempdir().expect("create temp dir");
2240            let src = dir.path().join("src");
2241            std::fs::create_dir_all(&src).unwrap();
2242            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2243
2244            let dist = dir.path().join("dist");
2245            std::fs::create_dir_all(&dist).unwrap();
2246            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
2247
2248            let canonical = dunce::canonicalize(dir.path()).unwrap();
2249            let result = resolve_entry_path(
2250                dir.path(),
2251                "./dist/utils.js",
2252                &canonical,
2253                EntryPointSource::PackageJsonExports,
2254            );
2255            assert!(result.is_some(), "should resolve dist/ path to src/");
2256            let ep = result.unwrap();
2257            assert!(
2258                ep.path
2259                    .to_string_lossy()
2260                    .replace('\\', "/")
2261                    .contains("src/utils.ts"),
2262                "should map ./dist/utils.js to src/utils.ts"
2263            );
2264        }
2265
2266        #[test]
2267        fn maps_build_output_to_src() {
2268            let dir = tempfile::tempdir().expect("create temp dir");
2269            let canonical = dunce::canonicalize(dir.path()).unwrap();
2270            let src = canonical.join("src");
2271            std::fs::create_dir_all(&src).unwrap();
2272            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
2273
2274            let result = resolve_entry_path(
2275                &canonical,
2276                "./build/index.js",
2277                &canonical,
2278                EntryPointSource::PackageJsonExports,
2279            );
2280            assert!(result.is_some(), "should map build/ output to src/");
2281            let ep = result.unwrap();
2282            assert!(
2283                ep.path
2284                    .to_string_lossy()
2285                    .replace('\\', "/")
2286                    .contains("src/index.tsx"),
2287                "should map ./build/index.js to src/index.tsx"
2288            );
2289        }
2290
2291        #[test]
2292        fn preserves_entry_point_source() {
2293            let dir = tempfile::tempdir().expect("create temp dir");
2294            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
2295
2296            let canonical = dunce::canonicalize(dir.path()).unwrap();
2297            let result = resolve_entry_path(
2298                dir.path(),
2299                "index.ts",
2300                &canonical,
2301                EntryPointSource::PackageJsonScript,
2302            );
2303            assert!(result.is_some());
2304            assert!(
2305                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
2306                "should preserve the source kind"
2307            );
2308        }
2309        #[test]
2310        fn tracks_skipped_entries_without_logging_each_repeat() {
2311            let dir = tempfile::tempdir().expect("create temp dir");
2312            let canonical = dunce::canonicalize(dir.path()).unwrap();
2313            let mut skipped_entries = FxHashMap::default();
2314
2315            let result = resolve_entry_path_with_tracking(
2316                dir.path(),
2317                "../scripts/build.js",
2318                &canonical,
2319                EntryPointSource::PackageJsonScript,
2320                Some(&mut skipped_entries),
2321                None,
2322            );
2323
2324            assert!(result.is_none(), "unsafe entry should be skipped");
2325            assert_eq!(
2326                skipped_entries.get("../scripts/build.js"),
2327                Some(&1),
2328                "warning tracker should count the skipped path"
2329            );
2330        }
2331
2332        #[test]
2333        fn formats_skipped_entry_warning_with_counts() {
2334            let mut skipped_entries = FxHashMap::default();
2335            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
2336            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
2337
2338            let warning =
2339                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
2340
2341            assert_eq!(
2342                warning,
2343                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
2344            );
2345        }
2346
2347        #[test]
2348        fn skipped_entry_summary_dedupes_identical_messages() {
2349            let message = format!(
2350                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
2351                std::process::id()
2352            );
2353            assert!(
2354                should_warn_skipped_entry(&message),
2355                "first occurrence of a message emits"
2356            );
2357            assert!(
2358                !should_warn_skipped_entry(&message),
2359                "identical repeat is suppressed"
2360            );
2361        }
2362
2363        #[test]
2364        fn rejects_parent_dir_escape_for_exact_file() {
2365            let sandbox = tempfile::tempdir().expect("create sandbox");
2366            let root = sandbox.path().join("project");
2367            std::fs::create_dir_all(&root).unwrap();
2368            std::fs::write(
2369                sandbox.path().join("escape.ts"),
2370                "export const escape = true;",
2371            )
2372            .unwrap();
2373
2374            let canonical = dunce::canonicalize(&root).unwrap();
2375            let result = resolve_entry_path(
2376                &root,
2377                "../escape.ts",
2378                &canonical,
2379                EntryPointSource::PackageJsonMain,
2380            );
2381
2382            assert!(
2383                result.is_none(),
2384                "should reject exact paths that escape the root"
2385            );
2386        }
2387
2388        #[test]
2389        fn rejects_parent_dir_escape_via_extension_fallback() {
2390            let sandbox = tempfile::tempdir().expect("create sandbox");
2391            let root = sandbox.path().join("project");
2392            std::fs::create_dir_all(&root).unwrap();
2393            std::fs::write(
2394                sandbox.path().join("escape.ts"),
2395                "export const escape = true;",
2396            )
2397            .unwrap();
2398
2399            let canonical = dunce::canonicalize(&root).unwrap();
2400            let result = resolve_entry_path(
2401                &root,
2402                "../escape",
2403                &canonical,
2404                EntryPointSource::PackageJsonMain,
2405            );
2406
2407            assert!(
2408                result.is_none(),
2409                "should reject extension fallback paths that escape the root"
2410            );
2411        }
2412    }
2413
2414    mod output_to_source_tests {
2415        use super::*;
2416
2417        /// The output mapping without a discovered file set: a `lib/` file on
2418        /// disk counts as present.
2419        fn try_legacy_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
2420            output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, |_| true)
2421        }
2422
2423        #[test]
2424        fn maps_dist_to_src_with_ts_extension() {
2425            let dir = tempfile::tempdir().expect("create temp dir");
2426            let src = dir.path().join("src");
2427            std::fs::create_dir_all(&src).unwrap();
2428            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
2429
2430            let result = try_legacy_output_to_source_path(dir.path(), "./dist/utils.js");
2431            assert!(result.is_some());
2432            assert!(
2433                result
2434                    .unwrap()
2435                    .to_string_lossy()
2436                    .replace('\\', "/")
2437                    .contains("src/utils.ts")
2438            );
2439        }
2440
2441        #[test]
2442        fn returns_none_when_no_source_file_exists() {
2443            let dir = tempfile::tempdir().expect("create temp dir");
2444            let result = try_legacy_output_to_source_path(dir.path(), "./dist/missing.js");
2445            assert!(result.is_none());
2446        }
2447
2448        #[test]
2449        fn ignores_non_output_directories() {
2450            let dir = tempfile::tempdir().expect("create temp dir");
2451            let src = dir.path().join("src");
2452            std::fs::create_dir_all(&src).unwrap();
2453            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2454
2455            let result = try_legacy_output_to_source_path(dir.path(), "./scripts/foo.js");
2456            assert!(result.is_none());
2457        }
2458
2459        #[test]
2460        fn maps_lib_entry_to_source_only_when_lib_target_is_missing() {
2461            let dir = tempfile::tempdir().expect("create temp dir");
2462            let src = dir.path().join("src");
2463            std::fs::create_dir_all(&src).unwrap();
2464            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
2465
2466            assert_eq!(
2467                try_legacy_output_to_source_path(dir.path(), "./lib/foo.js"),
2468                Some(src.join("foo.ts")),
2469                "a missing lib/ build output maps to the same-stem source file"
2470            );
2471
2472            let lib = dir.path().join("lib");
2473            std::fs::create_dir_all(&lib).unwrap();
2474            std::fs::write(lib.join("foo.js"), "exports.f = 1;").unwrap();
2475            assert_eq!(
2476                try_legacy_output_to_source_path(dir.path(), "./lib/foo.js"),
2477                None,
2478                "a lib/ file on disk stays the entry"
2479            );
2480        }
2481
2482        #[test]
2483        fn lib_entry_without_extension_counts_as_present() {
2484            let dir = tempfile::tempdir().expect("create temp dir");
2485            std::fs::create_dir_all(dir.path().join("src")).unwrap();
2486            std::fs::write(dir.path().join("src/index.ts"), "export {};").unwrap();
2487            std::fs::create_dir_all(dir.path().join("lib")).unwrap();
2488            std::fs::write(dir.path().join("lib/index.js"), "module.exports = {};").unwrap();
2489
2490            assert_eq!(
2491                try_legacy_output_to_source_path(dir.path(), "./lib/index"),
2492                None
2493            );
2494            assert_eq!(try_legacy_output_to_source_path(dir.path(), "./lib"), None);
2495        }
2496
2497        #[test]
2498        fn maps_missing_lib_entry_with_nested_prefix() {
2499            let dir = tempfile::tempdir().expect("create temp dir");
2500            let modules_src = dir.path().join("modules/src");
2501            std::fs::create_dir_all(&modules_src).unwrap();
2502            std::fs::write(modules_src.join("x.ts"), "export const x = 1;").unwrap();
2503
2504            assert_eq!(
2505                try_legacy_output_to_source_path(dir.path(), "./modules/lib/x.mjs"),
2506                Some(modules_src.join("x.ts"))
2507            );
2508        }
2509
2510        #[test]
2511        fn bare_missing_lib_entry_does_not_map_to_source_directory() {
2512            let dir = tempfile::tempdir().expect("create temp dir");
2513            std::fs::create_dir_all(dir.path().join("src")).unwrap();
2514
2515            assert_eq!(try_legacy_output_to_source_path(dir.path(), "./lib"), None);
2516        }
2517
2518        #[test]
2519        fn lib_is_not_an_index_fallback_output_dir() {
2520            assert!(!is_entry_in_output_dir("./lib/foo.js"));
2521        }
2522
2523        #[test]
2524        fn maps_nested_output_path_preserving_prefix() {
2525            let dir = tempfile::tempdir().expect("create temp dir");
2526            let modules_src = dir.path().join("modules").join("src");
2527            std::fs::create_dir_all(&modules_src).unwrap();
2528            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
2529
2530            let result = try_legacy_output_to_source_path(dir.path(), "./modules/dist/helper.js");
2531            assert!(result.is_some());
2532            assert!(
2533                result
2534                    .unwrap()
2535                    .to_string_lossy()
2536                    .replace('\\', "/")
2537                    .contains("modules/src/helper.ts")
2538            );
2539        }
2540    }
2541
2542    mod source_index_fallback_tests {
2543        use super::*;
2544
2545        #[test]
2546        fn detects_dist_entry_in_output_dir() {
2547            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
2548            assert!(is_entry_in_output_dir("dist/index.js"));
2549            assert!(is_entry_in_output_dir("./build/index.js"));
2550            assert!(is_entry_in_output_dir("./out/main.js"));
2551            assert!(is_entry_in_output_dir("./esm/index.js"));
2552            assert!(is_entry_in_output_dir("./cjs/index.js"));
2553        }
2554
2555        #[test]
2556        fn rejects_non_output_entry_paths() {
2557            assert!(!is_entry_in_output_dir("./src/index.ts"));
2558            assert!(!is_entry_in_output_dir("src/main.ts"));
2559            assert!(!is_entry_in_output_dir("./index.js"));
2560            assert!(!is_entry_in_output_dir(""));
2561        }
2562
2563        #[test]
2564        fn root_index_entries_are_recognized_for_source_fallback() {
2565            assert!(is_package_root_index_entry("./index.js"));
2566            assert!(is_package_root_index_entry("index.cjs"));
2567            assert!(is_package_root_index_entry("./index.d.ts"));
2568            assert!(!is_package_root_index_entry("./src/index.js"));
2569            assert!(!is_package_root_index_entry("./main.js"));
2570            assert!(!is_package_root_index_entry(""));
2571        }
2572
2573        #[test]
2574        fn rejects_substring_match_for_output_dir() {
2575            assert!(!is_entry_in_output_dir("./distro/index.js"));
2576            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
2577        }
2578
2579        #[test]
2580        fn finds_src_index_ts() {
2581            let dir = tempfile::tempdir().expect("create temp dir");
2582            let src = dir.path().join("src");
2583            std::fs::create_dir_all(&src).unwrap();
2584            let index_path = src.join("index.ts");
2585            std::fs::write(&index_path, "export const a = 1;").unwrap();
2586
2587            let result = try_source_index_fallback(dir.path());
2588            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2589        }
2590
2591        #[test]
2592        fn finds_src_index_tsx_when_ts_missing() {
2593            let dir = tempfile::tempdir().expect("create temp dir");
2594            let src = dir.path().join("src");
2595            std::fs::create_dir_all(&src).unwrap();
2596            let index_path = src.join("index.tsx");
2597            std::fs::write(&index_path, "export default 1;").unwrap();
2598
2599            let result = try_source_index_fallback(dir.path());
2600            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2601        }
2602
2603        #[test]
2604        fn prefers_src_index_over_root_index() {
2605            let dir = tempfile::tempdir().expect("create temp dir");
2606            let src = dir.path().join("src");
2607            std::fs::create_dir_all(&src).unwrap();
2608            let src_index = src.join("index.ts");
2609            std::fs::write(&src_index, "export const a = 1;").unwrap();
2610            let root_index = dir.path().join("index.ts");
2611            std::fs::write(&root_index, "export const b = 2;").unwrap();
2612
2613            let result = try_source_index_fallback(dir.path());
2614            assert_eq!(result.as_deref(), Some(src_index.as_path()));
2615        }
2616
2617        #[test]
2618        fn falls_back_to_src_main() {
2619            let dir = tempfile::tempdir().expect("create temp dir");
2620            let src = dir.path().join("src");
2621            std::fs::create_dir_all(&src).unwrap();
2622            let main_path = src.join("main.ts");
2623            std::fs::write(&main_path, "export const a = 1;").unwrap();
2624
2625            let result = try_source_index_fallback(dir.path());
2626            assert_eq!(result.as_deref(), Some(main_path.as_path()));
2627        }
2628
2629        #[test]
2630        fn falls_back_to_root_index_when_no_src() {
2631            let dir = tempfile::tempdir().expect("create temp dir");
2632            let index_path = dir.path().join("index.js");
2633            std::fs::write(&index_path, "module.exports = {};").unwrap();
2634
2635            let result = try_source_index_fallback(dir.path());
2636            assert_eq!(result.as_deref(), Some(index_path.as_path()));
2637        }
2638
2639        #[test]
2640        fn returns_none_when_nothing_matches() {
2641            let dir = tempfile::tempdir().expect("create temp dir");
2642            let result = try_source_index_fallback(dir.path());
2643            assert!(result.is_none());
2644        }
2645
2646        #[test]
2647        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
2648            let dir = tempfile::tempdir().expect("create temp dir");
2649            let canonical = dunce::canonicalize(dir.path()).unwrap();
2650
2651            let dist_dir = canonical.join("dist").join("esm2022");
2652            std::fs::create_dir_all(&dist_dir).unwrap();
2653            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
2654
2655            let src = canonical.join("src");
2656            std::fs::create_dir_all(&src).unwrap();
2657            let src_index = src.join("index.ts");
2658            std::fs::write(&src_index, "export const x = 1;").unwrap();
2659
2660            let result = resolve_entry_path(
2661                &canonical,
2662                "./dist/esm2022/index.js",
2663                &canonical,
2664                EntryPointSource::PackageJsonMain,
2665            );
2666            assert!(result.is_some());
2667            let entry = result.unwrap();
2668            assert_eq!(entry.path, src_index);
2669        }
2670
2671        #[test]
2672        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
2673            let dir = tempfile::tempdir().expect("create temp dir");
2674            let canonical = dunce::canonicalize(dir.path()).unwrap();
2675
2676            let src_mirror = canonical.join("src").join("esm2022");
2677            std::fs::create_dir_all(&src_mirror).unwrap();
2678            let mirror_index = src_mirror.join("index.ts");
2679            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
2680
2681            let src_index = canonical.join("src").join("index.ts");
2682            std::fs::write(&src_index, "export const y = 2;").unwrap();
2683
2684            let result = resolve_entry_path(
2685                &canonical,
2686                "./dist/esm2022/index.js",
2687                &canonical,
2688                EntryPointSource::PackageJsonMain,
2689            );
2690            assert_eq!(result.map(|e| e.path), Some(mirror_index));
2691        }
2692
2693        /// A tsconfig with `rootDir: "."` claims `dist/` but names no file. A
2694        /// Nest build emits `dist/<path>.js` from `src/<path>.ts`.
2695        #[test]
2696        fn configured_output_without_source_maps_to_an_analyzed_src_file() {
2697            let dir = tempfile::tempdir().expect("create temp dir");
2698            let canonical = dunce::canonicalize(dir.path()).unwrap();
2699            std::fs::write(
2700                canonical.join("tsconfig.build.json"),
2701                r#"{"compilerOptions":{"rootDir":".","outDir":"./dist"}}"#,
2702            )
2703            .unwrap();
2704            std::fs::create_dir_all(canonical.join("src/scripts")).unwrap();
2705            let script = canonical.join("src/scripts/setup.ts");
2706            std::fs::write(&script, "export {};").unwrap();
2707            let entry = "dist/scripts/setup.js";
2708
2709            let analyzed: &(dyn Fn(&Path) -> bool + Sync) = &|_| true;
2710            let result = resolve_entry_path_with_discovered(
2711                &canonical,
2712                entry,
2713                &canonical,
2714                EntryPointSource::PackageJsonScript,
2715                analyzed,
2716            );
2717            assert_eq!(result.map(|e| e.path), Some(script));
2718
2719            let not_analyzed: &(dyn Fn(&Path) -> bool + Sync) = &|_| false;
2720            let result = resolve_entry_path_with_discovered(
2721                &canonical,
2722                entry,
2723                &canonical,
2724                EntryPointSource::PackageJsonScript,
2725                not_analyzed,
2726            );
2727            assert_eq!(
2728                result.map(|e| e.path),
2729                None,
2730                "a source file outside the analyzed files must not become an entry"
2731            );
2732        }
2733
2734        #[test]
2735        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
2736            let dir = tempfile::tempdir().expect("create temp dir");
2737            let canonical = dunce::canonicalize(dir.path()).unwrap();
2738
2739            let src = canonical.join("src");
2740            std::fs::create_dir_all(&src).unwrap();
2741            let src_index = src.join("index.ts");
2742            std::fs::write(&src_index, "export const x = 1;").unwrap();
2743
2744            let result = resolve_entry_path(
2745                &canonical,
2746                "./index.js",
2747                &canonical,
2748                EntryPointSource::PackageJsonMain,
2749            );
2750            assert_eq!(result.map(|entry| entry.path), Some(src_index));
2751        }
2752    }
2753
2754    mod default_fallback_tests {
2755        use super::*;
2756
2757        #[test]
2758        fn finds_src_index_ts_as_fallback() {
2759            let dir = tempfile::tempdir().expect("create temp dir");
2760            let src = dir.path().join("src");
2761            std::fs::create_dir_all(&src).unwrap();
2762            let index_path = src.join("index.ts");
2763            std::fs::write(&index_path, "export const a = 1;").unwrap();
2764
2765            let files = vec![DiscoveredFile {
2766                id: FileId(0),
2767                path: index_path.clone(),
2768                size_bytes: 20,
2769            }];
2770
2771            let entries = apply_default_fallback(&files, dir.path(), None);
2772            assert_eq!(entries.len(), 1);
2773            assert_eq!(entries[0].path, index_path);
2774            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2775        }
2776
2777        #[test]
2778        fn finds_root_index_js_as_fallback() {
2779            let dir = tempfile::tempdir().expect("create temp dir");
2780            let index_path = dir.path().join("index.js");
2781            std::fs::write(&index_path, "module.exports = {};").unwrap();
2782
2783            let files = vec![DiscoveredFile {
2784                id: FileId(0),
2785                path: index_path.clone(),
2786                size_bytes: 21,
2787            }];
2788
2789            let entries = apply_default_fallback(&files, dir.path(), None);
2790            assert_eq!(entries.len(), 1);
2791            assert_eq!(entries[0].path, index_path);
2792        }
2793
2794        #[test]
2795        fn returns_empty_when_no_index_file() {
2796            let dir = tempfile::tempdir().expect("create temp dir");
2797            let other_path = dir.path().join("src").join("utils.ts");
2798
2799            let files = vec![DiscoveredFile {
2800                id: FileId(0),
2801                path: other_path,
2802                size_bytes: 10,
2803            }];
2804
2805            let entries = apply_default_fallback(&files, dir.path(), None);
2806            assert!(
2807                entries.is_empty(),
2808                "non-index files should not match default fallback"
2809            );
2810        }
2811
2812        #[test]
2813        fn workspace_filter_restricts_scope() {
2814            let dir = tempfile::tempdir().expect("create temp dir");
2815            let ws_a = dir.path().join("packages").join("a").join("src");
2816            std::fs::create_dir_all(&ws_a).unwrap();
2817            let ws_b = dir.path().join("packages").join("b").join("src");
2818            std::fs::create_dir_all(&ws_b).unwrap();
2819
2820            let index_a = ws_a.join("index.ts");
2821            let index_b = ws_b.join("index.ts");
2822
2823            let files = vec![
2824                DiscoveredFile {
2825                    id: FileId(0),
2826                    path: index_a.clone(),
2827                    size_bytes: 10,
2828                },
2829                DiscoveredFile {
2830                    id: FileId(1),
2831                    path: index_b,
2832                    size_bytes: 10,
2833                },
2834            ];
2835
2836            let ws_root = dir.path().join("packages").join("a");
2837            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2838            assert_eq!(entries.len(), 1);
2839            assert_eq!(entries[0].path, index_a);
2840        }
2841    }
2842
2843    mod wildcard_entry_tests {
2844        use super::*;
2845
2846        fn discovered_files_under(root: &Path) -> Vec<DiscoveredFile> {
2847            let mut paths = Vec::new();
2848            let mut pending = vec![root.to_path_buf()];
2849            while let Some(dir) = pending.pop() {
2850                for entry in std::fs::read_dir(&dir).unwrap().flatten() {
2851                    let path = entry.path();
2852                    if path.is_dir() {
2853                        pending.push(path);
2854                    } else {
2855                        paths.push(path);
2856                    }
2857                }
2858            }
2859            paths.sort();
2860            paths
2861                .into_iter()
2862                .enumerate()
2863                .map(|(index, path)| DiscoveredFile {
2864                    id: FileId(u32::try_from(index).unwrap()),
2865                    path,
2866                    size_bytes: 1,
2867                })
2868                .collect()
2869        }
2870
2871        fn entry_names(entries: &[EntryPoint], root: &Path) -> Vec<String> {
2872            let mut names: Vec<String> = entries
2873                .iter()
2874                .map(|ep| {
2875                    ep.path
2876                        .strip_prefix(root)
2877                        .unwrap()
2878                        .to_string_lossy()
2879                        .replace('\\', "/")
2880                })
2881                .collect();
2882            names.sort();
2883            names
2884        }
2885
2886        #[test]
2887        fn expands_wildcard_css_entries() {
2888            let dir = tempfile::tempdir().expect("create temp dir");
2889            let themes = dir.path().join("src").join("themes");
2890            std::fs::create_dir_all(&themes).unwrap();
2891            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2892            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2893
2894            let files = discovered_files_under(dir.path());
2895            let mut entries = Vec::new();
2896            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &files, &mut entries);
2897
2898            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2899            let paths: Vec<String> = entries
2900                .iter()
2901                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2902                .collect();
2903            assert!(paths.contains(&"dark.css".to_string()));
2904            assert!(paths.contains(&"light.css".to_string()));
2905            assert!(
2906                entries
2907                    .iter()
2908                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2909            );
2910        }
2911
2912        #[test]
2913        fn wildcard_does_not_match_nonexistent_files() {
2914            let dir = tempfile::tempdir().expect("create temp dir");
2915            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2916
2917            let files = discovered_files_under(dir.path());
2918            let mut entries = Vec::new();
2919            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &files, &mut entries);
2920
2921            assert!(
2922                entries.is_empty(),
2923                "should return empty when no files match the wildcard"
2924            );
2925        }
2926
2927        #[test]
2928        fn wildcard_only_matches_specified_extension() {
2929            let dir = tempfile::tempdir().expect("create temp dir");
2930            let themes = dir.path().join("src").join("themes");
2931            std::fs::create_dir_all(&themes).unwrap();
2932            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2933            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2934
2935            let files = discovered_files_under(dir.path());
2936            let mut entries = Vec::new();
2937            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &files, &mut entries);
2938
2939            assert_eq!(entries.len(), 1, "should only match CSS files");
2940            assert!(
2941                entries[0]
2942                    .path
2943                    .file_name()
2944                    .unwrap()
2945                    .to_string_lossy()
2946                    .ends_with(".css")
2947            );
2948        }
2949
2950        #[test]
2951        fn wildcard_matches_files_in_nested_directories() {
2952            let dir = tempfile::tempdir().expect("create temp dir");
2953            let src = dir.path().join("src");
2954            std::fs::create_dir_all(src.join("nested/deeper")).unwrap();
2955            std::fs::write(src.join("top.ts"), "export {};").unwrap();
2956            std::fs::write(src.join("nested/deep.ts"), "export {};").unwrap();
2957            std::fs::write(src.join("nested/deeper/leaf.ts"), "export {};").unwrap();
2958            std::fs::write(src.join("nested/helper.js"), "export {};").unwrap();
2959            std::fs::write(dir.path().join("outside.ts"), "export {};").unwrap();
2960
2961            let files = discovered_files_under(dir.path());
2962            let mut entries = Vec::new();
2963            expand_wildcard_entries(dir.path(), "./src/*.ts", &files, &mut entries);
2964
2965            assert_eq!(
2966                entry_names(&entries, dir.path()),
2967                vec![
2968                    "src/nested/deep.ts",
2969                    "src/nested/deeper/leaf.ts",
2970                    "src/top.ts"
2971                ],
2972            );
2973        }
2974
2975        #[test]
2976        fn wildcard_without_suffix_matches_every_nested_file() {
2977            let dir = tempfile::tempdir().expect("create temp dir");
2978            let src = dir.path().join("src");
2979            std::fs::create_dir_all(src.join("a/b")).unwrap();
2980            std::fs::write(src.join("top.ts"), "export {};").unwrap();
2981            std::fs::write(src.join("a/b/orphan.tsx"), "export {};").unwrap();
2982            std::fs::write(dir.path().join("index.ts"), "export {};").unwrap();
2983
2984            let files = discovered_files_under(dir.path());
2985            let mut entries = Vec::new();
2986            expand_wildcard_entries(dir.path(), "./src/*", &files, &mut entries);
2987
2988            assert_eq!(
2989                entry_names(&entries, dir.path()),
2990                vec!["src/a/b/orphan.tsx", "src/top.ts"],
2991            );
2992        }
2993
2994        #[test]
2995        fn wildcard_with_several_stars_requires_one_substring() {
2996            let dir = tempfile::tempdir().expect("create temp dir");
2997            std::fs::create_dir_all(dir.path().join("src/button")).unwrap();
2998            std::fs::create_dir_all(dir.path().join("src/card")).unwrap();
2999            std::fs::write(dir.path().join("src/button/button.ts"), "export {};").unwrap();
3000            std::fs::write(dir.path().join("src/card/other.ts"), "export {};").unwrap();
3001
3002            let files = discovered_files_under(dir.path());
3003            let mut entries = Vec::new();
3004            expand_wildcard_entries(dir.path(), "./src/*/*.ts", &files, &mut entries);
3005
3006            assert_eq!(
3007                entry_names(&entries, dir.path()),
3008                vec!["src/button/button.ts"],
3009            );
3010        }
3011    }
3012
3013    fn config_for(root: &Path) -> ResolvedConfig {
3014        FallowConfig::default().resolve(
3015            root.to_path_buf(),
3016            OutputFormat::Human,
3017            1,
3018            true,
3019            true,
3020            None,
3021        )
3022    }
3023
3024    fn discovered(root: &Path, rel: &str, id: u32) -> DiscoveredFile {
3025        DiscoveredFile {
3026            id: FileId(id),
3027            path: root.join(rel),
3028            size_bytes: 1,
3029        }
3030    }
3031
3032    /// The root discovery of a project without workspace packages.
3033    fn discover_entry_points_with_warnings(
3034        config: &ResolvedConfig,
3035        files: &[DiscoveredFile],
3036    ) -> EntryPointDiscovery {
3037        let root_pkg = fallow_config::load_dir_package_json(&config.root);
3038        let packages = collect_workspace_packages(&config.root, root_pkg.as_ref(), &[]);
3039        let runtime_seeds = workspace_runtime_script_seeds(&packages);
3040        discover_root_entry_points(
3041            config,
3042            files,
3043            root_pkg.as_ref(),
3044            true,
3045            ScriptWorkspaces {
3046                packages: &packages,
3047                project_root: &config.root,
3048                runtime_seeds: &runtime_seeds,
3049                discovered: None,
3050            },
3051        )
3052    }
3053
3054    /// The discovery of one workspace package, without other packages.
3055    fn discover_workspace_entry_points_with_warnings(
3056        ws_root: &Path,
3057        config: &ResolvedConfig,
3058        files: &[DiscoveredFile],
3059    ) -> EntryPointDiscovery {
3060        let pkg = fallow_config::load_dir_package_json(ws_root);
3061        let packages = std::sync::Arc::default();
3062        discover_workspace_package_entry_points(
3063            ws_root,
3064            files,
3065            pkg.as_ref(),
3066            IgnoredCommandEntries::new(&config.ignore_command_entries),
3067            ScriptWorkspaces {
3068                packages: &packages,
3069                project_root: &config.root,
3070                runtime_seeds: &RuntimeScriptSeeds::default(),
3071                discovered: None,
3072            },
3073        )
3074    }
3075
3076    fn workspace(root: &Path, dir: &str, name: &str) -> fallow_config::WorkspaceInfo {
3077        fallow_config::WorkspaceInfo {
3078            root: root.join(dir),
3079            name: name.to_string(),
3080            is_internal_dependency: false,
3081        }
3082    }
3083
3084    #[test]
3085    fn root_entry_points_use_package_json_scripts() {
3086        let dir = tempfile::tempdir().expect("tempdir");
3087        let root = dir.path();
3088        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
3089        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
3090            .expect("script file");
3091        std::fs::write(
3092            root.join("package.json"),
3093            r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
3094        )
3095        .expect("package file");
3096        let config = config_for(root);
3097        let files = [discovered(root, "scripts/build.ts", 0)];
3098
3099        let entries = discover_entry_points(&config, &files, &[]);
3100
3101        assert_eq!(entries.len(), 1);
3102        assert!(entries[0].path.ends_with("scripts/build.ts"));
3103        assert!(matches!(
3104            entries[0].source,
3105            EntryPointSource::PackageJsonScript
3106        ));
3107    }
3108
3109    #[test]
3110    fn build_script_is_support_while_default_index_stays_runtime() {
3111        let dir = tempfile::tempdir().expect("tempdir");
3112        let root = dir.path();
3113        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
3114        std::fs::create_dir_all(root.join("src")).expect("src dir");
3115        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
3116            .expect("script file");
3117        std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
3118        std::fs::write(
3119            root.join("package.json"),
3120            r#"{"scripts":{"build":"tsx scripts/build.ts"}}"#,
3121        )
3122        .expect("package file");
3123        let config = config_for(root);
3124        let files = [
3125            discovered(root, "scripts/build.ts", 0),
3126            discovered(root, "src/index.ts", 1),
3127        ];
3128
3129        let discovery = discover_entry_points_with_warnings(&config, &files);
3130
3131        assert_eq!(discovery.entries.len(), 1);
3132        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
3133        assert_eq!(discovery.support_entries.len(), 1);
3134        assert!(
3135            discovery.support_entries[0]
3136                .path
3137                .ends_with("scripts/build.ts")
3138        );
3139    }
3140
3141    #[test]
3142    fn indirect_quoted_start_script_stays_runtime() {
3143        let dir = tempfile::tempdir().expect("tempdir");
3144        let root = dir.path();
3145        std::fs::create_dir_all(root.join("src")).expect("src dir");
3146        std::fs::write(root.join("src/server.ts"), "export const server = true;")
3147            .expect("server file");
3148        std::fs::write(
3149            root.join("package.json"),
3150            r#"{"scripts":{"start":"npm run serve","serve":"node 'src/server.ts'"}}"#,
3151        )
3152        .expect("package file");
3153        let config = config_for(root);
3154        let files = [discovered(root, "src/server.ts", 0)];
3155
3156        let discovery = discover_entry_points_with_warnings(&config, &files);
3157
3158        assert_eq!(discovery.entries.len(), 1);
3159        assert!(discovery.entries[0].path.ends_with("src/server.ts"));
3160        assert!(discovery.support_entries.is_empty());
3161    }
3162
3163    #[test]
3164    fn indirect_start_script_includes_its_lifecycle_hooks() {
3165        let dir = tempfile::tempdir().expect("tempdir");
3166        let root = dir.path();
3167        std::fs::create_dir_all(root.join("src")).expect("src dir");
3168        for file in ["pre.ts", "server.ts", "post.ts"] {
3169            std::fs::write(root.join("src").join(file), "export const value = true;")
3170                .expect("runtime file");
3171        }
3172        std::fs::write(
3173            root.join("package.json"),
3174            r#"{"scripts":{"start":"npm run serve","preserve":"node src/pre.ts","serve":"node src/server.ts","postserve":"node src/post.ts"}}"#,
3175        )
3176        .expect("package file");
3177        let config = config_for(root);
3178        let files = [
3179            discovered(root, "src/pre.ts", 0),
3180            discovered(root, "src/server.ts", 1),
3181            discovered(root, "src/post.ts", 2),
3182        ];
3183
3184        let discovery = discover_entry_points_with_warnings(&config, &files);
3185
3186        assert_eq!(discovery.entries.len(), 3);
3187        assert!(discovery.support_entries.is_empty());
3188    }
3189
3190    #[test]
3191    #[expect(
3192        clippy::disallowed_types,
3193        reason = "test input matches the serde-deserialized package.json scripts map"
3194    )]
3195    fn start_does_not_recursively_invent_hooks_for_lifecycle_hooks() {
3196        let scripts = std::collections::HashMap::from([
3197            ("preprestart".to_string(), "node src/tool.ts".to_string()),
3198            ("prestart".to_string(), "node src/pre.ts".to_string()),
3199            ("start".to_string(), "node src/server.ts".to_string()),
3200            ("poststart".to_string(), "node src/post.ts".to_string()),
3201            ("postpoststart".to_string(), "node src/tool.ts".to_string()),
3202        ]);
3203
3204        let runtime = runtime_package_script_names(&scripts);
3205
3206        assert_eq!(
3207            runtime,
3208            FxHashSet::from_iter([
3209                "prestart".to_string(),
3210                "start".to_string(),
3211                "poststart".to_string(),
3212            ])
3213        );
3214    }
3215
3216    #[test]
3217    fn bun_build_script_remains_support_entry() {
3218        let dir = tempfile::tempdir().expect("tempdir");
3219        let root = dir.path();
3220        std::fs::create_dir_all(root.join("scripts")).expect("scripts dir");
3221        std::fs::write(root.join("scripts/build.ts"), "export const build = true;")
3222            .expect("build file");
3223        std::fs::write(
3224            root.join("package.json"),
3225            r#"{"scripts":{"build":"bun scripts/build.ts"}}"#,
3226        )
3227        .expect("package file");
3228        let config = config_for(root);
3229        let files = [discovered(root, "scripts/build.ts", 0)];
3230
3231        let discovery = discover_entry_points_with_warnings(&config, &files);
3232
3233        assert!(discovery.entries.is_empty());
3234        assert_eq!(discovery.support_entries.len(), 1);
3235    }
3236
3237    #[test]
3238    fn start_script_config_remains_support_only() {
3239        let dir = tempfile::tempdir().expect("tempdir");
3240        let root = dir.path();
3241        std::fs::write(root.join("vite.config.ts"), "export default {};").expect("config file");
3242        std::fs::write(
3243            root.join("package.json"),
3244            r#"{"scripts":{"start":"vite --config vite.config.ts"}}"#,
3245        )
3246        .expect("package file");
3247        let config = config_for(root);
3248        let files = [discovered(root, "vite.config.ts", 0)];
3249
3250        let discovery = discover_entry_points_with_warnings(&config, &files);
3251
3252        assert!(discovery.entries.is_empty());
3253        assert_eq!(discovery.support_entries.len(), 1);
3254        assert!(
3255            discovery.support_entries[0]
3256                .path
3257                .ends_with("vite.config.ts")
3258        );
3259    }
3260
3261    #[test]
3262    fn manifest_entry_remains_runtime_when_build_script_references_same_path() {
3263        let dir = tempfile::tempdir().expect("tempdir");
3264        let root = dir.path();
3265        std::fs::create_dir_all(root.join("src")).expect("src dir");
3266        std::fs::write(root.join("src/index.ts"), "export const app = true;").expect("index file");
3267        std::fs::write(
3268            root.join("package.json"),
3269            r#"{"main":"src/index.ts","scripts":{"build":"tsx src/index.ts"}}"#,
3270        )
3271        .expect("package file");
3272        let config = config_for(root);
3273        let files = [discovered(root, "src/index.ts", 0)];
3274
3275        let discovery = discover_entry_points_with_warnings(&config, &files);
3276
3277        assert_eq!(discovery.entries.len(), 1);
3278        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
3279    }
3280
3281    #[test]
3282    fn workspace_build_script_is_support_with_runtime_fallback() {
3283        let dir = tempfile::tempdir().expect("tempdir");
3284        let root = dir.path();
3285        let workspace = root.join("packages/app");
3286        std::fs::create_dir_all(workspace.join("scripts")).expect("scripts dir");
3287        std::fs::create_dir_all(workspace.join("src")).expect("src dir");
3288        std::fs::write(
3289            workspace.join("scripts/build.ts"),
3290            "export const build = true;",
3291        )
3292        .expect("script file");
3293        std::fs::write(workspace.join("src/index.ts"), "export const app = true;")
3294            .expect("index file");
3295        std::fs::write(
3296            workspace.join("package.json"),
3297            r#"{"name":"app","scripts":{"build":"tsx scripts/build.ts"}}"#,
3298        )
3299        .expect("package file");
3300        let files = [
3301            discovered(root, "packages/app/scripts/build.ts", 0),
3302            discovered(root, "packages/app/src/index.ts", 1),
3303        ];
3304
3305        let discovery =
3306            discover_workspace_entry_points_with_warnings(&workspace, &config_for(root), &files);
3307
3308        assert_eq!(discovery.entries.len(), 1);
3309        assert!(discovery.entries[0].path.ends_with("src/index.ts"));
3310        assert_eq!(discovery.support_entries.len(), 1);
3311        assert!(
3312            discovery.support_entries[0]
3313                .path
3314                .ends_with("scripts/build.ts")
3315        );
3316    }
3317
3318    #[test]
3319    fn workspace_script_selected_by_root_start_is_runtime() {
3320        let dir = tempfile::tempdir().expect("tempdir");
3321        let root = dir.path();
3322        let workspace = root.join("packages/api");
3323        std::fs::create_dir_all(workspace.join("src")).expect("src dir");
3324        std::fs::write(workspace.join("src/server.ts"), "export const app = true;")
3325            .expect("server file");
3326        let workspace_pkg: PackageJson = serde_json::from_str(
3327            r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
3328        )
3329        .expect("workspace package");
3330        let files = [discovered(root, "packages/api/src/server.ts", 0)];
3331        let runtime_seeds = RuntimeScriptSeeds::from_iter([(
3332            "packages/api".to_string(),
3333            FxHashSet::from_iter(["serve".to_string()]),
3334        )]);
3335
3336        let packages = std::sync::Arc::default();
3337        let discovery = discover_workspace_package_entry_points(
3338            &workspace,
3339            &files,
3340            Some(&workspace_pkg),
3341            IgnoredCommandEntries::NONE,
3342            ScriptWorkspaces {
3343                packages: &packages,
3344                project_root: root,
3345                runtime_seeds: &runtime_seeds,
3346                discovered: None,
3347            },
3348        );
3349
3350        assert_eq!(discovery.entries.len(), 1);
3351        assert!(discovery.entries[0].path.ends_with("src/server.ts"));
3352        assert!(discovery.support_entries.is_empty());
3353    }
3354
3355    #[test]
3356    fn scripts_that_select_workspace_packages_add_entries_in_each_pass() {
3357        let dir = tempfile::tempdir().expect("tempdir");
3358        let root = dir.path();
3359        for file in [
3360            "packages/web/scripts/root-start.ts",
3361            "packages/web/scripts/api-start.ts",
3362            "packages/web/scripts/gen.ts",
3363        ] {
3364            let path = root.join(file);
3365            std::fs::create_dir_all(path.parent().expect("parent")).expect("dir");
3366            std::fs::write(path, "export const run = true;").expect("script file");
3367        }
3368        let root_pkg: PackageJson = serde_json::from_str(
3369            r#"{"scripts":{"start":"yarn workspace web node scripts/root-start.ts"}}"#,
3370        )
3371        .expect("root package");
3372        let api_pkg: PackageJson = serde_json::from_str(
3373            r#"{
3374                "name": "api",
3375                "scripts": {
3376                    "start": "yarn workspace web node scripts/api-start.ts",
3377                    "gen": "pnpm -r exec tsx scripts/gen.ts"
3378                }
3379            }"#,
3380        )
3381        .expect("api package");
3382        let mut packages = crate::scripts::WorkspacePackages::default();
3383        packages.add("web", "packages/web", None);
3384        packages.add("api", "packages/api", api_pkg.scripts.as_ref());
3385        let packages = std::sync::Arc::new(packages);
3386        let runtime_seeds = RuntimeScriptSeeds::default();
3387        let workspaces = ScriptWorkspaces {
3388            packages: &packages,
3389            project_root: root,
3390            runtime_seeds: &runtime_seeds,
3391            discovered: None,
3392        };
3393        let config = config_for(root);
3394
3395        let root_discovery =
3396            discover_root_entry_points(&config, &[], Some(&root_pkg), false, workspaces);
3397        assert!(
3398            root_discovery
3399                .entries
3400                .iter()
3401                .any(|entry| entry.path.ends_with("packages/web/scripts/root-start.ts")),
3402            "a root start script that selects a package adds a runtime entry: {:?}",
3403            root_discovery.entries
3404        );
3405
3406        let api_discovery = discover_workspace_package_entry_points(
3407            &root.join("packages/api"),
3408            &[],
3409            Some(&api_pkg),
3410            IgnoredCommandEntries::NONE,
3411            workspaces,
3412        );
3413        assert!(
3414            api_discovery
3415                .entries
3416                .iter()
3417                .any(|entry| entry.path.ends_with("packages/web/scripts/api-start.ts")),
3418            "a package start script that selects another package adds a runtime entry: {:?}",
3419            api_discovery.entries
3420        );
3421        assert!(
3422            api_discovery
3423                .support_entries
3424                .iter()
3425                .any(|entry| entry.path.ends_with("packages/web/scripts/gen.ts")),
3426            "`pnpm -r exec` adds the file of each package where it exists: {:?}",
3427            api_discovery.support_entries
3428        );
3429        assert!(
3430            api_discovery.skipped_entries.is_empty(),
3431            "a file outside the calling package resolves from the project root: {:?}",
3432            api_discovery.skipped_entries
3433        );
3434    }
3435
3436    #[test]
3437    fn workspace_script_selection_does_not_promote_same_named_root_script() {
3438        let dir = tempfile::tempdir().expect("tempdir");
3439        let root = dir.path();
3440        let root_pkg: PackageJson = serde_json::from_str(
3441            r#"{
3442                "scripts": {
3443                    "start": "pnpm --filter=@scope/api run serve",
3444                    "serve": "node src/unrelated.ts"
3445                }
3446            }"#,
3447        )
3448        .expect("root package");
3449        let api_pkg: PackageJson = serde_json::from_str(
3450            r#"{"name":"@scope/api","scripts":{"serve":"node src/server.ts"}}"#,
3451        )
3452        .expect("api package");
3453        let workspace_pkgs = vec![(workspace(root, "packages/api", "@scope/api"), api_pkg)];
3454        let scripts = root_pkg.scripts.as_ref().expect("root scripts");
3455
3456        let runtime = runtime_package_script_names(scripts);
3457        let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3458        let workspace_seeds = workspace_runtime_script_seeds(&packages);
3459
3460        assert_eq!(runtime, FxHashSet::from_iter(["start".to_string()]));
3461        assert_eq!(
3462            workspace_seeds,
3463            RuntimeScriptSeeds::from_iter([(
3464                "packages/api".to_string(),
3465                FxHashSet::from_iter(["serve".to_string()])
3466            )])
3467        );
3468    }
3469
3470    #[test]
3471    fn workspace_runtime_script_seeds_follow_transitive_workspace_calls() {
3472        let dir = tempfile::tempdir().expect("tempdir");
3473        let root = dir.path();
3474        let root_pkg: PackageJson =
3475            serde_json::from_str(r#"{"scripts":{"start":"pnpm --filter @scope/api run serve"}}"#)
3476                .expect("root package");
3477        let api_pkg: PackageJson = serde_json::from_str(
3478            r#"{"name":"@scope/api","scripts":{"serve":"npm --workspace ../worker run work"}}"#,
3479        )
3480        .expect("api package");
3481        let worker_pkg: PackageJson = serde_json::from_str(
3482            r#"{"name":"@scope/worker","scripts":{"prework":"node pre.ts","work":"node worker.ts"}}"#,
3483        )
3484        .expect("worker package");
3485        let workspace_pkgs = vec![
3486            (workspace(root, "packages/api", "@scope/api"), api_pkg),
3487            (
3488                workspace(root, "packages/worker", "@scope/worker"),
3489                worker_pkg,
3490            ),
3491        ];
3492
3493        let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3494        let seeds = workspace_runtime_script_seeds(&packages);
3495
3496        assert_eq!(
3497            seeds.get("packages/api"),
3498            Some(&FxHashSet::from_iter(["serve".to_string()]))
3499        );
3500        assert_eq!(
3501            seeds.get("packages/worker"),
3502            Some(&FxHashSet::from_iter([
3503                "prework".to_string(),
3504                "work".to_string(),
3505            ]))
3506        );
3507    }
3508
3509    #[test]
3510    fn every_selection_form_seeds_the_runtime_scripts_of_the_selected_packages() {
3511        let dir = tempfile::tempdir().expect("tempdir");
3512        let root = dir.path();
3513        let web_pkg: PackageJson = serde_json::from_str(
3514            r#"{"name":"web","scripts":{"preserve":"node pre.ts","serve":"node web.ts"}}"#,
3515        )
3516        .expect("web package");
3517        let api_pkg: PackageJson =
3518            serde_json::from_str(r#"{"name":"api","scripts":{"serve":"node api.ts"}}"#)
3519                .expect("api package");
3520        let workspace_pkgs = vec![
3521            (workspace(root, "packages/web", "web"), web_pkg),
3522            (workspace(root, "packages/api", "api"), api_pkg),
3523        ];
3524        let web_serve = FxHashSet::from_iter(["preserve".to_string(), "serve".to_string()]);
3525        let api_serve = FxHashSet::from_iter(["serve".to_string()]);
3526        for (start, expected) in [
3527            (
3528                "pnpm -r run serve",
3529                vec![("packages/api", &api_serve), ("packages/web", &web_serve)],
3530            ),
3531            (
3532                "pnpm -C packages/web run serve",
3533                vec![("packages/web", &web_serve)],
3534            ),
3535            (
3536                "pnpm --dir=packages/web serve",
3537                vec![("packages/web", &web_serve)],
3538            ),
3539            (
3540                "npm --prefix packages/web run serve",
3541                vec![("packages/web", &web_serve)],
3542            ),
3543            (
3544                "yarn --cwd packages/web serve",
3545                vec![("packages/web", &web_serve)],
3546            ),
3547            (
3548                "yarn workspaces foreach -A run serve",
3549                vec![("packages/api", &api_serve), ("packages/web", &web_serve)],
3550            ),
3551            (
3552                "pnpm --filter './packages/*' --filter '!api' run serve",
3553                vec![("packages/web", &web_serve)],
3554            ),
3555        ] {
3556            let root_pkg: PackageJson =
3557                serde_json::from_str(&format!(r#"{{"scripts":{{"start":"{start}"}}}}"#))
3558                    .expect("root package");
3559            let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3560            let seeds = workspace_runtime_script_seeds(&packages);
3561            let expected: RuntimeScriptSeeds = expected
3562                .into_iter()
3563                .map(|(dir, names)| (dir.to_string(), names.clone()))
3564                .collect();
3565            assert_eq!(seeds, expected, "`{start}`");
3566        }
3567    }
3568
3569    #[test]
3570    fn a_workspace_start_script_seeds_a_script_of_the_root_package() {
3571        let dir = tempfile::tempdir().expect("tempdir");
3572        let root = dir.path();
3573        let root_pkg: PackageJson =
3574            serde_json::from_str(r#"{"name":"monorepo","scripts":{"serve":"node server.ts"}}"#)
3575                .expect("root package");
3576        let web_pkg: PackageJson =
3577            serde_json::from_str(r#"{"name":"web","scripts":{"start":"pnpm -w run serve"}}"#)
3578                .expect("web package");
3579        let workspace_pkgs = vec![(workspace(root, "packages/web", "web"), web_pkg)];
3580
3581        let packages = collect_workspace_packages(root, Some(&root_pkg), &workspace_pkgs);
3582        let seeds = workspace_runtime_script_seeds(&packages);
3583
3584        assert_eq!(
3585            seeds,
3586            RuntimeScriptSeeds::from_iter([(
3587                String::new(),
3588                FxHashSet::from_iter(["serve".to_string()])
3589            )])
3590        );
3591    }
3592
3593    #[test]
3594    fn root_entry_points_fall_back_to_default_index() {
3595        let dir = tempfile::tempdir().expect("tempdir");
3596        let root = dir.path();
3597        std::fs::create_dir_all(root.join("src")).expect("src dir");
3598        std::fs::write(root.join("src/index.ts"), "export const main = true;").expect("index file");
3599        let config = config_for(root);
3600        let files = [discovered(root, "src/index.ts", 0)];
3601
3602        let entries = discover_entry_points(&config, &files, &[]);
3603
3604        assert_eq!(entries.len(), 1);
3605        assert!(entries[0].path.ends_with("src/index.ts"));
3606        assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
3607    }
3608
3609    #[test]
3610    fn default_index_matchers_preserve_patterns_and_are_shared() {
3611        let matchers = default_index_matchers();
3612        for path in [
3613            "src/index.ts",
3614            "src/index.tsx",
3615            "src/index.js",
3616            "src/index.jsx",
3617            "src/main.ts",
3618            "src/main.tsx",
3619            "src/main.js",
3620            "src/main.jsx",
3621            "index.ts",
3622            "index.tsx",
3623            "index.js",
3624            "index.jsx",
3625            "main.ts",
3626            "main.tsx",
3627            "main.js",
3628            "main.jsx",
3629        ] {
3630            assert!(
3631                matchers.is_match(path),
3632                "default index did not match {path}"
3633            );
3634        }
3635        for path in [
3636            "src/index.mts",
3637            "src/nested/index.ts",
3638            "packages/app/src/index.ts",
3639            "custom-entry.ts",
3640        ] {
3641            assert!(!matchers.is_match(path), "non-default index matched {path}");
3642        }
3643
3644        std::thread::scope(|scope| {
3645            let handles = std::iter::repeat_with(|| scope.spawn(default_index_matchers))
3646                .take(4)
3647                .collect::<Vec<_>>();
3648            for handle in handles {
3649                let from_thread = handle.join().expect("fallback matcher thread");
3650                assert!(std::ptr::eq(matchers, from_thread));
3651            }
3652        });
3653    }
3654
3655    #[test]
3656    fn workspace_entry_points_use_workspace_package_json() {
3657        let dir = tempfile::tempdir().expect("tempdir");
3658        let root = dir.path();
3659        let ws_root = root.join("packages/app");
3660        std::fs::create_dir_all(ws_root.join("src")).expect("workspace src dir");
3661        std::fs::write(ws_root.join("src/main.ts"), "export const main = true;")
3662            .expect("workspace main");
3663        std::fs::write(ws_root.join("package.json"), r#"{"main":"src/main.ts"}"#)
3664            .expect("workspace package file");
3665        let config = config_for(root);
3666        let files = [discovered(root, "packages/app/src/main.ts", 0)];
3667
3668        let entries =
3669            discover_entry_points(&config, &files, &[workspace(root, "packages/app", "app")]);
3670
3671        assert_eq!(entries.len(), 1);
3672        assert!(entries[0].path.ends_with("packages/app/src/main.ts"));
3673        assert!(matches!(
3674            entries[0].source,
3675            EntryPointSource::PackageJsonMain
3676        ));
3677    }
3678}