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