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