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