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