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 use entry_points::{
16    CategorizedEntryPoints, compile_glob_set, discover_dynamically_loaded_entry_points,
17    discover_entry_points, discover_plugin_entry_point_sets, discover_plugin_entry_points,
18    discover_workspace_entry_points,
19};
20pub(crate) use entry_points::{
21    EntryPointDiscovery, discover_entry_points_with_warnings_from_pkg,
22    discover_workspace_entry_points_with_warnings_from_pkg, warn_skipped_entry_summary,
23};
24pub use infrastructure::discover_infrastructure_entry_points;
25pub use walk::{
26    HiddenDirScope, PRODUCTION_EXCLUDE_PATTERNS, SOURCE_EXTENSIONS, discover_files,
27    discover_files_with_additional_hidden_dirs,
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    // Skip the last component (treated as a filename: the walker filters
268    // files by extension, not by hidden status, so hidden files are already
269    // passed through without scoping).
270    let upto = components.len().saturating_sub(1);
271    for component in &components[..upto] {
272        if let Component::Normal(name) = component {
273            let s = name.to_string_lossy();
274            if s.starts_with('.') && s.len() > 1 {
275                out.push(s.into_owned());
276            }
277        }
278    }
279    out
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    // ── ALLOWED_HIDDEN_DIRS exhaustiveness ───────────────────────────
287
288    #[test]
289    fn allowed_hidden_dirs_count() {
290        // Guard: if a new dir is added, add a test for it
291        assert_eq!(
292            ALLOWED_HIDDEN_DIRS.len(),
293            5,
294            "update tests when adding new allowed hidden dirs"
295        );
296    }
297
298    #[test]
299    fn allowed_hidden_dirs_all_start_with_dot() {
300        for dir in ALLOWED_HIDDEN_DIRS {
301            assert!(
302                dir.starts_with('.'),
303                "allowed hidden dir '{dir}' must start with '.'"
304            );
305        }
306    }
307
308    #[test]
309    fn allowed_hidden_dirs_no_duplicates() {
310        let mut seen = rustc_hash::FxHashSet::default();
311        for dir in ALLOWED_HIDDEN_DIRS {
312            assert!(seen.insert(*dir), "duplicate allowed hidden dir: {dir}");
313        }
314    }
315
316    #[test]
317    fn allowed_hidden_dirs_no_trailing_slash() {
318        for dir in ALLOWED_HIDDEN_DIRS {
319            assert!(
320                !dir.ends_with('/'),
321                "allowed hidden dir '{dir}' should not have trailing slash"
322            );
323        }
324    }
325
326    // ── Re-export smoke tests ───────────────────────────────────────
327
328    #[test]
329    fn file_id_re_exported() {
330        // Verify the re-export works by constructing a FileId through the discover module
331        let id = FileId(42);
332        assert_eq!(id.0, 42);
333    }
334
335    #[test]
336    fn source_extensions_re_exported() {
337        assert!(SOURCE_EXTENSIONS.contains(&"ts"));
338        assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
339    }
340
341    #[test]
342    fn compile_glob_set_re_exported() {
343        let result = compile_glob_set(&["**/*.ts".to_string()]);
344        assert!(result.is_some());
345    }
346
347    // ── SCRIPT_SCOPE_DENYLIST exhaustiveness ────────────────────────
348
349    #[test]
350    fn script_scope_denylist_all_start_with_dot() {
351        for dir in SCRIPT_SCOPE_DENYLIST {
352            assert!(
353                dir.starts_with('.'),
354                "denylisted dir '{dir}' must start with '.'"
355            );
356        }
357    }
358
359    #[test]
360    fn script_scope_denylist_no_duplicates() {
361        let mut seen = rustc_hash::FxHashSet::default();
362        for dir in SCRIPT_SCOPE_DENYLIST {
363            assert!(seen.insert(*dir), "duplicate denylisted dir: {dir}");
364        }
365    }
366
367    #[test]
368    fn script_scope_denylist_does_not_overlap_allowlist() {
369        for dir in SCRIPT_SCOPE_DENYLIST {
370            assert!(
371                !ALLOWED_HIDDEN_DIRS.contains(dir),
372                "denylisted dir '{dir}' must not also appear in ALLOWED_HIDDEN_DIRS"
373            );
374        }
375    }
376
377    // ── extract_hidden_segments ─────────────────────────────────────
378
379    #[test]
380    fn extract_hidden_segments_single_segment() {
381        assert_eq!(
382            extract_hidden_segments(".config/eslint.config.js"),
383            vec![".config".to_string()]
384        );
385    }
386
387    #[test]
388    fn extract_hidden_segments_with_leading_dot_slash() {
389        assert_eq!(
390            extract_hidden_segments("./.config/eslint.config.js"),
391            vec![".config".to_string()]
392        );
393    }
394
395    #[test]
396    fn extract_hidden_segments_nested_hidden() {
397        assert_eq!(
398            extract_hidden_segments(".foo/.bar/x.js"),
399            vec![".foo".to_string(), ".bar".to_string()]
400        );
401    }
402
403    #[test]
404    fn extract_hidden_segments_hidden_inside_normal_parent() {
405        assert_eq!(
406            extract_hidden_segments("sub/.config/eslint.config.js"),
407            vec![".config".to_string()]
408        );
409    }
410
411    #[test]
412    fn extract_hidden_segments_no_hidden_returns_empty() {
413        assert!(extract_hidden_segments("src/index.ts").is_empty());
414    }
415
416    #[test]
417    fn extract_hidden_segments_skips_trailing_filename() {
418        // The last component is a file. The walker filters files by extension,
419        // not by hidden status, so it must not appear in the scope.
420        assert!(extract_hidden_segments(".env").is_empty());
421        assert!(extract_hidden_segments("src/.eslintrc.js").is_empty());
422    }
423
424    #[test]
425    fn extract_hidden_segments_skips_paths_with_parent_dir() {
426        // `..` anywhere in the path means the path can escape a package root.
427        assert!(extract_hidden_segments("../.config/eslint.config.js").is_empty());
428        assert!(extract_hidden_segments(".config/../other/x.js").is_empty());
429        assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
430    }
431
432    #[test]
433    fn extract_hidden_segments_skips_absolute_paths() {
434        // Absolute paths cannot be safely scoped to a package root.
435        #[cfg(unix)]
436        {
437            assert!(extract_hidden_segments("/etc/.config/eslint.config.js").is_empty());
438        }
439        #[cfg(windows)]
440        {
441            assert!(extract_hidden_segments(r"C:\etc\.config\eslint.config.js").is_empty());
442        }
443    }
444
445    #[test]
446    fn extract_hidden_segments_ignores_bare_dot() {
447        // `.` is the current directory marker, not a hidden segment.
448        assert!(extract_hidden_segments(".").is_empty());
449        assert!(extract_hidden_segments("./src/index.ts").is_empty());
450    }
451
452    // ── collect_script_hidden_dir_scopes ────────────────────────────
453
454    #[expect(
455        clippy::disallowed_types,
456        reason = "PackageJson::scripts uses std HashMap for serde compatibility"
457    )]
458    fn make_pkg_with_scripts(entries: &[(&str, &str)]) -> PackageJson {
459        let mut pkg = PackageJson::default();
460        let mut scripts: std::collections::HashMap<String, String> =
461            std::collections::HashMap::new();
462        for (name, value) in entries {
463            scripts.insert((*name).to_string(), (*value).to_string());
464        }
465        pkg.scripts = Some(scripts);
466        pkg
467    }
468
469    fn make_config(root: std::path::PathBuf) -> ResolvedConfig {
470        fallow_config::FallowConfig::default().resolve(
471            root,
472            fallow_config::OutputFormat::Human,
473            1,
474            true,
475            true,
476            None,
477        )
478    }
479
480    #[test]
481    fn script_scope_extracts_dash_c_config_arg() {
482        let dir = tempfile::tempdir().expect("tempdir");
483        let config = make_config(dir.path().to_path_buf());
484        let pkg = make_pkg_with_scripts(&[("lint", "eslint -c .config/eslint.config.js")]);
485        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
486
487        assert_eq!(scopes.len(), 1, "one scope for the root package");
488        // We cannot reach into HiddenDirScope's private fields, but we can verify
489        // via the file walker that the directory is now traversed.
490        let target_dir = dir.path().join(".config");
491        std::fs::create_dir_all(&target_dir).unwrap();
492        std::fs::write(target_dir.join("eslint.config.js"), "export default {};").unwrap();
493        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
494        let names: Vec<String> = files
495            .iter()
496            .map(|f| {
497                f.path
498                    .strip_prefix(dir.path())
499                    .unwrap_or(&f.path)
500                    .to_string_lossy()
501                    .replace('\\', "/")
502            })
503            .collect();
504        assert!(
505            names.contains(&".config/eslint.config.js".to_string()),
506            "expected .config/eslint.config.js to be discovered; got {names:?}"
507        );
508    }
509
510    #[test]
511    fn script_scope_extracts_long_config_arg_with_equals() {
512        let dir = tempfile::tempdir().expect("tempdir");
513        let config = make_config(dir.path().to_path_buf());
514        let pkg = make_pkg_with_scripts(&[("test", "vitest --config=.config/vitest.config.ts")]);
515        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
516        assert_eq!(scopes.len(), 1);
517    }
518
519    #[test]
520    fn script_scope_extracts_positional_file_arg() {
521        let dir = tempfile::tempdir().expect("tempdir");
522        let config = make_config(dir.path().to_path_buf());
523        let pkg = make_pkg_with_scripts(&[("build", "tsx ./.scripts/build.ts")]);
524        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
525        assert_eq!(scopes.len(), 1);
526    }
527
528    #[test]
529    fn script_scope_denies_known_bad_dirs() {
530        let dir = tempfile::tempdir().expect("tempdir");
531        let config = make_config(dir.path().to_path_buf());
532        // A script referencing a denied dir must NOT produce a scope.
533        let pkg = make_pkg_with_scripts(&[
534            ("cache", "tsx .nx/scripts/cache.ts"),
535            ("vscode", "node .vscode/build.js"),
536            ("yarn-state", "node .yarn/releases/yarn-4.0.0.cjs"),
537        ]);
538        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
539        assert!(
540            scopes.is_empty(),
541            "denylisted dirs must not produce scopes; got {scopes:?}"
542        );
543    }
544
545    #[test]
546    fn script_scope_mixes_denied_and_allowed_dirs() {
547        let dir = tempfile::tempdir().expect("tempdir");
548        let config = make_config(dir.path().to_path_buf());
549        // Mix of denied (.nx) and allowed (.config). Only the allowed survives.
550        let pkg = make_pkg_with_scripts(&[(
551            "lint",
552            "nx run-many --target=lint && eslint -c .config/eslint.config.js",
553        )]);
554        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
555        assert_eq!(scopes.len(), 1, "one scope for the .config reference");
556
557        // Confirm by walking: .config/ should be discovered, .nx/ should not.
558        std::fs::create_dir_all(dir.path().join(".config")).unwrap();
559        std::fs::write(
560            dir.path().join(".config/eslint.config.js"),
561            "export default {};",
562        )
563        .unwrap();
564        std::fs::create_dir_all(dir.path().join(".nx/cache")).unwrap();
565        std::fs::write(dir.path().join(".nx/cache/build.js"), "// cache").unwrap();
566
567        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
568        let names: Vec<String> = files
569            .iter()
570            .map(|f| {
571                f.path
572                    .strip_prefix(dir.path())
573                    .unwrap_or(&f.path)
574                    .to_string_lossy()
575                    .replace('\\', "/")
576            })
577            .collect();
578        assert!(names.contains(&".config/eslint.config.js".to_string()));
579        assert!(
580            !names.contains(&".nx/cache/build.js".to_string()),
581            "denylisted .nx must stay hidden"
582        );
583    }
584
585    #[test]
586    fn script_scope_skips_parent_dir_paths() {
587        let dir = tempfile::tempdir().expect("tempdir");
588        let config = make_config(dir.path().to_path_buf());
589        let pkg = make_pkg_with_scripts(&[("lint", "eslint -c ../../.config/eslint.config.js")]);
590        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
591        assert!(
592            scopes.is_empty(),
593            "paths with .. must not generate scopes; got {scopes:?}"
594        );
595    }
596
597    #[test]
598    fn script_scope_no_scripts_returns_empty() {
599        let dir = tempfile::tempdir().expect("tempdir");
600        let config = make_config(dir.path().to_path_buf());
601        let pkg = PackageJson::default();
602        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
603        assert!(scopes.is_empty());
604    }
605
606    #[test]
607    fn script_scope_no_hidden_paths_returns_empty() {
608        let dir = tempfile::tempdir().expect("tempdir");
609        let config = make_config(dir.path().to_path_buf());
610        let pkg = make_pkg_with_scripts(&[
611            ("build", "tsc -p tsconfig.json"),
612            ("lint", "eslint -c eslint.config.js"),
613        ]);
614        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
615        assert!(scopes.is_empty());
616    }
617
618    #[test]
619    fn script_scope_dedupes_within_package() {
620        let dir = tempfile::tempdir().expect("tempdir");
621        let config = make_config(dir.path().to_path_buf());
622        // Two scripts both reference .config: should produce one scope with one dir.
623        let pkg = make_pkg_with_scripts(&[
624            ("lint", "eslint -c .config/eslint.config.js"),
625            ("test", "vitest --config .config/vitest.config.ts"),
626        ]);
627        let scopes = collect_script_hidden_dir_scopes(&config, Some(&pkg), &[]);
628        assert_eq!(scopes.len(), 1);
629    }
630
631    #[test]
632    fn script_scope_workspace_packages_have_own_scope_root() {
633        let dir = tempfile::tempdir().expect("tempdir");
634        let config = make_config(dir.path().to_path_buf());
635        // Workspace has its own .config/ that should be scoped to its root,
636        // not the project root.
637        let ws_root = dir.path().join("packages/app");
638        std::fs::create_dir_all(&ws_root).unwrap();
639        let ws_pkg_path = ws_root.join("package.json");
640        std::fs::write(
641            &ws_pkg_path,
642            r#"{"name":"app","scripts":{"lint":"eslint -c .config/eslint.config.js"}}"#,
643        )
644        .unwrap();
645        let ws = fallow_config::WorkspaceInfo {
646            root: ws_root.clone(),
647            name: "app".to_string(),
648            is_internal_dependency: false,
649        };
650        let scopes = collect_script_hidden_dir_scopes(&config, None, &[ws]);
651        assert_eq!(scopes.len(), 1);
652
653        // The scope should only allow .config under the workspace root, not anywhere else.
654        std::fs::create_dir_all(ws_root.join(".config")).unwrap();
655        std::fs::write(
656            ws_root.join(".config/eslint.config.js"),
657            "export default {};",
658        )
659        .unwrap();
660        // A sibling .config under a different (unscoped) package must stay hidden.
661        let other_root = dir.path().join("packages/other");
662        std::fs::create_dir_all(other_root.join(".config")).unwrap();
663        std::fs::write(
664            other_root.join(".config/eslint.config.js"),
665            "export default {};",
666        )
667        .unwrap();
668
669        let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
670        let names: Vec<String> = files
671            .iter()
672            .map(|f| {
673                f.path
674                    .strip_prefix(dir.path())
675                    .unwrap_or(&f.path)
676                    .to_string_lossy()
677                    .replace('\\', "/")
678            })
679            .collect();
680        assert!(names.contains(&"packages/app/.config/eslint.config.js".to_string()));
681        assert!(
682            !names.contains(&"packages/other/.config/eslint.config.js".to_string()),
683            "unscoped workspace must not get .config traversed"
684        );
685    }
686}