Skip to main content

fallow_core/
discover.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3
4use fallow_config::{PackageJson, ResolvedConfig};
5use ignore::WalkBuilder;
6
7// Re-export types from fallow-types
8pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
9
10pub const SOURCE_EXTENSIONS: &[&str] = &[
11    "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "vue", "svelte", "astro", "mdx", "css",
12    "scss",
13];
14
15/// Hidden (dot-prefixed) directories that should be included in file discovery.
16///
17/// Most hidden directories (`.git`, `.cache`, etc.) should be skipped, but certain
18/// convention directories contain source or config files that fallow needs to see:
19/// - `.storybook` — Storybook configuration (the Storybook plugin depends on this)
20/// - `.well-known` — Standard web convention directory
21/// - `.changeset` — Changesets configuration
22/// - `.github` — GitHub workflows and CI scripts
23const ALLOWED_HIDDEN_DIRS: &[&str] = &[".storybook", ".well-known", ".changeset", ".github"];
24
25/// Check if a hidden directory name is on the allowlist.
26fn is_allowed_hidden_dir(name: &OsStr) -> bool {
27    ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
28}
29
30/// Check if a hidden directory entry should be allowed through the filter.
31///
32/// Returns `true` if the entry is not hidden or is on the allowlist.
33/// Hidden files (not directories) are always allowed through since the type
34/// filter handles them.
35fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
36    let name = entry.file_name();
37    let name_str = name.to_string_lossy();
38
39    // Not hidden — always allow
40    if !name_str.starts_with('.') {
41        return true;
42    }
43
44    // Hidden files are fine — the type filter (source extensions) will handle them
45    if entry.file_type().is_some_and(|ft| ft.is_file()) {
46        return true;
47    }
48
49    // Hidden directory — check against the allowlist
50    is_allowed_hidden_dir(name)
51}
52
53/// Glob patterns for test/dev/story files excluded in production mode.
54const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
55    // Test files
56    "**/*.test.*",
57    "**/*.spec.*",
58    "**/*.e2e.*",
59    "**/*.e2e-spec.*",
60    "**/*.bench.*",
61    "**/*.fixture.*",
62    // Story files
63    "**/*.stories.*",
64    "**/*.story.*",
65    // Test directories
66    "**/__tests__/**",
67    "**/__mocks__/**",
68    "**/__snapshots__/**",
69    "**/__fixtures__/**",
70    "**/test/**",
71    "**/tests/**",
72    // Dev/config files at project level
73    "**/*.config.*",
74    "**/.*.js",
75    "**/.*.ts",
76    "**/.*.mjs",
77    "**/.*.cjs",
78];
79
80/// Discover all source files in the project.
81pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
82    let _span = tracing::info_span!("discover_files").entered();
83
84    let mut types_builder = ignore::types::TypesBuilder::new();
85    for ext in SOURCE_EXTENSIONS {
86        types_builder
87            .add("source", &format!("*.{ext}"))
88            .expect("valid glob");
89    }
90    types_builder.select("source");
91    let types = types_builder.build().expect("valid types");
92
93    let mut walk_builder = WalkBuilder::new(&config.root);
94    walk_builder
95        .hidden(false)
96        .git_ignore(true)
97        .git_global(true)
98        .git_exclude(true)
99        .types(types)
100        .threads(config.threads)
101        .filter_entry(is_allowed_hidden);
102    let walker = walk_builder.build();
103
104    // Build production exclude matcher if needed
105    let production_excludes = if config.production {
106        let mut builder = globset::GlobSetBuilder::new();
107        for pattern in PRODUCTION_EXCLUDE_PATTERNS {
108            if let Ok(glob) = globset::Glob::new(pattern) {
109                builder.add(glob);
110            }
111        }
112        builder.build().ok()
113    } else {
114        None
115    };
116
117    let mut files: Vec<DiscoveredFile> = walker
118        .filter_map(|entry| entry.ok())
119        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
120        .filter(|entry| !config.ignore_patterns.is_match(entry.path()))
121        .filter(|entry| {
122            // In production mode, exclude test/story/dev files
123            production_excludes.as_ref().is_none_or(|excludes| {
124                let relative = entry
125                    .path()
126                    .strip_prefix(&config.root)
127                    .unwrap_or(entry.path());
128                !excludes.is_match(relative)
129            })
130        })
131        .enumerate()
132        .map(|(idx, entry)| {
133            let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
134            DiscoveredFile {
135                id: FileId(idx as u32),
136                path: entry.into_path(),
137                size_bytes,
138            }
139        })
140        .collect();
141
142    // Sort by path for stable, deterministic FileId assignment.
143    // The same set of files always produces the same IDs regardless of file
144    // size changes, which is the foundation for incremental analysis and
145    // cross-run graph caching.
146    files.sort_unstable_by(|a, b| a.path.cmp(&b.path));
147
148    // Re-assign IDs after sorting
149    for (idx, file) in files.iter_mut().enumerate() {
150        file.id = FileId(idx as u32);
151    }
152
153    files
154}
155
156/// Known output directory names from exports maps.
157/// When an entry point path is inside one of these directories, we also try
158/// the `src/` equivalent to find the tracked source file.
159const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
160
161/// Resolve a path relative to a base directory, with security check and extension fallback.
162///
163/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
164/// trying source extensions as fallback when the exact path doesn't exist.
165/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
166/// by trying to map back to the source file (e.g., `./src/utils.ts`).
167fn resolve_entry_path(
168    base: &Path,
169    entry: &str,
170    canonical_root: &Path,
171    source: EntryPointSource,
172) -> Option<EntryPoint> {
173    let resolved = base.join(entry);
174    // Security: ensure resolved path stays within the allowed root
175    let canonical_resolved = resolved.canonicalize().unwrap_or(resolved.clone());
176    if !canonical_resolved.starts_with(canonical_root) {
177        tracing::warn!(path = %entry, "Skipping entry point outside project root");
178        return None;
179    }
180
181    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
182    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
183    // We check this BEFORE the exists() check because even if the dist file exists,
184    // fallow ignores dist/ by default, so we need the source file instead.
185    if let Some(source_path) = try_output_to_source_path(base, entry) {
186        // Security: ensure the mapped source path stays within the project root
187        if let Ok(canonical_source) = source_path.canonicalize()
188            && canonical_source.starts_with(canonical_root)
189        {
190            return Some(EntryPoint {
191                path: source_path,
192                source,
193            });
194        }
195    }
196
197    if resolved.exists() {
198        return Some(EntryPoint {
199            path: resolved,
200            source,
201        });
202    }
203    // Try with source extensions
204    for ext in SOURCE_EXTENSIONS {
205        let with_ext = resolved.with_extension(ext);
206        if with_ext.exists() {
207            return Some(EntryPoint {
208                path: with_ext,
209                source,
210            });
211        }
212    }
213    None
214}
215
216/// Try to map an entry path from an output directory to its source equivalent.
217///
218/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
219/// - `/project/packages/ui/src/utils.ts`
220/// - `/project/packages/ui/src/utils.tsx`
221/// - etc. for all source extensions
222///
223/// Preserves any path prefix between the package root and the output dir,
224/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
225///
226/// Returns `Some(path)` if a source file is found.
227fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
228    let entry_path = Path::new(entry);
229    let components: Vec<_> = entry_path.components().collect();
230
231    // Find the last output directory component in the entry path
232    let output_pos = components.iter().rposition(|c| {
233        if let std::path::Component::Normal(s) = c
234            && let Some(name) = s.to_str()
235        {
236            return OUTPUT_DIRS.contains(&name);
237        }
238        false
239    })?;
240
241    // Build the relative prefix before the output dir, filtering out CurDir (".")
242    let prefix: PathBuf = components[..output_pos]
243        .iter()
244        .filter(|c| !matches!(c, std::path::Component::CurDir))
245        .collect();
246
247    // Build the relative path after the output dir (e.g., "utils.js")
248    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
249
250    // Try base + prefix + "src" + suffix-with-source-extension
251    for ext in SOURCE_EXTENSIONS {
252        let source_candidate = base
253            .join(&prefix)
254            .join("src")
255            .join(suffix.with_extension(ext));
256        if source_candidate.exists() {
257            return Some(source_candidate);
258        }
259    }
260
261    None
262}
263
264/// Default index patterns used when no other entry points are found.
265const DEFAULT_INDEX_PATTERNS: &[&str] = &[
266    "src/index.{ts,tsx,js,jsx}",
267    "src/main.{ts,tsx,js,jsx}",
268    "index.{ts,tsx,js,jsx}",
269    "main.{ts,tsx,js,jsx}",
270];
271
272/// Fall back to default index patterns if no entries were found.
273///
274/// When `ws_filter` is `Some`, only files whose path starts with the given
275/// workspace root are considered (used for workspace-scoped discovery).
276fn apply_default_fallback(
277    files: &[DiscoveredFile],
278    root: &Path,
279    ws_filter: Option<&Path>,
280) -> Vec<EntryPoint> {
281    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
282        .iter()
283        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
284        .collect();
285
286    let mut entries = Vec::new();
287    for file in files {
288        // Use strip_prefix instead of canonicalize for workspace filtering
289        if let Some(ws_root) = ws_filter
290            && file.path.strip_prefix(ws_root).is_err()
291        {
292            continue;
293        }
294        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
295        let relative_str = relative.to_string_lossy();
296        if default_matchers
297            .iter()
298            .any(|m| m.is_match(relative_str.as_ref()))
299        {
300            entries.push(EntryPoint {
301                path: file.path.clone(),
302                source: EntryPointSource::DefaultIndex,
303            });
304        }
305    }
306    entries
307}
308
309/// Discover entry points from package.json, framework rules, and defaults.
310pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
311    let _span = tracing::info_span!("discover_entry_points").entered();
312    let mut entries = Vec::new();
313
314    // Pre-compute relative paths for all files (once, not per pattern)
315    let relative_paths: Vec<String> = files
316        .iter()
317        .map(|f| {
318            f.path
319                .strip_prefix(&config.root)
320                .unwrap_or(&f.path)
321                .to_string_lossy()
322                .into_owned()
323        })
324        .collect();
325
326    // 1. Manual entries from config — batch all patterns into a single GlobSet
327    // for O(files) matching instead of O(patterns × files).
328    {
329        let mut builder = globset::GlobSetBuilder::new();
330        for pattern in &config.entry_patterns {
331            if let Ok(glob) = globset::Glob::new(pattern) {
332                builder.add(glob);
333            }
334        }
335        if let Ok(glob_set) = builder.build()
336            && !glob_set.is_empty()
337        {
338            for (idx, rel) in relative_paths.iter().enumerate() {
339                if glob_set.is_match(rel) {
340                    entries.push(EntryPoint {
341                        path: files[idx].path.clone(),
342                        source: EntryPointSource::ManualEntry,
343                    });
344                }
345            }
346        }
347    }
348
349    // 2. Package.json entries
350    // Pre-compute canonical root once for all resolve_entry_path calls
351    let canonical_root = config.root.canonicalize().unwrap_or(config.root.clone());
352    let pkg_path = config.root.join("package.json");
353    if let Ok(pkg) = PackageJson::load(&pkg_path) {
354        for entry_path in pkg.entry_points() {
355            if let Some(ep) = resolve_entry_path(
356                &config.root,
357                &entry_path,
358                &canonical_root,
359                EntryPointSource::PackageJsonMain,
360            ) {
361                entries.push(ep);
362            }
363        }
364
365        // 2b. Package.json scripts — extract file references as entry points
366        if let Some(scripts) = &pkg.scripts {
367            for script_value in scripts.values() {
368                for file_ref in extract_script_file_refs(script_value) {
369                    if let Some(ep) = resolve_entry_path(
370                        &config.root,
371                        &file_ref,
372                        &canonical_root,
373                        EntryPointSource::PackageJsonScript,
374                    ) {
375                        entries.push(ep);
376                    }
377                }
378            }
379        }
380
381        // Framework rules now flow through PluginRegistry via external_plugins.
382    }
383
384    // 4. Auto-discover nested package.json entry points
385    // For monorepo-like structures without explicit workspace config, scan for
386    // package.json files in subdirectories and use their main/exports as entries.
387    discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
388
389    // 5. Default index files (if no other entries found)
390    if entries.is_empty() {
391        entries = apply_default_fallback(files, &config.root, None);
392    }
393
394    // Deduplicate by path
395    entries.sort_by(|a, b| a.path.cmp(&b.path));
396    entries.dedup_by(|a, b| a.path == b.path);
397
398    entries
399}
400
401/// Discover entry points from nested package.json files in subdirectories.
402///
403/// When a project has subdirectories with their own package.json (e.g., `packages/foo/package.json`),
404/// the `main`, `module`, `exports`, and `bin` fields of those package.json files should be treated
405/// as entry points. This handles monorepos without explicit workspace configuration.
406fn discover_nested_package_entries(
407    root: &Path,
408    _files: &[DiscoveredFile],
409    entries: &mut Vec<EntryPoint>,
410    canonical_root: &Path,
411) {
412    // Walk common monorepo patterns to find nested package.json files
413    let search_dirs = ["packages", "apps", "libs", "modules", "plugins"];
414    for dir_name in &search_dirs {
415        let search_dir = root.join(dir_name);
416        if !search_dir.is_dir() {
417            continue;
418        }
419        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
420            continue;
421        };
422        for entry in read_dir.flatten() {
423            let pkg_path = entry.path().join("package.json");
424            if !pkg_path.exists() {
425                continue;
426            }
427            let Ok(pkg) = PackageJson::load(&pkg_path) else {
428                continue;
429            };
430            let pkg_dir = entry.path();
431            for entry_path in pkg.entry_points() {
432                if let Some(ep) = resolve_entry_path(
433                    &pkg_dir,
434                    &entry_path,
435                    canonical_root,
436                    EntryPointSource::PackageJsonExports,
437                ) {
438                    entries.push(ep);
439                }
440            }
441            // Also check scripts in nested package.json
442            if let Some(scripts) = &pkg.scripts {
443                for script_value in scripts.values() {
444                    for file_ref in extract_script_file_refs(script_value) {
445                        if let Some(ep) = resolve_entry_path(
446                            &pkg_dir,
447                            &file_ref,
448                            canonical_root,
449                            EntryPointSource::PackageJsonScript,
450                        ) {
451                            entries.push(ep);
452                        }
453                    }
454                }
455            }
456        }
457    }
458}
459
460/// Discover entry points for a workspace package.
461pub fn discover_workspace_entry_points(
462    ws_root: &Path,
463    _config: &ResolvedConfig,
464    all_files: &[DiscoveredFile],
465) -> Vec<EntryPoint> {
466    let mut entries = Vec::new();
467
468    let pkg_path = ws_root.join("package.json");
469    if let Ok(pkg) = PackageJson::load(&pkg_path) {
470        let canonical_ws_root = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
471        for entry_path in pkg.entry_points() {
472            if let Some(ep) = resolve_entry_path(
473                ws_root,
474                &entry_path,
475                &canonical_ws_root,
476                EntryPointSource::PackageJsonMain,
477            ) {
478                entries.push(ep);
479            }
480        }
481
482        // Scripts field — extract file references as entry points
483        if let Some(scripts) = &pkg.scripts {
484            for script_value in scripts.values() {
485                for file_ref in extract_script_file_refs(script_value) {
486                    if let Some(ep) = resolve_entry_path(
487                        ws_root,
488                        &file_ref,
489                        &canonical_ws_root,
490                        EntryPointSource::PackageJsonScript,
491                    ) {
492                        entries.push(ep);
493                    }
494                }
495            }
496        }
497
498        // Framework rules now flow through PluginRegistry via external_plugins.
499    }
500
501    // Fall back to default index files if no entry points found for this workspace
502    if entries.is_empty() {
503        entries = apply_default_fallback(all_files, ws_root, None);
504    }
505
506    entries.sort_by(|a, b| a.path.cmp(&b.path));
507    entries.dedup_by(|a, b| a.path == b.path);
508    entries
509}
510
511/// Extract file path references from a package.json script value.
512///
513/// Recognises patterns like:
514/// - `node path/to/script.js`
515/// - `ts-node path/to/script.ts`
516/// - `tsx path/to/script.ts`
517/// - `npx ts-node path/to/script.ts`
518/// - Bare file paths ending in `.js`, `.ts`, `.mjs`, `.cjs`, `.mts`, `.cts`
519///
520/// Script values are split by `&&`, `||`, and `;` to handle chained commands.
521fn extract_script_file_refs(script: &str) -> Vec<String> {
522    let mut refs = Vec::new();
523
524    // Runners whose next argument is a file path
525    const RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node"];
526
527    // Split on shell operators to handle chained commands
528    for segment in script.split(&['&', '|', ';'][..]) {
529        let segment = segment.trim();
530        if segment.is_empty() {
531            continue;
532        }
533
534        let tokens: Vec<&str> = segment.split_whitespace().collect();
535        if tokens.is_empty() {
536            continue;
537        }
538
539        // Skip leading `npx`/`pnpx`/`yarn`/`pnpm exec` to find the actual command
540        let mut start = 0;
541        if matches!(tokens.first(), Some(&"npx" | &"pnpx")) {
542            start = 1;
543        } else if tokens.len() >= 2 && matches!(tokens[0], "yarn" | "pnpm") && tokens[1] == "exec" {
544            start = 2;
545        }
546
547        if start >= tokens.len() {
548            continue;
549        }
550
551        let cmd = tokens[start];
552
553        // Check if the command is a known runner
554        if RUNNERS.contains(&cmd) {
555            // Collect ALL file path arguments after the runner (handles
556            // `node --test file1.mjs file2.mjs ...` and similar multi-file patterns)
557            for &token in &tokens[start + 1..] {
558                if token.starts_with('-') {
559                    continue;
560                }
561                // Must look like a file path (contains '/' or '.' extension)
562                if looks_like_file_path(token) {
563                    refs.push(token.to_string());
564                }
565            }
566        } else {
567            // Scan all tokens for bare file paths (e.g. `./scripts/build.js`)
568            for &token in &tokens[start..] {
569                if token.starts_with('-') {
570                    continue;
571                }
572                if looks_like_script_file(token) {
573                    refs.push(token.to_string());
574                }
575            }
576        }
577    }
578
579    refs
580}
581
582/// Check if a token looks like a file path argument (has a directory separator or a
583/// JS/TS file extension).
584fn looks_like_file_path(token: &str) -> bool {
585    let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
586    if extensions.iter().any(|ext| token.ends_with(ext)) {
587        return true;
588    }
589    // Only treat tokens with `/` as paths if they look like actual file paths,
590    // not URLs or scoped package names like @scope/package
591    token.starts_with("./")
592        || token.starts_with("../")
593        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
594}
595
596/// Check if a token looks like a standalone script file reference (must have a
597/// JS/TS extension and a path-like structure, not a bare command name).
598fn looks_like_script_file(token: &str) -> bool {
599    let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
600    if !extensions.iter().any(|ext| token.ends_with(ext)) {
601        return false;
602    }
603    // Must contain a path separator or start with ./ to distinguish from
604    // bare package names like `webpack.js`
605    token.contains('/') || token.starts_with("./") || token.starts_with("../")
606}
607
608/// Discover entry points from plugin results (dynamic config parsing).
609///
610/// Converts plugin-discovered patterns and setup files into concrete entry points
611/// by matching them against the discovered file list.
612pub fn discover_plugin_entry_points(
613    plugin_result: &crate::plugins::AggregatedPluginResult,
614    config: &ResolvedConfig,
615    files: &[DiscoveredFile],
616) -> Vec<EntryPoint> {
617    let mut entries = Vec::new();
618
619    // Pre-compute relative paths
620    let relative_paths: Vec<String> = files
621        .iter()
622        .map(|f| {
623            f.path
624                .strip_prefix(&config.root)
625                .unwrap_or(&f.path)
626                .to_string_lossy()
627                .into_owned()
628        })
629        .collect();
630
631    // Match plugin entry patterns against files using a single GlobSet
632    // for O(files) matching instead of O(patterns × files).
633    let mut builder = globset::GlobSetBuilder::new();
634    for pattern in plugin_result
635        .entry_patterns
636        .iter()
637        .chain(plugin_result.discovered_always_used.iter())
638        .chain(plugin_result.always_used.iter())
639    {
640        if let Ok(glob) = globset::Glob::new(pattern) {
641            builder.add(glob);
642        }
643    }
644    if let Ok(glob_set) = builder.build()
645        && !glob_set.is_empty()
646    {
647        for (idx, rel) in relative_paths.iter().enumerate() {
648            if glob_set.is_match(rel) {
649                entries.push(EntryPoint {
650                    path: files[idx].path.clone(),
651                    source: EntryPointSource::Plugin {
652                        name: "plugin".to_string(),
653                    },
654                });
655            }
656        }
657    }
658
659    // Add setup files (absolute paths from plugin config parsing)
660    for setup_file in &plugin_result.setup_files {
661        let resolved = if setup_file.is_absolute() {
662            setup_file.clone()
663        } else {
664            config.root.join(setup_file)
665        };
666        if resolved.exists() {
667            entries.push(EntryPoint {
668                path: resolved,
669                source: EntryPointSource::Plugin {
670                    name: "plugin-setup".to_string(),
671                },
672            });
673        } else {
674            // Try with extensions
675            for ext in SOURCE_EXTENSIONS {
676                let with_ext = resolved.with_extension(ext);
677                if with_ext.exists() {
678                    entries.push(EntryPoint {
679                        path: with_ext,
680                        source: EntryPointSource::Plugin {
681                            name: "plugin-setup".to_string(),
682                        },
683                    });
684                    break;
685                }
686            }
687        }
688    }
689
690    // Deduplicate
691    entries.sort_by(|a, b| a.path.cmp(&b.path));
692    entries.dedup_by(|a, b| a.path == b.path);
693    entries
694}
695
696/// Pre-compile a set of glob patterns for efficient matching against many paths.
697pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
698    if patterns.is_empty() {
699        return None;
700    }
701    let mut builder = globset::GlobSetBuilder::new();
702    for pattern in patterns {
703        if let Ok(glob) = globset::Glob::new(pattern) {
704            builder.add(glob);
705        }
706    }
707    builder.build().ok()
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    // extract_script_file_refs tests (Issue 3)
715    #[test]
716    fn script_node_runner() {
717        let refs = extract_script_file_refs("node utilities/generate-coverage-badge.js");
718        assert_eq!(refs, vec!["utilities/generate-coverage-badge.js"]);
719    }
720
721    #[test]
722    fn script_ts_node_runner() {
723        let refs = extract_script_file_refs("ts-node scripts/seed.ts");
724        assert_eq!(refs, vec!["scripts/seed.ts"]);
725    }
726
727    #[test]
728    fn script_tsx_runner() {
729        let refs = extract_script_file_refs("tsx scripts/migrate.ts");
730        assert_eq!(refs, vec!["scripts/migrate.ts"]);
731    }
732
733    #[test]
734    fn script_npx_prefix() {
735        let refs = extract_script_file_refs("npx ts-node scripts/generate.ts");
736        assert_eq!(refs, vec!["scripts/generate.ts"]);
737    }
738
739    #[test]
740    fn script_chained_commands() {
741        let refs = extract_script_file_refs("node scripts/build.js && node scripts/post-build.js");
742        assert_eq!(refs, vec!["scripts/build.js", "scripts/post-build.js"]);
743    }
744
745    #[test]
746    fn script_with_flags() {
747        let refs = extract_script_file_refs(
748            "node --experimental-specifier-resolution=node scripts/run.mjs",
749        );
750        assert_eq!(refs, vec!["scripts/run.mjs"]);
751    }
752
753    #[test]
754    fn script_no_file_ref() {
755        let refs = extract_script_file_refs("next build");
756        assert!(refs.is_empty());
757    }
758
759    #[test]
760    fn script_bare_file_path() {
761        let refs = extract_script_file_refs("echo done && node ./scripts/check.js");
762        assert_eq!(refs, vec!["./scripts/check.js"]);
763    }
764
765    #[test]
766    fn script_semicolon_separator() {
767        let refs = extract_script_file_refs("node scripts/a.js; node scripts/b.ts");
768        assert_eq!(refs, vec!["scripts/a.js", "scripts/b.ts"]);
769    }
770
771    // looks_like_file_path tests
772    #[test]
773    fn file_path_with_extension() {
774        assert!(looks_like_file_path("scripts/build.js"));
775        assert!(looks_like_file_path("scripts/build.ts"));
776        assert!(looks_like_file_path("scripts/build.mjs"));
777    }
778
779    #[test]
780    fn file_path_with_slash() {
781        assert!(looks_like_file_path("scripts/build"));
782    }
783
784    #[test]
785    fn not_file_path() {
786        assert!(!looks_like_file_path("--watch"));
787        assert!(!looks_like_file_path("build"));
788    }
789
790    // looks_like_script_file tests
791    #[test]
792    fn script_file_with_path() {
793        assert!(looks_like_script_file("scripts/build.js"));
794        assert!(looks_like_script_file("./scripts/build.ts"));
795        assert!(looks_like_script_file("../scripts/build.mjs"));
796    }
797
798    #[test]
799    fn not_script_file_bare_name() {
800        // Bare names without path separator should not match
801        assert!(!looks_like_script_file("webpack.js"));
802        assert!(!looks_like_script_file("build"));
803    }
804
805    // is_allowed_hidden_dir tests
806    #[test]
807    fn allowed_hidden_dirs() {
808        assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
809        assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
810        assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
811        assert!(is_allowed_hidden_dir(OsStr::new(".github")));
812    }
813
814    #[test]
815    fn disallowed_hidden_dirs() {
816        assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
817        assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
818        assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
819        assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
820        assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
821    }
822
823    #[test]
824    fn non_hidden_dirs_not_in_allowlist() {
825        // Non-hidden names should not match the allowlist (they are always allowed
826        // by is_allowed_hidden because they don't start with '.')
827        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
828        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
829    }
830
831    mod proptests {
832        use super::*;
833        use proptest::prelude::*;
834
835        proptest! {
836            /// Valid glob patterns should never panic when compiled via globset.
837            #[test]
838            fn glob_patterns_never_panic_on_compile(
839                prefix in "[a-zA-Z0-9_]{1,20}",
840                ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
841            ) {
842                let pattern = format!("**/{prefix}*.{ext}");
843                // Should not panic — either compiles or returns Err gracefully
844                let result = globset::Glob::new(&pattern);
845                prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
846            }
847
848            /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
849            #[test]
850            fn non_source_extensions_not_in_list(
851                ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "html", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
852            ) {
853                prop_assert!(
854                    !SOURCE_EXTENSIONS.contains(&ext),
855                    "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
856                );
857            }
858
859            /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
860            #[test]
861            fn compile_glob_set_no_panic(
862                patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
863            ) {
864                // Should not panic regardless of input
865                let _ = compile_glob_set(&patterns);
866            }
867
868            /// looks_like_file_path should never panic on arbitrary strings.
869            #[test]
870            fn looks_like_file_path_no_panic(s in "[a-zA-Z0-9_./@-]{1,80}") {
871                let _ = looks_like_file_path(&s);
872            }
873
874            /// looks_like_script_file should never panic on arbitrary strings.
875            #[test]
876            fn looks_like_script_file_no_panic(s in "[a-zA-Z0-9_./@-]{1,80}") {
877                let _ = looks_like_script_file(&s);
878            }
879
880            /// extract_script_file_refs should never panic on arbitrary input.
881            #[test]
882            fn extract_script_file_refs_no_panic(s in "[a-zA-Z0-9 _./@&|;-]{1,200}") {
883                let _ = extract_script_file_refs(&s);
884            }
885        }
886    }
887}