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