Skip to main content

fallow_core/discover/
mod.rs

1mod entry_points;
2mod infrastructure;
3mod parse_scripts;
4mod walk;
5
6use std::path::{Component, Path};
7
8use fallow_config::{PackageJson, ResolvedConfig};
9use rustc_hash::FxHashSet;
10
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
12
13pub(crate) use entry_points::resolve_entry_path;
14pub use entry_points::{
15    CategorizedEntryPoints, compile_glob_set, discover_dynamically_loaded_entry_points,
16    discover_entry_points, discover_plugin_entry_point_sets, discover_plugin_entry_points,
17    discover_workspace_entry_points,
18};
19pub(crate) use entry_points::{
20    EntryPointDiscovery, discover_entry_points_with_warnings_from_pkg,
21    discover_workspace_entry_points_with_warnings_from_pkg, warn_skipped_entry_summary,
22};
23pub use infrastructure::discover_infrastructure_entry_points;
24pub use walk::{
25    HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS, discover_files,
26    discover_files_and_config_candidates, discover_files_with_additional_hidden_dirs,
27    is_allowed_hidden_dir,
28};
29
30/// Collect package-scoped hidden directory traversal rules for active plugins.
31///
32/// Source discovery runs before full plugin execution, so this consults
33/// package-activation checks and static plugin metadata only. Callers that
34/// also need script-derived scopes should use [`collect_hidden_dir_scopes`]
35/// instead, which loads each workspace's `package.json` once and feeds both
36/// passes; standalone CLI command paths can use
37/// [`discover_files_with_plugin_scopes`] when they have neither already.
38#[must_use]
39pub fn collect_plugin_hidden_dir_scopes(
40    config: &ResolvedConfig,
41    root_pkg: Option<&PackageJson>,
42    workspaces: &[fallow_config::WorkspaceInfo],
43) -> Vec<HiddenDirScope> {
44    let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
45    let mut scopes = Vec::new();
46
47    if let Some(pkg) = root_pkg {
48        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
49    }
50
51    for ws in workspaces {
52        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
53            push_plugin_hidden_dir_scope(&mut scopes, &registry, &pkg, &ws.root);
54        }
55    }
56
57    scopes
58}
59
60/// Combined plugin-derived and script-derived hidden directory scopes.
61///
62/// Loads each workspace's `package.json` ONCE and feeds both the plugin
63/// registry's `discovery_hidden_dirs` check and the
64/// `package.json#scripts` extractor. Prefer this over calling
65/// [`collect_plugin_hidden_dir_scopes`] and
66/// [`collect_script_hidden_dir_scopes`] back-to-back: on monorepos with
67/// many workspace packages, doing the workspace `package.json` read once
68/// avoids quadratic I/O.
69#[must_use]
70pub fn collect_hidden_dir_scopes(
71    config: &ResolvedConfig,
72    root_pkg: Option<&PackageJson>,
73    workspaces: &[fallow_config::WorkspaceInfo],
74) -> Vec<HiddenDirScope> {
75    let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
76    let registry = crate::plugins::PluginRegistry::new(config.external_plugins.clone());
77    let mut scopes = Vec::new();
78
79    if let Some(pkg) = root_pkg {
80        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
81        if let Some(scope) = build_script_scope(pkg, &config.root) {
82            scopes.push(scope);
83        }
84    }
85
86    for ws in workspaces {
87        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
88            push_plugin_hidden_dir_scope(&mut scopes, &registry, &pkg, &ws.root);
89            if let Some(scope) = build_script_scope(&pkg, &ws.root) {
90                scopes.push(scope);
91            }
92        }
93    }
94
95    scopes
96}
97
98fn push_plugin_hidden_dir_scope(
99    scopes: &mut Vec<HiddenDirScope>,
100    registry: &crate::plugins::PluginRegistry,
101    pkg: &PackageJson,
102    root: &Path,
103) {
104    let dirs = registry.discovery_hidden_dirs(pkg, root);
105    if !dirs.is_empty() {
106        scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
107    }
108}
109
110/// Discover files with plugin-aware hidden directory traversal.
111///
112/// Convenience wrapper for command paths (list, dupes, health, flags, coverage)
113/// that don't already have workspaces / root `package.json` on hand. Internally
114/// loads the root `package.json` and discovers workspaces so plugin-contributed
115/// hidden directories (e.g. React Router's `.client` / `.server` folders) AND
116/// hidden directories referenced from `package.json#scripts` (e.g.
117/// `eslint -c .config/eslint.config.js`) are traversed consistently across
118/// every command.
119#[must_use]
120pub fn discover_files_with_plugin_scopes(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
121    let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
122    let workspaces = fallow_config::discover_workspaces(&config.root);
123    let scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
124    discover_files_with_additional_hidden_dirs(config, &scopes)
125}
126
127/// Hidden (dot-prefixed) directories that should be included in file discovery.
128///
129/// Most hidden directories (`.git`, `.cache`, etc.) should be skipped, but certain
130/// convention directories contain source or config files that fallow needs to see:
131/// - `.storybook` — Storybook configuration (the Storybook plugin depends on this)
132/// - `.vitepress` — VitePress configuration and theme files
133/// - `.well-known` — Standard web convention directory
134/// - `.changeset` — Changesets configuration
135/// - `.github` — GitHub workflows and CI scripts
136const ALLOWED_HIDDEN_DIRS: &[&str] = &[
137    ".storybook",
138    ".vitepress",
139    ".well-known",
140    ".changeset",
141    ".github",
142];
143
144/// Hidden directories that must NEVER be auto-scoped from a `package.json#scripts`
145/// reference. These are build caches, VCS metadata, IDE state, or package-manager
146/// state where walking would tank performance or pollute analysis. A script that
147/// happens to read or write into one of these directories (e.g. `nx run ... && cp
148/// dist/foo .nx/cache/`) must not pull the entire directory into source discovery.
149const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
150    ".git",
151    ".next",
152    ".nuxt",
153    ".output",
154    ".svelte-kit",
155    ".turbo",
156    ".nx",
157    ".cache",
158    ".parcel-cache",
159    ".vercel",
160    ".netlify",
161    ".yarn",
162    ".pnpm-store",
163    ".docusaurus",
164    ".vscode",
165    ".idea",
166    ".fallow",
167    ".husky",
168];
169
170/// Collect package-scoped hidden directory traversal rules from
171/// `package.json#scripts` references.
172///
173/// Many tools accept custom config paths via `--config` / `-c` flags or positional
174/// file arguments (e.g. `eslint -c .config/eslint.config.js`,
175/// `vitest --config .config/vitest.config.ts`, `tsx ./.scripts/build.ts`). The file
176/// walker's hidden-directory filter would otherwise skip `.config/` and friends,
177/// leaving the referenced file out of the file registry. The file is detected as
178/// an entry point but never parsed, so its imports are never credited.
179///
180/// Guardrails:
181/// - Only the structured outputs of `crate::scripts::parse_script`
182///   (`config_args`, `file_args`) are inspected. Arbitrary script tokens are not
183///   scanned, so a logging path like `.nx/cache/result.json` in a script body
184///   cannot pull `.nx/` into scope.
185/// - Paths containing `..` are skipped. A workspace script referencing
186///   `../../.config/...` should not generate a scope rooted at that workspace.
187/// - `SCRIPT_SCOPE_DENYLIST` excludes known build-cache, VCS, IDE, and
188///   package-manager state directories regardless of script content.
189#[must_use]
190pub fn collect_script_hidden_dir_scopes(
191    config: &ResolvedConfig,
192    root_pkg: Option<&PackageJson>,
193    workspaces: &[fallow_config::WorkspaceInfo],
194) -> Vec<HiddenDirScope> {
195    let _span = tracing::info_span!("collect_script_hidden_dir_scopes").entered();
196    let mut scopes = Vec::new();
197
198    if let Some(pkg) = root_pkg
199        && let Some(scope) = build_script_scope(pkg, &config.root)
200    {
201        scopes.push(scope);
202    }
203    for ws in workspaces {
204        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json"))
205            && let Some(scope) = build_script_scope(&pkg, &ws.root)
206        {
207            scopes.push(scope);
208        }
209    }
210    scopes
211}
212
213fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
214    let scripts = pkg.scripts.as_ref()?;
215    let mut seen = FxHashSet::default();
216    let mut dirs: Vec<String> = Vec::new();
217
218    for (script_name, script_value) in scripts {
219        for cmd in crate::scripts::parse_script(script_value) {
220            for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
221                for hidden in extract_hidden_segments(path) {
222                    if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
223                        continue;
224                    }
225                    if seen.insert(hidden.clone()) {
226                        tracing::debug!(
227                            dir = %hidden,
228                            script = %script_name,
229                            package_root = %root.display(),
230                            "inferred hidden_dir_scope from package.json#scripts"
231                        );
232                        dirs.push(hidden);
233                    }
234                }
235            }
236        }
237    }
238
239    if dirs.is_empty() {
240        None
241    } else {
242        Some(HiddenDirScope::new(root.to_path_buf(), dirs))
243    }
244}
245
246/// Extract hidden (dot-prefixed) directory segments from a relative path.
247///
248/// Returns an empty vec when the path is absolute or contains any `..`
249/// component, so scopes cannot escape a package root. Trailing file
250/// components are not included (a path like `.config/eslint.config.js`
251/// yields `[".config"]`, not `[".config", "eslint.config.js"]`).
252///
253/// A bare single-component path like `.env` is treated as a file (not a
254/// directory) and yields empty. Real-world tools that accept a directory
255/// as the value of `-c` are vanishingly rare; the common case is a file
256/// path. Conflating the two would over-eagerly scope hidden filenames.
257fn extract_hidden_segments(path: &str) -> Vec<String> {
258    let p = Path::new(path);
259    if p.is_absolute() {
260        return Vec::new();
261    }
262    let components: Vec<Component> = p.components().collect();
263    if components.iter().any(|c| matches!(c, Component::ParentDir)) {
264        return Vec::new();
265    }
266    let mut out = Vec::new();
267    let upto = components.len().saturating_sub(1);
268    for component in &components[..upto] {
269        if let Component::Normal(name) = component {
270            let s = name.to_string_lossy();
271            if s.starts_with('.') && s.len() > 1 {
272                out.push(s.into_owned());
273            }
274        }
275    }
276    out
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn allowed_hidden_dirs_count() {
285        assert_eq!(
286            ALLOWED_HIDDEN_DIRS.len(),
287            5,
288            "update tests when adding new allowed hidden dirs"
289        );
290    }
291
292    #[test]
293    fn allowed_hidden_dirs_all_start_with_dot() {
294        for dir in ALLOWED_HIDDEN_DIRS {
295            assert!(
296                dir.starts_with('.'),
297                "allowed hidden dir '{dir}' must start with '.'"
298            );
299        }
300    }
301
302    #[test]
303    fn allowed_hidden_dirs_no_duplicates() {
304        let mut seen = rustc_hash::FxHashSet::default();
305        for dir in ALLOWED_HIDDEN_DIRS {
306            assert!(seen.insert(*dir), "duplicate allowed hidden dir: {dir}");
307        }
308    }
309
310    #[test]
311    fn allowed_hidden_dirs_no_trailing_slash() {
312        for dir in ALLOWED_HIDDEN_DIRS {
313            assert!(
314                !dir.ends_with('/'),
315                "allowed hidden dir '{dir}' should not have trailing slash"
316            );
317        }
318    }
319
320    #[test]
321    fn file_id_re_exported() {
322        let id = FileId(42);
323        assert_eq!(id.0, 42);
324    }
325
326    #[test]
327    fn source_extensions_re_exported() {
328        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
329        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
330    }
331
332    #[test]
333    fn compile_glob_set_re_exported() {
334        let result = compile_glob_set(&["**/*.ts".to_string()]);
335        assert!(result.is_some());
336    }
337
338    #[test]
339    fn script_scope_denylist_all_start_with_dot() {
340        for dir in SCRIPT_SCOPE_DENYLIST {
341            assert!(
342                dir.starts_with('.'),
343                "denylisted dir '{dir}' must start with '.'"
344            );
345        }
346    }
347
348    #[test]
349    fn script_scope_denylist_no_duplicates() {
350        let mut seen = rustc_hash::FxHashSet::default();
351        for dir in SCRIPT_SCOPE_DENYLIST {
352            assert!(seen.insert(*dir), "duplicate denylisted dir: {dir}");
353        }
354    }
355
356    #[test]
357    fn script_scope_denylist_does_not_overlap_allowlist() {
358        for dir in SCRIPT_SCOPE_DENYLIST {
359            assert!(
360                !ALLOWED_HIDDEN_DIRS.contains(dir),
361                "denylisted dir '{dir}' must not also appear in ALLOWED_HIDDEN_DIRS"
362            );
363        }
364    }
365
366    #[test]
367    fn extract_hidden_segments_single_segment() {
368        assert_eq!(
369            extract_hidden_segments(".config/eslint.config.js"),
370            vec![".config".to_string()]
371        );
372    }
373
374    #[test]
375    fn extract_hidden_segments_with_leading_dot_slash() {
376        assert_eq!(
377            extract_hidden_segments("./.config/eslint.config.js"),
378            vec![".config".to_string()]
379        );
380    }
381
382    #[test]
383    fn extract_hidden_segments_nested_hidden() {
384        assert_eq!(
385            extract_hidden_segments(".foo/.bar/x.js"),
386            vec![".foo".to_string(), ".bar".to_string()]
387        );
388    }
389
390    #[test]
391    fn extract_hidden_segments_hidden_inside_normal_parent() {
392        assert_eq!(
393            extract_hidden_segments("sub/.config/eslint.config.js"),
394            vec![".config".to_string()]
395        );
396    }
397
398    #[test]
399    fn extract_hidden_segments_no_hidden_returns_empty() {
400        assert!(extract_hidden_segments("src/index.ts").is_empty());
401    }
402
403    #[test]
404    fn extract_hidden_segments_skips_trailing_filename() {
405        assert!(extract_hidden_segments(".env").is_empty());
406        assert!(extract_hidden_segments("src/.eslintrc.js").is_empty());
407    }
408
409    #[test]
410    fn extract_hidden_segments_skips_paths_with_parent_dir() {
411        assert!(extract_hidden_segments("../.config/eslint.config.js").is_empty());
412        assert!(extract_hidden_segments(".config/../other/x.js").is_empty());
413        assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
414    }
415
416    #[test]
417    fn extract_hidden_segments_skips_absolute_paths() {
418        #[cfg(unix)]
419        {
420            assert!(extract_hidden_segments("/etc/.config/eslint.config.js").is_empty());
421        }
422        #[cfg(windows)]
423        {
424            assert!(extract_hidden_segments(r"C:\etc\.config\eslint.config.js").is_empty());
425        }
426    }
427
428    #[test]
429    fn extract_hidden_segments_ignores_bare_dot() {
430        assert!(extract_hidden_segments(".").is_empty());
431        assert!(extract_hidden_segments("./src/index.ts").is_empty());
432    }
433
434    #[expect(
435        clippy::disallowed_types,
436        reason = "PackageJson::scripts uses std HashMap for serde compatibility"
437    )]
438    fn make_pkg_with_scripts(entries: &[(&str, &str)]) -> PackageJson {
439        let mut pkg = PackageJson::default();
440        let mut scripts: std::collections::HashMap<String, String> =
441            std::collections::HashMap::new();
442        for (name, value) in entries {
443            scripts.insert((*name).to_string(), (*value).to_string());
444        }
445        pkg.scripts = Some(scripts);
446        pkg
447    }
448
449    fn make_config(root: std::path::PathBuf) -> ResolvedConfig {
450        fallow_config::FallowConfig::default().resolve(
451            root,
452            fallow_config::OutputFormat::Human,
453            1,
454            true,
455            true,
456            None,
457        )
458    }
459
460    #[test]
461    fn script_scope_extracts_dash_c_config_arg() {
462        let dir = tempfile::tempdir().expect("tempdir");
463        let config = make_config(dir.path().to_path_buf());
464        let pkg = make_pkg_with_scripts(&[("lint", "eslint -c .config/eslint.config.js")]);
465        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
466
467        assert_eq!(scopes.len(), 1, "one scope for the root package");
468        let target_dir = dir.path().join(".config");
469        std::fs::create_dir_all(&target_dir).unwrap();
470        std::fs::write(target_dir.join("eslint.config.js"), "export default {};").unwrap();
471        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
472        let names: Vec<String> = files
473            .iter()
474            .map(|f| {
475                f.path
476                    .strip_prefix(dir.path())
477                    .unwrap_or(&f.path)
478                    .to_string_lossy()
479                    .replace('\\', "/")
480            })
481            .collect();
482        assert!(
483            names.contains(&".config/eslint.config.js".to_string()),
484            "expected .config/eslint.config.js to be discovered; got {names:?}"
485        );
486    }
487
488    #[test]
489    fn script_scope_extracts_long_config_arg_with_equals() {
490        let dir = tempfile::tempdir().expect("tempdir");
491        let config = make_config(dir.path().to_path_buf());
492        let pkg = make_pkg_with_scripts(&[("test", "vitest --config=.config/vitest.config.ts")]);
493        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
494        assert_eq!(scopes.len(), 1);
495    }
496
497    #[test]
498    fn script_scope_extracts_positional_file_arg() {
499        let dir = tempfile::tempdir().expect("tempdir");
500        let config = make_config(dir.path().to_path_buf());
501        let pkg = make_pkg_with_scripts(&[("build", "tsx ./.scripts/build.ts")]);
502        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
503        assert_eq!(scopes.len(), 1);
504    }
505
506    #[test]
507    fn script_scope_denies_known_bad_dirs() {
508        let dir = tempfile::tempdir().expect("tempdir");
509        let config = make_config(dir.path().to_path_buf());
510        let pkg = make_pkg_with_scripts(&[
511            ("cache", "tsx .nx/scripts/cache.ts"),
512            ("vscode", "node .vscode/build.js"),
513            ("yarn-state", "node .yarn/releases/yarn-4.0.0.cjs"),
514        ]);
515        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
516        assert!(
517            scopes.is_empty(),
518            "denylisted dirs must not produce scopes; got {scopes:?}"
519        );
520    }
521
522    #[test]
523    fn script_scope_mixes_denied_and_allowed_dirs() {
524        let dir = tempfile::tempdir().expect("tempdir");
525        let config = make_config(dir.path().to_path_buf());
526        let pkg = make_pkg_with_scripts(&[(
527            "lint",
528            "nx run-many --target=lint && eslint -c .config/eslint.config.js",
529        )]);
530        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
531        assert_eq!(scopes.len(), 1, "one scope for the .config reference");
532
533        std::fs::create_dir_all(dir.path().join(".config")).unwrap();
534        std::fs::write(
535            dir.path().join(".config/eslint.config.js"),
536            "export default {};",
537        )
538        .unwrap();
539        std::fs::create_dir_all(dir.path().join(".nx/cache")).unwrap();
540        std::fs::write(dir.path().join(".nx/cache/build.js"), "// cache").unwrap();
541
542        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
543        let names: Vec<String> = files
544            .iter()
545            .map(|f| {
546                f.path
547                    .strip_prefix(dir.path())
548                    .unwrap_or(&f.path)
549                    .to_string_lossy()
550                    .replace('\\', "/")
551            })
552            .collect();
553        assert!(names.contains(&".config/eslint.config.js".to_string()));
554        assert!(
555            !names.contains(&".nx/cache/build.js".to_string()),
556            "denylisted .nx must stay hidden"
557        );
558    }
559
560    #[test]
561    fn script_scope_skips_parent_dir_paths() {
562        let dir = tempfile::tempdir().expect("tempdir");
563        let config = make_config(dir.path().to_path_buf());
564        let pkg = make_pkg_with_scripts(&[("lint", "eslint -c ../../.config/eslint.config.js")]);
565        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
566        assert!(
567            scopes.is_empty(),
568            "paths with .. must not generate scopes; got {scopes:?}"
569        );
570    }
571
572    #[test]
573    fn script_scope_no_scripts_returns_empty() {
574        let dir = tempfile::tempdir().expect("tempdir");
575        let config = make_config(dir.path().to_path_buf());
576        let pkg = PackageJson::default();
577        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
578        assert!(scopes.is_empty());
579    }
580
581    #[test]
582    fn script_scope_no_hidden_paths_returns_empty() {
583        let dir = tempfile::tempdir().expect("tempdir");
584        let config = make_config(dir.path().to_path_buf());
585        let pkg = make_pkg_with_scripts(&[
586            ("build", "tsc -p tsconfig.json"),
587            ("lint", "eslint -c eslint.config.js"),
588        ]);
589        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
590        assert!(scopes.is_empty());
591    }
592
593    #[test]
594    fn script_scope_dedupes_within_package() {
595        let dir = tempfile::tempdir().expect("tempdir");
596        let config = make_config(dir.path().to_path_buf());
597        let pkg = make_pkg_with_scripts(&[
598            ("lint", "eslint -c .config/eslint.config.js"),
599            ("test", "vitest --config .config/vitest.config.ts"),
600        ]);
601        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
602        assert_eq!(scopes.len(), 1);
603    }
604
605    #[test]
606    fn script_scope_workspace_packages_have_own_scope_root() {
607        let dir = tempfile::tempdir().expect("tempdir");
608        let config = make_config(dir.path().to_path_buf());
609        let ws_root = dir.path().join("packages/app");
610        std::fs::create_dir_all(&ws_root).unwrap();
611        let ws_pkg_path = ws_root.join("package.json");
612        std::fs::write(
613            &ws_pkg_path,
614            r#"{"name":"app","scripts":{"lint":"eslint -c .config/eslint.config.js"}}"#,
615        )
616        .unwrap();
617        let ws = fallow_config::WorkspaceInfo {
618            root: ws_root.clone(),
619            name: "app".to_string(),
620            is_internal_dependency: false,
621        };
622        let scopes = collect_script_hidden_dir_scopes(&config, None, &[ws]);
623        assert_eq!(scopes.len(), 1);
624
625        std::fs::create_dir_all(ws_root.join(".config")).unwrap();
626        std::fs::write(
627            ws_root.join(".config/eslint.config.js"),
628            "export default {};",
629        )
630        .unwrap();
631        let other_root = dir.path().join("packages/other");
632        std::fs::create_dir_all(other_root.join(".config")).unwrap();
633        std::fs::write(
634            other_root.join(".config/eslint.config.js"),
635            "export default {};",
636        )
637        .unwrap();
638
639        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
640        let names: Vec<String> = files
641            .iter()
642            .map(|f| {
643                f.path
644                    .strip_prefix(dir.path())
645                    .unwrap_or(&f.path)
646                    .to_string_lossy()
647                    .replace('\\', "/")
648            })
649            .collect();
650        assert!(names.contains(&"packages/app/.config/eslint.config.js".to_string()));
651        assert!(
652            !names.contains(&"packages/other/.config/eslint.config.js".to_string()),
653            "unscoped workspace must not get .config traversed"
654        );
655    }
656}