Skip to main content

fallow_core/
discover.rs

1use std::path::{Path, PathBuf};
2
3use fallow_config::{FrameworkDetection, PackageJson, ResolvedConfig};
4use ignore::WalkBuilder;
5
6/// A discovered source file on disk.
7#[derive(Debug, Clone)]
8pub struct DiscoveredFile {
9    /// Unique file index.
10    pub id: FileId,
11    /// Absolute path.
12    pub path: PathBuf,
13    /// File size in bytes (for sorting largest-first).
14    pub size_bytes: u64,
15}
16
17/// Compact file identifier.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct FileId(pub u32);
20
21/// An entry point into the module graph.
22#[derive(Debug, Clone)]
23pub struct EntryPoint {
24    pub path: PathBuf,
25    pub source: EntryPointSource,
26}
27
28/// Where an entry point was discovered from.
29#[derive(Debug, Clone)]
30pub enum EntryPointSource {
31    PackageJsonMain,
32    PackageJsonModule,
33    PackageJsonExports,
34    PackageJsonBin,
35    PackageJsonScript,
36    FrameworkRule { name: String },
37    TestFile,
38    DefaultIndex,
39    ManualEntry,
40}
41
42const SOURCE_EXTENSIONS: &[&str] = &[
43    "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "vue", "svelte",
44];
45
46/// Discover all source files in the project.
47pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
48    let _span = tracing::info_span!("discover_files").entered();
49
50    let mut types_builder = ignore::types::TypesBuilder::new();
51    for ext in SOURCE_EXTENSIONS {
52        types_builder
53            .add("source", &format!("*.{ext}"))
54            .expect("valid glob");
55    }
56    types_builder.select("source");
57    let types = types_builder.build().expect("valid types");
58
59    let walker = WalkBuilder::new(&config.root)
60        .hidden(true)
61        .git_ignore(true)
62        .git_global(true)
63        .git_exclude(true)
64        .types(types)
65        .threads(config.threads)
66        .build();
67
68    let mut files: Vec<DiscoveredFile> = walker
69        .filter_map(|entry| entry.ok())
70        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
71        .filter(|entry| !config.ignore_patterns.is_match(entry.path()))
72        .enumerate()
73        .map(|(idx, entry)| {
74            let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
75            DiscoveredFile {
76                id: FileId(idx as u32),
77                path: entry.into_path(),
78                size_bytes,
79            }
80        })
81        .collect();
82
83    // Sort largest files first for better rayon work-stealing, with path as tiebreaker for determinism
84    files.sort_unstable_by(|a, b| {
85        b.size_bytes
86            .cmp(&a.size_bytes)
87            .then_with(|| a.path.cmp(&b.path))
88    });
89
90    // Re-assign IDs after sorting
91    for (idx, file) in files.iter_mut().enumerate() {
92        file.id = FileId(idx as u32);
93    }
94
95    files
96}
97
98/// Resolve a path relative to a base directory, with security check and extension fallback.
99///
100/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
101/// trying source extensions as fallback when the exact path doesn't exist.
102fn resolve_entry_path(
103    base: &Path,
104    entry: &str,
105    canonical_root: &Path,
106    source: EntryPointSource,
107) -> Option<EntryPoint> {
108    let resolved = base.join(entry);
109    // Security: ensure resolved path stays within the allowed root
110    let canonical_resolved = resolved.canonicalize().unwrap_or(resolved.clone());
111    if !canonical_resolved.starts_with(canonical_root) {
112        tracing::warn!(path = %entry, "Skipping entry point outside project root");
113        return None;
114    }
115    if resolved.exists() {
116        return Some(EntryPoint {
117            path: resolved,
118            source,
119        });
120    }
121    // Try with source extensions
122    for ext in SOURCE_EXTENSIONS {
123        let with_ext = resolved.with_extension(ext);
124        if with_ext.exists() {
125            return Some(EntryPoint {
126                path: with_ext,
127                source,
128            });
129        }
130    }
131    None
132}
133
134/// Pre-compile entry point and always_used glob matchers from a framework rule.
135fn compile_rule_matchers(
136    rule: &fallow_config::FrameworkRule,
137) -> (Vec<globset::GlobMatcher>, Vec<globset::GlobMatcher>) {
138    let entry_matchers: Vec<globset::GlobMatcher> = rule
139        .entry_points
140        .iter()
141        .filter_map(|ep| {
142            globset::Glob::new(&ep.pattern)
143                .ok()
144                .map(|g| g.compile_matcher())
145        })
146        .collect();
147
148    let always_matchers: Vec<globset::GlobMatcher> = rule
149        .always_used
150        .iter()
151        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
152        .collect();
153
154    (entry_matchers, always_matchers)
155}
156
157/// Default index patterns used when no other entry points are found.
158const DEFAULT_INDEX_PATTERNS: &[&str] = &[
159    "src/index.{ts,tsx,js,jsx}",
160    "src/main.{ts,tsx,js,jsx}",
161    "index.{ts,tsx,js,jsx}",
162    "main.{ts,tsx,js,jsx}",
163];
164
165/// Fall back to default index patterns if no entries were found.
166///
167/// When `ws_filter` is `Some`, only files whose canonical path starts with the given
168/// canonical workspace root are considered (used for workspace-scoped discovery).
169fn apply_default_fallback(
170    files: &[DiscoveredFile],
171    root: &Path,
172    ws_filter: Option<&Path>,
173) -> Vec<EntryPoint> {
174    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
175        .iter()
176        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
177        .collect();
178
179    let mut entries = Vec::new();
180    for file in files {
181        if let Some(canonical_ws) = ws_filter {
182            let canonical_file = file.path.canonicalize().unwrap_or(file.path.clone());
183            if !canonical_file.starts_with(canonical_ws) {
184                continue;
185            }
186        }
187        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
188        let relative_str = relative.to_string_lossy();
189        if default_matchers
190            .iter()
191            .any(|m| m.is_match(relative_str.as_ref()))
192        {
193            entries.push(EntryPoint {
194                path: file.path.clone(),
195                source: EntryPointSource::DefaultIndex,
196            });
197        }
198    }
199    entries
200}
201
202/// Discover entry points from package.json, framework rules, and defaults.
203pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
204    let _span = tracing::info_span!("discover_entry_points").entered();
205    let mut entries = Vec::new();
206
207    // Pre-compute relative paths for all files (once, not per pattern)
208    let relative_paths: Vec<String> = files
209        .iter()
210        .map(|f| {
211            f.path
212                .strip_prefix(&config.root)
213                .unwrap_or(&f.path)
214                .to_string_lossy()
215                .into_owned()
216        })
217        .collect();
218
219    // 1. Manual entries from config — pre-compile all patterns
220    for pattern in &config.entry_patterns {
221        if let Ok(glob) = globset::Glob::new(pattern) {
222            let matcher = glob.compile_matcher();
223            for (idx, rel) in relative_paths.iter().enumerate() {
224                if matcher.is_match(rel) {
225                    entries.push(EntryPoint {
226                        path: files[idx].path.clone(),
227                        source: EntryPointSource::ManualEntry,
228                    });
229                }
230            }
231        }
232    }
233
234    // 2. Package.json entries
235    let pkg_path = config.root.join("package.json");
236    if let Ok(pkg) = PackageJson::load(&pkg_path) {
237        let canonical_root = config.root.canonicalize().unwrap_or(config.root.clone());
238        for entry_path in pkg.entry_points() {
239            if let Some(ep) = resolve_entry_path(
240                &config.root,
241                &entry_path,
242                &canonical_root,
243                EntryPointSource::PackageJsonMain,
244            ) {
245                entries.push(ep);
246            }
247        }
248
249        // 2b. Package.json scripts — extract file references as entry points
250        if let Some(scripts) = &pkg.scripts {
251            for script_value in scripts.values() {
252                for file_ref in extract_script_file_refs(script_value) {
253                    if let Some(ep) = resolve_entry_path(
254                        &config.root,
255                        &file_ref,
256                        &canonical_root,
257                        EntryPointSource::PackageJsonScript,
258                    ) {
259                        entries.push(ep);
260                    }
261                }
262            }
263        }
264
265        // 3. Framework rules — cache active status + pre-compile pattern matchers
266        let active_rules: Vec<&fallow_config::FrameworkRule> = config
267            .framework_rules
268            .iter()
269            .filter(|rule| is_framework_active(rule, &pkg, &config.root))
270            .collect();
271
272        for rule in &active_rules {
273            let (entry_matchers, always_matchers) = compile_rule_matchers(rule);
274
275            // Single pass over files for all matchers of this rule
276            for (idx, rel) in relative_paths.iter().enumerate() {
277                let matched = entry_matchers.iter().any(|m| m.is_match(rel))
278                    || always_matchers.iter().any(|m| m.is_match(rel));
279                if matched {
280                    entries.push(EntryPoint {
281                        path: files[idx].path.clone(),
282                        source: EntryPointSource::FrameworkRule {
283                            name: rule.name.clone(),
284                        },
285                    });
286                }
287            }
288        }
289    }
290
291    // 4. Auto-discover nested package.json entry points
292    // For monorepo-like structures without explicit workspace config, scan for
293    // package.json files in subdirectories and use their main/exports as entries.
294    discover_nested_package_entries(&config.root, files, &mut entries);
295
296    // 5. Default index files (if no other entries found)
297    if entries.is_empty() {
298        entries = apply_default_fallback(files, &config.root, None);
299    }
300
301    // Deduplicate by path
302    entries.sort_by(|a, b| a.path.cmp(&b.path));
303    entries.dedup_by(|a, b| a.path == b.path);
304
305    entries
306}
307
308/// Discover entry points from nested package.json files in subdirectories.
309///
310/// When a project has subdirectories with their own package.json (e.g., `packages/foo/package.json`),
311/// the `main`, `module`, `exports`, and `bin` fields of those package.json files should be treated
312/// as entry points. This handles monorepos without explicit workspace configuration.
313fn discover_nested_package_entries(
314    root: &Path,
315    _files: &[DiscoveredFile],
316    entries: &mut Vec<EntryPoint>,
317) {
318    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
319
320    // Walk common monorepo patterns to find nested package.json files
321    let search_dirs = ["packages", "apps", "libs", "modules", "plugins"];
322    for dir_name in &search_dirs {
323        let search_dir = root.join(dir_name);
324        if !search_dir.is_dir() {
325            continue;
326        }
327        let read_dir = match std::fs::read_dir(&search_dir) {
328            Ok(rd) => rd,
329            Err(_) => continue,
330        };
331        for entry in read_dir.flatten() {
332            let pkg_path = entry.path().join("package.json");
333            if !pkg_path.exists() {
334                continue;
335            }
336            let Ok(pkg) = PackageJson::load(&pkg_path) else {
337                continue;
338            };
339            let pkg_dir = entry.path();
340            for entry_path in pkg.entry_points() {
341                if let Some(ep) = resolve_entry_path(
342                    &pkg_dir,
343                    &entry_path,
344                    &canonical_root,
345                    EntryPointSource::PackageJsonExports,
346                ) {
347                    entries.push(ep);
348                }
349            }
350            // Also check scripts in nested package.json
351            if let Some(scripts) = &pkg.scripts {
352                for script_value in scripts.values() {
353                    for file_ref in extract_script_file_refs(script_value) {
354                        if let Some(ep) = resolve_entry_path(
355                            &pkg_dir,
356                            &file_ref,
357                            &canonical_root,
358                            EntryPointSource::PackageJsonScript,
359                        ) {
360                            entries.push(ep);
361                        }
362                    }
363                }
364            }
365        }
366    }
367}
368
369/// Check if a framework rule is active based on its detection config.
370fn is_framework_active(
371    rule: &fallow_config::FrameworkRule,
372    pkg: &PackageJson,
373    root: &Path,
374) -> bool {
375    match &rule.detection {
376        None => true, // No detection = always active
377        Some(detection) => check_detection(detection, pkg, root),
378    }
379}
380
381fn check_detection(detection: &FrameworkDetection, pkg: &PackageJson, root: &Path) -> bool {
382    match detection {
383        FrameworkDetection::Dependency { package } => {
384            pkg.all_dependency_names().iter().any(|d| d == package)
385        }
386        FrameworkDetection::FileExists { pattern } => file_exists_glob(pattern, root),
387        FrameworkDetection::All { conditions } => {
388            conditions.iter().all(|c| check_detection(c, pkg, root))
389        }
390        FrameworkDetection::Any { conditions } => {
391            conditions.iter().any(|c| check_detection(c, pkg, root))
392        }
393    }
394}
395
396/// Discover entry points for a workspace package.
397pub fn discover_workspace_entry_points(
398    ws_root: &Path,
399    config: &ResolvedConfig,
400    all_files: &[DiscoveredFile],
401) -> Vec<EntryPoint> {
402    let mut entries = Vec::new();
403
404    // Also load root package.json for framework detection (monorepo deps are often at root)
405    let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
406
407    let pkg_path = ws_root.join("package.json");
408    if let Ok(pkg) = PackageJson::load(&pkg_path) {
409        let canonical_ws_root = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
410        for entry_path in pkg.entry_points() {
411            if let Some(ep) = resolve_entry_path(
412                ws_root,
413                &entry_path,
414                &canonical_ws_root,
415                EntryPointSource::PackageJsonMain,
416            ) {
417                entries.push(ep);
418            }
419        }
420
421        // Scripts field — extract file references as entry points
422        if let Some(scripts) = &pkg.scripts {
423            for script_value in scripts.values() {
424                for file_ref in extract_script_file_refs(script_value) {
425                    if let Some(ep) = resolve_entry_path(
426                        ws_root,
427                        &file_ref,
428                        &canonical_ws_root,
429                        EntryPointSource::PackageJsonScript,
430                    ) {
431                        entries.push(ep);
432                    }
433                }
434            }
435        }
436
437        // Apply framework rules to workspace.
438        // Check activation against BOTH workspace and root package deps (monorepo hoisting).
439        let canonical_ws = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
440        for rule in &config.framework_rules {
441            let ws_active = is_framework_active(rule, &pkg, ws_root);
442            let root_active = root_pkg
443                .as_ref()
444                .map(|rpkg| is_framework_active(rule, rpkg, &config.root))
445                .unwrap_or(false);
446
447            if !ws_active && !root_active {
448                continue;
449            }
450
451            let (entry_matchers, always_matchers) = compile_rule_matchers(rule);
452
453            // Only consider files within this workspace for framework rule matching
454            for file in all_files {
455                let canonical_file = file.path.canonicalize().unwrap_or(file.path.clone());
456                if !canonical_file.starts_with(&canonical_ws) {
457                    continue;
458                }
459                let relative = file.path.strip_prefix(ws_root).unwrap_or(&file.path);
460                let relative_str = relative.to_string_lossy();
461                let matched = entry_matchers
462                    .iter()
463                    .any(|m| m.is_match(relative_str.as_ref()))
464                    || always_matchers
465                        .iter()
466                        .any(|m| m.is_match(relative_str.as_ref()));
467                if matched {
468                    entries.push(EntryPoint {
469                        path: file.path.clone(),
470                        source: EntryPointSource::FrameworkRule {
471                            name: rule.name.clone(),
472                        },
473                    });
474                }
475            }
476        }
477    }
478
479    // Fall back to default index files if no entry points found for this workspace
480    if entries.is_empty() {
481        let canonical_ws = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
482        entries = apply_default_fallback(all_files, ws_root, Some(&canonical_ws));
483    }
484
485    entries.sort_by(|a, b| a.path.cmp(&b.path));
486    entries.dedup_by(|a, b| a.path == b.path);
487    entries
488}
489
490/// Extract file path references from a package.json script value.
491///
492/// Recognises patterns like:
493/// - `node path/to/script.js`
494/// - `ts-node path/to/script.ts`
495/// - `tsx path/to/script.ts`
496/// - `npx ts-node path/to/script.ts`
497/// - Bare file paths ending in `.js`, `.ts`, `.mjs`, `.cjs`, `.mts`, `.cts`
498///
499/// Script values are split by `&&`, `||`, and `;` to handle chained commands.
500fn extract_script_file_refs(script: &str) -> Vec<String> {
501    let mut refs = Vec::new();
502
503    // Runners whose next argument is a file path
504    const RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node"];
505
506    // Split on shell operators to handle chained commands
507    for segment in script.split(&['&', '|', ';'][..]) {
508        let segment = segment.trim();
509        if segment.is_empty() {
510            continue;
511        }
512
513        let tokens: Vec<&str> = segment.split_whitespace().collect();
514        if tokens.is_empty() {
515            continue;
516        }
517
518        // Skip leading `npx`/`pnpx`/`yarn`/`pnpm exec` to find the actual command
519        let mut start = 0;
520        if matches!(tokens.first(), Some(&"npx" | &"pnpx")) {
521            start = 1;
522        } else if tokens.len() >= 2 && matches!(tokens[0], "yarn" | "pnpm") && tokens[1] == "exec" {
523            start = 2;
524        }
525
526        if start >= tokens.len() {
527            continue;
528        }
529
530        let cmd = tokens[start];
531
532        // Check if the command is a known runner
533        if RUNNERS.contains(&cmd) {
534            // Collect ALL file path arguments after the runner (handles
535            // `node --test file1.mjs file2.mjs ...` and similar multi-file patterns)
536            for &token in &tokens[start + 1..] {
537                if token.starts_with('-') {
538                    continue;
539                }
540                // Must look like a file path (contains '/' or '.' extension)
541                if looks_like_file_path(token) {
542                    refs.push(token.to_string());
543                }
544            }
545        } else {
546            // Scan all tokens for bare file paths (e.g. `./scripts/build.js`)
547            for &token in &tokens[start..] {
548                if token.starts_with('-') {
549                    continue;
550                }
551                if looks_like_script_file(token) {
552                    refs.push(token.to_string());
553                }
554            }
555        }
556    }
557
558    refs
559}
560
561/// Check if a token looks like a file path argument (has a directory separator or a
562/// JS/TS file extension).
563fn looks_like_file_path(token: &str) -> bool {
564    let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
565    if extensions.iter().any(|ext| token.ends_with(ext)) {
566        return true;
567    }
568    // Only treat tokens with `/` as paths if they look like actual file paths,
569    // not URLs or scoped package names like @scope/package
570    token.starts_with("./")
571        || token.starts_with("../")
572        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
573}
574
575/// Check if a token looks like a standalone script file reference (must have a
576/// JS/TS extension and a path-like structure, not a bare command name).
577fn looks_like_script_file(token: &str) -> bool {
578    let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
579    if !extensions.iter().any(|ext| token.ends_with(ext)) {
580        return false;
581    }
582    // Must contain a path separator or start with ./ to distinguish from
583    // bare package names like `webpack.js`
584    token.contains('/') || token.starts_with("./") || token.starts_with("../")
585}
586
587/// Check whether any file matching a glob pattern exists under root.
588///
589/// Uses `globset::Glob` for pattern compilation (supports brace expansion like
590/// `{ts,js}`) and walks the static prefix directory to find matches.
591fn file_exists_glob(pattern: &str, root: &Path) -> bool {
592    let matcher = match globset::Glob::new(pattern) {
593        Ok(g) => g.compile_matcher(),
594        Err(_) => return false,
595    };
596
597    // Extract the static directory prefix from the pattern to narrow the walk.
598    // E.g. for ".storybook/main.{ts,js}" the prefix is ".storybook".
599    let prefix: PathBuf = Path::new(pattern)
600        .components()
601        .take_while(|c| {
602            let s = c.as_os_str().to_string_lossy();
603            !s.contains('*') && !s.contains('?') && !s.contains('{') && !s.contains('[')
604        })
605        .collect();
606
607    let search_dir = if prefix.as_os_str().is_empty() {
608        root.to_path_buf()
609    } else {
610        // prefix may be an exact directory or include the filename portion.
611        let joined = root.join(&prefix);
612        if joined.is_dir() {
613            joined
614        } else if let Some(parent) = joined.parent() {
615            // Only use parent if it's NOT the root itself (avoid walking entire project)
616            if parent != root && parent.is_dir() {
617                parent.to_path_buf()
618            } else {
619                // The prefix directory doesn't exist — no match possible
620                return false;
621            }
622        } else {
623            return false;
624        }
625    };
626
627    if !search_dir.is_dir() {
628        return false;
629    }
630
631    walk_dir_recursive(&search_dir, root, &matcher)
632}
633
634/// Maximum recursion depth for directory walking to prevent infinite loops on symlink cycles.
635const MAX_WALK_DEPTH: usize = 20;
636
637/// Recursively walk a directory and check if any file matches the glob.
638fn walk_dir_recursive(dir: &Path, root: &Path, matcher: &globset::GlobMatcher) -> bool {
639    walk_dir_recursive_depth(dir, root, matcher, 0)
640}
641
642/// Inner recursive walker with depth tracking.
643fn walk_dir_recursive_depth(
644    dir: &Path,
645    root: &Path,
646    matcher: &globset::GlobMatcher,
647    depth: usize,
648) -> bool {
649    if depth >= MAX_WALK_DEPTH {
650        tracing::warn!(
651            dir = %dir.display(),
652            "Maximum directory walk depth reached, possible symlink cycle"
653        );
654        return false;
655    }
656
657    let entries = match std::fs::read_dir(dir) {
658        Ok(rd) => rd,
659        Err(_) => return false,
660    };
661
662    for entry in entries.flatten() {
663        // Use symlink_metadata to avoid following symlinks (prevents cycles)
664        let is_real_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
665        if is_real_dir {
666            if walk_dir_recursive_depth(&entry.path(), root, matcher, depth + 1) {
667                return true;
668            }
669        } else {
670            let path = entry.path();
671            let relative = path.strip_prefix(root).unwrap_or(&path);
672            if matcher.is_match(relative) {
673                return true;
674            }
675        }
676    }
677
678    false
679}
680
681/// Discover entry points from plugin results (dynamic config parsing).
682///
683/// Converts plugin-discovered patterns and setup files into concrete entry points
684/// by matching them against the discovered file list.
685pub fn discover_plugin_entry_points(
686    plugin_result: &crate::plugins::AggregatedPluginResult,
687    config: &ResolvedConfig,
688    files: &[DiscoveredFile],
689) -> Vec<EntryPoint> {
690    let mut entries = Vec::new();
691
692    // Pre-compute relative paths
693    let relative_paths: Vec<String> = files
694        .iter()
695        .map(|f| {
696            f.path
697                .strip_prefix(&config.root)
698                .unwrap_or(&f.path)
699                .to_string_lossy()
700                .into_owned()
701        })
702        .collect();
703
704    // Match plugin entry patterns against files
705    let all_patterns: Vec<&str> = plugin_result
706        .entry_patterns
707        .iter()
708        .chain(plugin_result.discovered_always_used.iter())
709        .chain(plugin_result.always_used.iter())
710        .map(|s| s.as_str())
711        .collect();
712
713    let matchers: Vec<globset::GlobMatcher> = all_patterns
714        .iter()
715        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
716        .collect();
717
718    for (idx, rel) in relative_paths.iter().enumerate() {
719        if matchers.iter().any(|m| m.is_match(rel)) {
720            entries.push(EntryPoint {
721                path: files[idx].path.clone(),
722                source: EntryPointSource::FrameworkRule {
723                    name: "plugin".to_string(),
724                },
725            });
726        }
727    }
728
729    // Add setup files (absolute paths from plugin config parsing)
730    for setup_file in &plugin_result.setup_files {
731        let resolved = if setup_file.is_absolute() {
732            setup_file.clone()
733        } else {
734            config.root.join(setup_file)
735        };
736        if resolved.exists() {
737            entries.push(EntryPoint {
738                path: resolved,
739                source: EntryPointSource::FrameworkRule {
740                    name: "plugin-setup".to_string(),
741                },
742            });
743        } else {
744            // Try with extensions
745            for ext in SOURCE_EXTENSIONS {
746                let with_ext = resolved.with_extension(ext);
747                if with_ext.exists() {
748                    entries.push(EntryPoint {
749                        path: with_ext,
750                        source: EntryPointSource::FrameworkRule {
751                            name: "plugin-setup".to_string(),
752                        },
753                    });
754                    break;
755                }
756            }
757        }
758    }
759
760    // Deduplicate
761    entries.sort_by(|a, b| a.path.cmp(&b.path));
762    entries.dedup_by(|a, b| a.path == b.path);
763    entries
764}
765
766/// Pre-compile a set of glob patterns for efficient matching against many paths.
767pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
768    if patterns.is_empty() {
769        return None;
770    }
771    let mut builder = globset::GlobSetBuilder::new();
772    for pattern in patterns {
773        if let Ok(glob) = globset::Glob::new(pattern) {
774            builder.add(glob);
775        }
776    }
777    builder.build().ok()
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    // extract_script_file_refs tests (Issue 3)
785    #[test]
786    fn script_node_runner() {
787        let refs = extract_script_file_refs("node utilities/generate-coverage-badge.js");
788        assert_eq!(refs, vec!["utilities/generate-coverage-badge.js"]);
789    }
790
791    #[test]
792    fn script_ts_node_runner() {
793        let refs = extract_script_file_refs("ts-node scripts/seed.ts");
794        assert_eq!(refs, vec!["scripts/seed.ts"]);
795    }
796
797    #[test]
798    fn script_tsx_runner() {
799        let refs = extract_script_file_refs("tsx scripts/migrate.ts");
800        assert_eq!(refs, vec!["scripts/migrate.ts"]);
801    }
802
803    #[test]
804    fn script_npx_prefix() {
805        let refs = extract_script_file_refs("npx ts-node scripts/generate.ts");
806        assert_eq!(refs, vec!["scripts/generate.ts"]);
807    }
808
809    #[test]
810    fn script_chained_commands() {
811        let refs = extract_script_file_refs("node scripts/build.js && node scripts/post-build.js");
812        assert_eq!(refs, vec!["scripts/build.js", "scripts/post-build.js"]);
813    }
814
815    #[test]
816    fn script_with_flags() {
817        let refs = extract_script_file_refs(
818            "node --experimental-specifier-resolution=node scripts/run.mjs",
819        );
820        assert_eq!(refs, vec!["scripts/run.mjs"]);
821    }
822
823    #[test]
824    fn script_no_file_ref() {
825        let refs = extract_script_file_refs("next build");
826        assert!(refs.is_empty());
827    }
828
829    #[test]
830    fn script_bare_file_path() {
831        let refs = extract_script_file_refs("echo done && node ./scripts/check.js");
832        assert_eq!(refs, vec!["./scripts/check.js"]);
833    }
834
835    #[test]
836    fn script_semicolon_separator() {
837        let refs = extract_script_file_refs("node scripts/a.js; node scripts/b.ts");
838        assert_eq!(refs, vec!["scripts/a.js", "scripts/b.ts"]);
839    }
840
841    // looks_like_file_path tests
842    #[test]
843    fn file_path_with_extension() {
844        assert!(looks_like_file_path("scripts/build.js"));
845        assert!(looks_like_file_path("scripts/build.ts"));
846        assert!(looks_like_file_path("scripts/build.mjs"));
847    }
848
849    #[test]
850    fn file_path_with_slash() {
851        assert!(looks_like_file_path("scripts/build"));
852    }
853
854    #[test]
855    fn not_file_path() {
856        assert!(!looks_like_file_path("--watch"));
857        assert!(!looks_like_file_path("build"));
858    }
859
860    // looks_like_script_file tests
861    #[test]
862    fn script_file_with_path() {
863        assert!(looks_like_script_file("scripts/build.js"));
864        assert!(looks_like_script_file("./scripts/build.ts"));
865        assert!(looks_like_script_file("../scripts/build.mjs"));
866    }
867
868    #[test]
869    fn not_script_file_bare_name() {
870        // Bare names without path separator should not match
871        assert!(!looks_like_script_file("webpack.js"));
872        assert!(!looks_like_script_file("build"));
873    }
874}