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