Skip to main content

fallow_core/discover/
entry_points.rs

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