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