Skip to main content

fallow_engine/
discover.rs

1//! Discovery helpers and types exposed through the engine boundary.
2
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use fallow_config::{
8    PackageJson, ResolvedConfig, WorkspaceDiagnostic, WorkspaceInfo, discover_workspaces,
9    find_undeclared_workspaces_with_ignores,
10};
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
12
13/// Discovery vocabulary shared with the analysis backend: file extensions,
14/// production-exclusion globs, and the hidden directories traversal allows.
15pub use crate::core_backend::{
16    ALLOWED_HIDDEN_DIRS, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS,
17};
18use rustc_hash::FxHashSet;
19
20use crate::{EngineError, EngineResult, plugins::PluginRegistry};
21
22const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
23
24const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
25    ".git",
26    ".next",
27    ".nuxt",
28    ".output",
29    ".svelte-kit",
30    ".turbo",
31    ".nx",
32    ".cache",
33    ".parcel-cache",
34    ".vercel",
35    ".netlify",
36    ".yarn",
37    ".pnpm-store",
38    ".docusaurus",
39    ".vscode",
40    ".idea",
41    ".fallow",
42    ".husky",
43];
44
45const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
46const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
47const SCRIPT_MULTIPLEXERS: &[&str] = &[
48    "concurrently",
49    "npm-run-all",
50    "npm-run-all2",
51    "run-s",
52    "run-p",
53    "run-s2",
54    "run-p2",
55];
56const BUN_RUNTIME_FLAGS: &[&str] = &["--bun", "--watch", "--hot", "--smol", "--no-clear-screen"];
57
58/// Discover workspace packages through the engine boundary.
59///
60/// Use this for callers that only need workspace metadata and do not yet own an
61/// `AnalysisSession`. Session-backed flows should prefer
62/// [`AnalysisSession::workspaces`](crate::session::AnalysisSession::workspaces)
63/// so discovery is reused with the rest of the analysis context.
64#[must_use]
65pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
66    discover_workspaces(root)
67}
68
69/// Discover workspace packages and diagnostics through the engine boundary.
70///
71/// This is for CLI/API surfaces that need to render workspace diagnostics but
72/// do not otherwise need a full [`AnalysisSession`](crate::session::AnalysisSession).
73///
74/// # Errors
75///
76/// Returns an engine error when workspace manifest loading fails.
77pub fn discover_workspace_packages_with_diagnostics(
78    root: &Path,
79    ignore_patterns: &globset::GlobSet,
80) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>)> {
81    fallow_config::discover_workspaces_with_diagnostics(root, ignore_patterns)
82        .map_err(|err| EngineError::new(err.to_string()))
83}
84
85/// Package-scoped hidden directories that source discovery should traverse.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct HiddenDirScope {
88    root: PathBuf,
89    dirs: Vec<String>,
90}
91
92impl HiddenDirScope {
93    #[must_use]
94    const fn new(root: PathBuf, dirs: Vec<String>) -> Self {
95        Self { root, dirs }
96    }
97
98    #[must_use]
99    pub(crate) fn root(&self) -> &Path {
100        &self.root
101    }
102
103    #[must_use]
104    pub(crate) fn dirs(&self) -> &[String] {
105        &self.dirs
106    }
107}
108
109/// Reusable engine discovery prelude for one resolved project.
110#[derive(Debug, Clone)]
111pub struct AnalysisDiscovery {
112    files: Vec<DiscoveredFile>,
113    workspaces: Vec<WorkspaceInfo>,
114    root_pkg: Option<PackageJson>,
115    config_candidates: Vec<PathBuf>,
116    discover_ms: f64,
117    workspaces_ms: f64,
118}
119
120impl AnalysisDiscovery {
121    fn from_parts(
122        files: Vec<DiscoveredFile>,
123        workspaces: Vec<WorkspaceInfo>,
124        root_pkg: Option<PackageJson>,
125        config_candidates: Vec<PathBuf>,
126        discover_ms: f64,
127        workspaces_ms: f64,
128    ) -> Self {
129        Self {
130            files,
131            workspaces,
132            root_pkg,
133            config_candidates,
134            discover_ms,
135            workspaces_ms,
136        }
137    }
138
139    /// Discovered source files, indexed by stable `FileId` for this session.
140    #[must_use]
141    pub(crate) fn files(&self) -> &[DiscoveredFile] {
142        &self.files
143    }
144
145    /// Discovered workspace packages for this session.
146    #[must_use]
147    pub(crate) fn workspaces(&self) -> &[WorkspaceInfo] {
148        &self.workspaces
149    }
150
151    pub(crate) fn root_pkg(&self) -> Option<&PackageJson> {
152        self.root_pkg.as_ref()
153    }
154
155    pub(crate) fn config_candidates(&self) -> &[PathBuf] {
156        &self.config_candidates
157    }
158
159    pub(crate) fn discover_ms(&self) -> f64 {
160        self.discover_ms
161    }
162
163    pub(crate) fn workspaces_ms(&self) -> f64 {
164        self.workspaces_ms
165    }
166
167    /// Consume this discovery prelude and return its source file registry.
168    #[must_use]
169    pub fn into_files(self) -> Vec<DiscoveredFile> {
170        self.files
171    }
172}
173
174/// Run engine-owned workspace and source discovery for a resolved project.
175#[must_use]
176pub(crate) fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
177    warn_missing_node_modules(config);
178
179    let workspaces_start = Instant::now();
180    let workspaces = discover_workspaces(&config.root);
181    let workspaces_ms = workspaces_start.elapsed().as_secs_f64() * 1000.0;
182    if !workspaces.is_empty() {
183        tracing::info!(count = workspaces.len(), "workspaces discovered");
184    }
185    warn_undeclared_workspaces(
186        &config.root,
187        &workspaces,
188        &config.ignore_patterns,
189        config.quiet,
190    );
191
192    let root_pkg = fallow_config::load_dir_package_json(&config.root);
193    let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
194
195    let discover_start = Instant::now();
196    let (files, config_candidates) =
197        discover_files_and_config_candidates(config, &hidden_dir_scopes);
198    let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
199
200    AnalysisDiscovery::from_parts(
201        files,
202        workspaces,
203        root_pkg,
204        config_candidates,
205        discover_ms,
206        workspaces_ms,
207    )
208}
209
210/// Run source discovery with workspace metadata already resolved by config load.
211///
212/// This is the normal [`AnalysisSession`](crate::session::AnalysisSession) path:
213/// config loading already expanded workspace globs and collected diagnostics, so
214/// source discovery can reuse that set instead of walking workspace manifests a
215/// second time.
216#[must_use]
217pub(crate) fn prepare_analysis_discovery_with_workspaces(
218    config: &ResolvedConfig,
219    workspaces: &[WorkspaceInfo],
220    workspaces_ms: f64,
221) -> AnalysisDiscovery {
222    warn_missing_node_modules(config);
223
224    if !workspaces.is_empty() {
225        tracing::info!(count = workspaces.len(), "workspaces discovered");
226    }
227
228    let root_pkg = fallow_config::load_dir_package_json(&config.root);
229    let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), workspaces);
230
231    let discover_start = Instant::now();
232    let (files, config_candidates) =
233        discover_files_and_config_candidates(config, &hidden_dir_scopes);
234    let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
235
236    AnalysisDiscovery::from_parts(
237        files,
238        workspaces.to_vec(),
239        root_pkg,
240        config_candidates,
241        discover_ms,
242        workspaces_ms,
243    )
244}
245
246fn warn_missing_node_modules(config: &ResolvedConfig) {
247    if config.root.join("node_modules").is_dir() {
248        return;
249    }
250    if fallow_config::is_deno_without_node_modules(&config.root) {
251        return;
252    }
253
254    tracing::warn!(
255        "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
256    );
257}
258
259fn format_undeclared_workspace_warning(
260    root: &Path,
261    undeclared: &[WorkspaceDiagnostic],
262) -> Option<String> {
263    if undeclared.is_empty() {
264        return None;
265    }
266
267    let preview = undeclared
268        .iter()
269        .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
270        .map(|diagnostic| {
271            diagnostic
272                .path
273                .strip_prefix(root)
274                .unwrap_or(&diagnostic.path)
275                .display()
276                .to_string()
277                .replace('\\', "/")
278        })
279        .collect::<Vec<_>>();
280    let remaining = undeclared
281        .len()
282        .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
283    let tail = if remaining > 0 {
284        format!(" (and {remaining} more)")
285    } else {
286        String::new()
287    };
288    let noun = if undeclared.len() == 1 {
289        "directory with package.json is"
290    } else {
291        "directories with package.json are"
292    };
293    let guidance = if undeclared.len() == 1 {
294        "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
295    } else {
296        "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
297    };
298
299    Some(format!(
300        "{} {} not declared as {}: {}{}. {}",
301        undeclared.len(),
302        noun,
303        if undeclared.len() == 1 {
304            "a workspace"
305        } else {
306            "workspaces"
307        },
308        preview.join(", "),
309        tail,
310        guidance
311    ))
312}
313
314fn warn_undeclared_workspaces(
315    root: &Path,
316    workspaces: &[WorkspaceInfo],
317    ignore_patterns: &globset::GlobSet,
318    quiet: bool,
319) {
320    let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
321    if undeclared.is_empty() {
322        return;
323    }
324
325    let existing = fallow_config::workspace_diagnostics_for(root);
326    let already_flagged: FxHashSet<PathBuf> = existing
327        .iter()
328        .map(|diagnostic| {
329            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
330        })
331        .collect();
332    let undeclared: Vec<_> = undeclared
333        .into_iter()
334        .filter(|diagnostic| {
335            let canonical =
336                dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
337            !already_flagged.contains(&canonical)
338        })
339        .collect();
340    if undeclared.is_empty() {
341        return;
342    }
343
344    fallow_config::append_workspace_diagnostics(root, undeclared.clone());
345
346    if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
347        tracing::warn!("{message}");
348    }
349}
350
351/// Check if a hidden directory name is on the discovery allowlist.
352#[must_use]
353pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
354    ALLOWED_HIDDEN_DIRS
355        .iter()
356        .any(|&dir| OsStr::new(dir) == name)
357}
358
359/// Collect plugin-derived hidden directory scopes.
360#[must_use]
361pub fn collect_plugin_hidden_dir_scopes(
362    config: &ResolvedConfig,
363    root_pkg: Option<&PackageJson>,
364    workspaces: &[WorkspaceInfo],
365) -> Vec<HiddenDirScope> {
366    let registry = PluginRegistry::new(config.external_plugins.clone());
367    let mut scopes = Vec::new();
368
369    if let Some(pkg) = root_pkg {
370        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
371    }
372
373    for ws in workspaces {
374        if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
375            push_plugin_hidden_dir_scope(&mut scopes, &registry, &pkg, &ws.root);
376        }
377    }
378
379    scopes
380}
381
382fn push_plugin_hidden_dir_scope(
383    scopes: &mut Vec<HiddenDirScope>,
384    registry: &PluginRegistry,
385    pkg: &PackageJson,
386    root: &Path,
387) {
388    let dirs = registry.discovery_hidden_dirs(pkg, root);
389    if !dirs.is_empty() {
390        scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
391    }
392}
393
394/// Collect plugin and script-derived hidden directory scopes.
395#[must_use]
396pub(crate) fn collect_hidden_dir_scopes(
397    config: &ResolvedConfig,
398    root_pkg: Option<&PackageJson>,
399    workspaces: &[WorkspaceInfo],
400) -> Vec<HiddenDirScope> {
401    let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
402    let registry = PluginRegistry::new(config.external_plugins.clone());
403    let mut scopes = Vec::new();
404
405    if let Some(pkg) = root_pkg {
406        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
407        push_script_hidden_dir_scope(&mut scopes, pkg, &config.root);
408    }
409
410    for ws in workspaces {
411        if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
412            push_plugin_hidden_dir_scope(&mut scopes, &registry, &pkg, &ws.root);
413            push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
414        }
415    }
416
417    scopes
418}
419
420fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
421    if let Some(scope) = build_script_scope(pkg, root) {
422        scopes.push(scope);
423    }
424}
425
426fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
427    let scripts = pkg.scripts.as_ref()?;
428    let mut seen = FxHashSet::default();
429    let mut dirs: Vec<String> = Vec::new();
430
431    for (script_name, script_value) in scripts {
432        for cmd in parse_script_value(script_value) {
433            for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
434                for hidden in extract_hidden_segments(path) {
435                    if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
436                        continue;
437                    }
438                    if seen.insert(hidden.clone()) {
439                        tracing::debug!(
440                            dir = %hidden,
441                            script = %script_name,
442                            package_root = %root.display(),
443                            "inferred hidden_dir_scope from package.json#scripts"
444                        );
445                        dirs.push(hidden);
446                    }
447                }
448            }
449        }
450    }
451
452    if dirs.is_empty() {
453        None
454    } else {
455        Some(HiddenDirScope::new(root.to_path_buf(), dirs))
456    }
457}
458
459#[derive(Debug, PartialEq, Eq)]
460struct ScriptCommand {
461    config_args: Vec<String>,
462    file_args: Vec<String>,
463}
464
465fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
466    let mut commands = Vec::new();
467
468    for segment in split_shell_operators(script) {
469        let segment = segment.trim();
470        if segment.is_empty() {
471            continue;
472        }
473        if let Some(cmd) = parse_command_segment(segment) {
474            commands.push(cmd);
475        }
476    }
477
478    commands
479}
480
481fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
482    let tokens: Vec<&str> = segment
483        .split_whitespace()
484        .map(strip_surrounding_quotes)
485        .collect();
486    if tokens.is_empty() {
487        return None;
488    }
489
490    let idx = skip_initial_wrappers(&tokens, 0)?;
491    let idx = advance_past_package_manager(&tokens, idx)?;
492    let binary = tokens[idx];
493
494    if SCRIPT_MULTIPLEXERS.contains(&binary) {
495        return Some(ScriptCommand {
496            config_args: Vec::new(),
497            file_args: Vec::new(),
498        });
499    }
500
501    let is_node_runner = NODE_RUNNERS.contains(&binary);
502    let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
503
504    Some(ScriptCommand {
505        config_args,
506        file_args,
507    })
508}
509
510fn split_shell_operators(script: &str) -> Vec<&str> {
511    let mut segments = Vec::new();
512    let mut start = 0;
513    let bytes = script.as_bytes();
514    let len = bytes.len();
515    let mut index = 0;
516    let mut in_single_quote = false;
517    let mut in_double_quote = false;
518
519    while index < len {
520        let byte = bytes[index];
521
522        if byte == b'\'' && !in_double_quote {
523            in_single_quote = !in_single_quote;
524            index += 1;
525            continue;
526        }
527        if byte == b'"' && !in_single_quote {
528            in_double_quote = !in_double_quote;
529            index += 1;
530            continue;
531        }
532
533        if in_single_quote || in_double_quote {
534            index += 1;
535            continue;
536        }
537
538        if let Some(op_len) = shell_operator_len(bytes, index) {
539            segments.push(&script[start..index]);
540            index += op_len;
541            start = index;
542            continue;
543        }
544
545        index += 1;
546    }
547
548    if start < len {
549        segments.push(&script[start..]);
550    }
551
552    segments
553}
554
555fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
556    let byte = bytes[index];
557    let next = bytes.get(index + 1).copied();
558
559    if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
560        return Some(2);
561    }
562
563    if byte == b';' {
564        return Some(1);
565    }
566    if byte == b'|' && next != Some(b'|') {
567        return Some(1);
568    }
569    if byte == b'&' && next != Some(b'&') {
570        return Some(1);
571    }
572
573    None
574}
575
576fn strip_surrounding_quotes(token: &str) -> &str {
577    if token.len() >= 2 {
578        let first = token.as_bytes()[0];
579        let last = token.as_bytes()[token.len() - 1];
580        if (first == b'\'' || first == b'"') && first == last {
581            return &token[1..token.len() - 1];
582        }
583    }
584    token
585}
586
587fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
588    while index < tokens.len() && is_env_assignment(tokens[index]) {
589        index += 1;
590    }
591    if index >= tokens.len() {
592        return None;
593    }
594
595    while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
596        index += 1;
597        while index < tokens.len() && is_env_assignment(tokens[index]) {
598            index += 1;
599        }
600        if index < tokens.len() && tokens[index] == "--" {
601            index += 1;
602        }
603    }
604    if index >= tokens.len() {
605        return None;
606    }
607
608    Some(index)
609}
610
611fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
612    let token = tokens[index];
613    if matches!(token, "npx" | "pnpx" | "bunx") {
614        index += 1;
615        while index < tokens.len() && tokens[index].starts_with('-') {
616            let flag = tokens[index];
617            index += 1;
618            if matches!(flag, "--package" | "-p") && index < tokens.len() {
619                index += 1;
620            }
621        }
622    } else if token == "bun" {
623        index += 1;
624        let mut saw_runtime_flag = false;
625        while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
626            index += 1;
627            saw_runtime_flag = true;
628        }
629        if index >= tokens.len() {
630            return None;
631        }
632        let subcmd = tokens[index];
633        if subcmd == "exec" || subcmd == "x" {
634            index += 1;
635        } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
636            return None;
637        }
638    } else if matches!(token, "yarn" | "pnpm" | "npm") {
639        if index + 1 < tokens.len() {
640            let subcmd = tokens[index + 1];
641            if subcmd == "exec" || subcmd == "dlx" {
642                index += 2;
643            } else {
644                return None;
645            }
646        } else {
647            return None;
648        }
649    }
650    if index >= tokens.len() {
651        return None;
652    }
653
654    Some(index)
655}
656
657fn extract_args_for_binary(
658    tokens: &[&str],
659    mut index: usize,
660    is_node_runner: bool,
661) -> (Vec<String>, Vec<String>) {
662    let mut file_args = Vec::new();
663    let mut config_args = Vec::new();
664
665    while index < tokens.len() {
666        let token = tokens[index];
667
668        if is_node_runner
669            && matches!(
670                token,
671                "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
672            )
673        {
674            index += 2;
675            continue;
676        }
677
678        if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
679            config_args.push(config);
680            if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
681                index += 1;
682            } else {
683                index += 2;
684            }
685            continue;
686        }
687
688        if token.starts_with('-') {
689            index += 1;
690            continue;
691        }
692
693        if looks_like_file_path(token) {
694            file_args.push(token.to_string());
695        }
696        index += 1;
697    }
698
699    (file_args, config_args)
700}
701
702fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
703    if let Some(value) = token.strip_prefix("--config=")
704        && !value.is_empty()
705    {
706        return Some(value.to_string());
707    }
708    if let Some(value) = token.strip_prefix("-c=")
709        && !value.is_empty()
710    {
711        return Some(value.to_string());
712    }
713    if matches!(token, "--config" | "-c")
714        && let Some(next_token) = next
715        && !next_token.starts_with('-')
716    {
717        return Some(next_token.to_string());
718    }
719    None
720}
721
722fn is_env_assignment(token: &str) -> bool {
723    token.find('=').is_some_and(|eq_pos| {
724        let name = &token[..eq_pos];
725        !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
726    })
727}
728
729fn looks_like_file_path(token: &str) -> bool {
730    if !could_be_file_path(token) {
731        return false;
732    }
733
734    const EXTENSIONS: &[&str] = &[
735        ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
736        ".toml",
737    ];
738    if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
739        return true;
740    }
741    token.starts_with("./")
742        || token.starts_with("../")
743        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
744}
745
746fn could_be_file_path(token: &str) -> bool {
747    if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
748        return false;
749    }
750
751    if token.contains('\\') {
752        return false;
753    }
754
755    if let Some(open) = token.find('[') {
756        let after_open = &token[open + 1..];
757        let close_offset = after_open.find(']');
758        if !matches!(close_offset, Some(offset) if offset > 0) {
759            return false;
760        }
761    }
762
763    true
764}
765
766fn extract_hidden_segments(path: &str) -> Vec<String> {
767    let path = Path::new(path);
768    if path.is_absolute() {
769        return Vec::new();
770    }
771
772    let mut hidden = Vec::new();
773    let components = path.components().collect::<Vec<_>>();
774    if components.iter().any(|component| {
775        matches!(
776            component,
777            std::path::Component::ParentDir | std::path::Component::RootDir
778        )
779    }) {
780        return Vec::new();
781    }
782
783    for (index, component) in components.iter().enumerate() {
784        let std::path::Component::Normal(value) = component else {
785            continue;
786        };
787        let value = value.to_string_lossy();
788        if !value.starts_with('.') || value == "." || value == ".." {
789            continue;
790        }
791        if index == components.len().saturating_sub(1) {
792            continue;
793        }
794        hidden.push(value.to_string());
795    }
796
797    hidden
798}
799
800/// Discover source files and non-source config candidates in one traversal.
801#[must_use]
802pub fn discover_files_and_config_candidates(
803    config: &ResolvedConfig,
804    additional_hidden_dir_scopes: &[HiddenDirScope],
805) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
806    crate::core_backend::discover_files_and_config_candidates(config, additional_hidden_dir_scopes)
807}
808
809/// Discover configured and inferred entry points.
810#[must_use]
811pub(crate) fn discover_entry_points(
812    config: &ResolvedConfig,
813    files: &[DiscoveredFile],
814) -> Vec<EntryPoint> {
815    crate::core_backend::discover_entry_points(config, files)
816}
817
818/// Discover entry points for a workspace package.
819#[must_use]
820pub(crate) fn discover_workspace_entry_points(
821    ws_root: &Path,
822    config: &ResolvedConfig,
823    all_files: &[DiscoveredFile],
824) -> Vec<EntryPoint> {
825    crate::core_backend::discover_workspace_entry_points(ws_root, config, all_files)
826}
827
828/// Discover entry points from plugin results.
829#[must_use]
830pub(crate) fn discover_plugin_entry_points(
831    plugin_result: &crate::plugins::AggregatedPluginResult,
832    config: &ResolvedConfig,
833    files: &[DiscoveredFile],
834) -> Vec<EntryPoint> {
835    crate::core_backend::discover_plugin_entry_points(plugin_result.backend(), config, files)
836}
837
838#[cfg(test)]
839mod tests {
840    use std::path::PathBuf;
841
842    use fallow_config::PackageJson;
843
844    use super::{
845        ALLOWED_HIDDEN_DIRS, HiddenDirScope, collect_hidden_dir_scopes,
846        collect_plugin_hidden_dir_scopes, extract_hidden_segments, is_allowed_hidden_dir,
847    };
848
849    #[test]
850    fn hidden_dir_scope_exposes_root_and_dirs() {
851        let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
852
853        assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
854        assert_eq!(scope.dirs(), [".next"]);
855    }
856
857    #[test]
858    fn hidden_dir_allowlist_is_engine_owned() {
859        for dir in ALLOWED_HIDDEN_DIRS {
860            assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
861        }
862        assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
863    }
864
865    #[test]
866    fn plugin_hidden_dir_scopes_are_engine_owned() {
867        let dir = tempfile::tempdir().expect("tempdir");
868        let config = fallow_config::FallowConfig::default().resolve(
869            dir.path().to_path_buf(),
870            fallow_config::OutputFormat::Human,
871            1,
872            true,
873            true,
874            None,
875        );
876        let pkg: PackageJson = serde_json::from_value(serde_json::json!({
877            "devDependencies": {
878                "@react-router/dev": "^7.0.0"
879            }
880        }))
881        .expect("valid package fixture");
882
883        let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
884
885        assert_eq!(scopes.len(), 1);
886        assert_eq!(scopes[0].root(), dir.path());
887        assert_eq!(scopes[0].dirs(), [".client", ".server"]);
888    }
889
890    #[test]
891    fn script_hidden_dir_scopes_are_engine_owned() {
892        let dir = tempfile::tempdir().expect("tempdir");
893        let config = fallow_config::FallowConfig::default().resolve(
894            dir.path().to_path_buf(),
895            fallow_config::OutputFormat::Human,
896            1,
897            true,
898            true,
899            None,
900        );
901        let pkg: PackageJson = serde_json::from_value(serde_json::json!({
902            "scripts": {
903                "lint": "eslint -c .config/eslint.config.js",
904                "build": "tsx ./.scripts/build.ts",
905                "cache": "tsx .nx/cache/build.ts"
906            }
907        }))
908        .expect("valid package fixture");
909
910        let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
911
912        assert_eq!(scopes.len(), 1);
913        assert_eq!(scopes[0].root(), dir.path());
914        let mut dirs = scopes[0].dirs().to_vec();
915        dirs.sort();
916        assert_eq!(dirs, [".config", ".scripts"]);
917    }
918
919    #[test]
920    fn hidden_segment_extraction_rejects_escape_paths() {
921        assert_eq!(
922            extract_hidden_segments(".foo/.bar/x.js"),
923            vec![".foo".to_string(), ".bar".to_string()]
924        );
925        assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
926        assert!(extract_hidden_segments(".env").is_empty());
927    }
928}