Skip to main content

fallow_config/config/
resolution.rs

1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::duplicates_config::DuplicatesConfig;
8use super::format::OutputFormat;
9use super::health::HealthConfig;
10use super::rules::{PartialRulesConfig, RulesConfig, Severity};
11use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
12
13use super::FallowConfig;
14
15/// Rule for ignoring specific exports.
16#[derive(Debug, Deserialize, Serialize, JsonSchema)]
17pub struct IgnoreExportRule {
18    /// Glob pattern for files.
19    pub file: String,
20    /// Export names to ignore (`*` for all).
21    pub exports: Vec<String>,
22}
23
24/// Per-file override entry.
25#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct ConfigOverride {
28    /// Glob patterns to match files against (relative to config file location).
29    pub files: Vec<String>,
30    /// Partial rules — only specified fields override the base rules.
31    #[serde(default)]
32    pub rules: PartialRulesConfig,
33}
34
35/// Resolved override with pre-compiled glob matchers.
36#[derive(Debug)]
37pub struct ResolvedOverride {
38    pub matchers: Vec<globset::GlobMatcher>,
39    pub rules: PartialRulesConfig,
40}
41
42/// Fully resolved configuration with all globs pre-compiled.
43#[derive(Debug)]
44pub struct ResolvedConfig {
45    pub root: PathBuf,
46    pub entry_patterns: Vec<String>,
47    pub ignore_patterns: GlobSet,
48    pub output: OutputFormat,
49    pub cache_dir: PathBuf,
50    pub threads: usize,
51    pub no_cache: bool,
52    pub ignore_dependencies: Vec<String>,
53    pub ignore_export_rules: Vec<IgnoreExportRule>,
54    pub duplicates: DuplicatesConfig,
55    pub health: HealthConfig,
56    pub rules: RulesConfig,
57    /// Whether production mode is active.
58    pub production: bool,
59    /// Suppress progress output and non-essential stderr messages.
60    pub quiet: bool,
61    /// External plugin definitions (from plugin files + inline framework definitions).
62    pub external_plugins: Vec<ExternalPluginDef>,
63    /// Per-file rule overrides with pre-compiled glob matchers.
64    pub overrides: Vec<ResolvedOverride>,
65}
66
67impl FallowConfig {
68    /// Resolve into a fully resolved config with compiled globs.
69    pub fn resolve(
70        self,
71        root: PathBuf,
72        output: OutputFormat,
73        threads: usize,
74        no_cache: bool,
75        quiet: bool,
76    ) -> ResolvedConfig {
77        let mut ignore_builder = GlobSetBuilder::new();
78        for pattern in &self.ignore_patterns {
79            match Glob::new(pattern) {
80                Ok(glob) => {
81                    ignore_builder.add(glob);
82                }
83                Err(e) => {
84                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
85                }
86            }
87        }
88
89        // Default ignores
90        // Note: `build/` is only ignored at the project root (not `**/build/**`)
91        // because nested `build/` directories like `test/build/` may contain source files.
92        let default_ignores = [
93            "**/node_modules/**",
94            "**/dist/**",
95            "build/**",
96            "**/.git/**",
97            "**/coverage/**",
98            "**/*.min.js",
99            "**/*.min.mjs",
100        ];
101        for pattern in &default_ignores {
102            if let Ok(glob) = Glob::new(pattern) {
103                ignore_builder.add(glob);
104            }
105        }
106
107        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
108        let cache_dir = root.join(".fallow");
109
110        let mut rules = self.rules;
111
112        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
113        let production = self.production;
114        if production {
115            rules.unused_dev_dependencies = Severity::Off;
116            rules.unused_optional_dependencies = Severity::Off;
117        }
118
119        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
120        // Merge inline framework definitions into external plugins
121        external_plugins.extend(self.framework);
122
123        // Pre-compile override glob matchers
124        let overrides = self
125            .overrides
126            .into_iter()
127            .filter_map(|o| {
128                let matchers: Vec<globset::GlobMatcher> = o
129                    .files
130                    .iter()
131                    .filter_map(|pattern| match Glob::new(pattern) {
132                        Ok(glob) => Some(glob.compile_matcher()),
133                        Err(e) => {
134                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
135                            None
136                        }
137                    })
138                    .collect();
139                if matchers.is_empty() {
140                    None
141                } else {
142                    Some(ResolvedOverride {
143                        matchers,
144                        rules: o.rules,
145                    })
146                }
147            })
148            .collect();
149
150        ResolvedConfig {
151            root,
152            entry_patterns: self.entry,
153            ignore_patterns: compiled_ignore_patterns,
154            output,
155            cache_dir,
156            threads,
157            no_cache,
158            ignore_dependencies: self.ignore_dependencies,
159            ignore_export_rules: self.ignore_exports,
160            duplicates: self.duplicates,
161            health: self.health,
162            rules,
163            production,
164            quiet,
165            external_plugins,
166            overrides,
167        }
168    }
169}
170
171impl ResolvedConfig {
172    /// Resolve the effective rules for a given file path.
173    /// Starts with base rules and applies matching overrides in order.
174    #[must_use]
175    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
176        if self.overrides.is_empty() {
177            return self.rules.clone();
178        }
179
180        let relative = path.strip_prefix(&self.root).unwrap_or(path);
181        let relative_str = relative.to_string_lossy();
182
183        let mut rules = self.rules.clone();
184        for override_entry in &self.overrides {
185            let matches = override_entry
186                .matchers
187                .iter()
188                .any(|m| m.is_match(relative_str.as_ref()));
189            if matches {
190                rules.apply_partial(&override_entry.rules);
191            }
192        }
193        rules
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::config::health::HealthConfig;
201
202    #[test]
203    fn overrides_deserialize() {
204        let json_str = r#"{
205            "overrides": [{
206                "files": ["*.test.ts"],
207                "rules": {
208                    "unused-exports": "off"
209                }
210            }]
211        }"#;
212        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
213        assert_eq!(config.overrides.len(), 1);
214        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
215        assert_eq!(
216            config.overrides[0].rules.unused_exports,
217            Some(Severity::Off)
218        );
219        assert_eq!(config.overrides[0].rules.unused_files, None);
220    }
221
222    #[test]
223    fn resolve_rules_for_path_no_overrides() {
224        let config = FallowConfig {
225            schema: None,
226            extends: vec![],
227            entry: vec![],
228            ignore_patterns: vec![],
229            framework: vec![],
230            workspaces: None,
231            ignore_dependencies: vec![],
232            ignore_exports: vec![],
233            duplicates: DuplicatesConfig::default(),
234            health: HealthConfig::default(),
235            rules: RulesConfig::default(),
236            production: false,
237            plugins: vec![],
238            overrides: vec![],
239        };
240        let resolved = config.resolve(
241            PathBuf::from("/project"),
242            OutputFormat::Human,
243            1,
244            true,
245            true,
246        );
247        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
248        assert_eq!(rules.unused_files, Severity::Error);
249    }
250
251    #[test]
252    fn resolve_rules_for_path_with_matching_override() {
253        let config = FallowConfig {
254            schema: None,
255            extends: vec![],
256            entry: vec![],
257            ignore_patterns: vec![],
258            framework: vec![],
259            workspaces: None,
260            ignore_dependencies: vec![],
261            ignore_exports: vec![],
262            duplicates: DuplicatesConfig::default(),
263            health: HealthConfig::default(),
264            rules: RulesConfig::default(),
265            production: false,
266            plugins: vec![],
267            overrides: vec![ConfigOverride {
268                files: vec!["*.test.ts".to_string()],
269                rules: PartialRulesConfig {
270                    unused_exports: Some(Severity::Off),
271                    ..Default::default()
272                },
273            }],
274        };
275        let resolved = config.resolve(
276            PathBuf::from("/project"),
277            OutputFormat::Human,
278            1,
279            true,
280            true,
281        );
282
283        // Test file matches override
284        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
285        assert_eq!(test_rules.unused_exports, Severity::Off);
286        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
287
288        // Non-test file does not match
289        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
290        assert_eq!(src_rules.unused_exports, Severity::Error);
291    }
292
293    #[test]
294    fn resolve_rules_for_path_later_override_wins() {
295        let config = FallowConfig {
296            schema: None,
297            extends: vec![],
298            entry: vec![],
299            ignore_patterns: vec![],
300            framework: vec![],
301            workspaces: None,
302            ignore_dependencies: vec![],
303            ignore_exports: vec![],
304            duplicates: DuplicatesConfig::default(),
305            health: HealthConfig::default(),
306            rules: RulesConfig::default(),
307            production: false,
308            plugins: vec![],
309            overrides: vec![
310                ConfigOverride {
311                    files: vec!["*.ts".to_string()],
312                    rules: PartialRulesConfig {
313                        unused_files: Some(Severity::Warn),
314                        ..Default::default()
315                    },
316                },
317                ConfigOverride {
318                    files: vec!["*.test.ts".to_string()],
319                    rules: PartialRulesConfig {
320                        unused_files: Some(Severity::Off),
321                        ..Default::default()
322                    },
323                },
324            ],
325        };
326        let resolved = config.resolve(
327            PathBuf::from("/project"),
328            OutputFormat::Human,
329            1,
330            true,
331            true,
332        );
333
334        // First override matches *.ts, second matches *.test.ts; second wins
335        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
336        assert_eq!(rules.unused_files, Severity::Off);
337
338        // Non-test .ts file only matches first override
339        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
340        assert_eq!(rules2.unused_files, Severity::Warn);
341    }
342
343    /// Helper to build a FallowConfig with minimal boilerplate.
344    fn make_config(production: bool) -> FallowConfig {
345        FallowConfig {
346            schema: None,
347            extends: vec![],
348            entry: vec![],
349            ignore_patterns: vec![],
350            framework: vec![],
351            workspaces: None,
352            ignore_dependencies: vec![],
353            ignore_exports: vec![],
354            duplicates: DuplicatesConfig::default(),
355            health: HealthConfig::default(),
356            rules: RulesConfig::default(),
357            production,
358            plugins: vec![],
359            overrides: vec![],
360        }
361    }
362
363    // ── Production mode ─────────────────────────────────────────────
364
365    #[test]
366    fn resolve_production_forces_dev_deps_off() {
367        let resolved = make_config(true).resolve(
368            PathBuf::from("/project"),
369            OutputFormat::Human,
370            1,
371            true,
372            true,
373        );
374        assert_eq!(
375            resolved.rules.unused_dev_dependencies,
376            Severity::Off,
377            "production mode should force unused_dev_dependencies to off"
378        );
379    }
380
381    #[test]
382    fn resolve_production_forces_optional_deps_off() {
383        let resolved = make_config(true).resolve(
384            PathBuf::from("/project"),
385            OutputFormat::Human,
386            1,
387            true,
388            true,
389        );
390        assert_eq!(
391            resolved.rules.unused_optional_dependencies,
392            Severity::Off,
393            "production mode should force unused_optional_dependencies to off"
394        );
395    }
396
397    #[test]
398    fn resolve_production_preserves_other_rules() {
399        let resolved = make_config(true).resolve(
400            PathBuf::from("/project"),
401            OutputFormat::Human,
402            1,
403            true,
404            true,
405        );
406        // Other rules should remain at their defaults
407        assert_eq!(resolved.rules.unused_files, Severity::Error);
408        assert_eq!(resolved.rules.unused_exports, Severity::Error);
409        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
410    }
411
412    #[test]
413    fn resolve_non_production_keeps_dev_deps_default() {
414        let resolved = make_config(false).resolve(
415            PathBuf::from("/project"),
416            OutputFormat::Human,
417            1,
418            true,
419            true,
420        );
421        assert_eq!(
422            resolved.rules.unused_dev_dependencies,
423            Severity::Error,
424            "non-production should keep default severity"
425        );
426        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Error);
427    }
428
429    #[test]
430    fn resolve_production_flag_stored() {
431        let resolved = make_config(true).resolve(
432            PathBuf::from("/project"),
433            OutputFormat::Human,
434            1,
435            true,
436            true,
437        );
438        assert!(resolved.production);
439
440        let resolved2 = make_config(false).resolve(
441            PathBuf::from("/project"),
442            OutputFormat::Human,
443            1,
444            true,
445            true,
446        );
447        assert!(!resolved2.production);
448    }
449
450    // ── Default ignore patterns ─────────────────────────────────────
451
452    #[test]
453    fn resolve_default_ignores_node_modules() {
454        let resolved = make_config(false).resolve(
455            PathBuf::from("/project"),
456            OutputFormat::Human,
457            1,
458            true,
459            true,
460        );
461        assert!(
462            resolved
463                .ignore_patterns
464                .is_match("node_modules/lodash/index.js")
465        );
466        assert!(
467            resolved
468                .ignore_patterns
469                .is_match("packages/a/node_modules/react/index.js")
470        );
471    }
472
473    #[test]
474    fn resolve_default_ignores_dist() {
475        let resolved = make_config(false).resolve(
476            PathBuf::from("/project"),
477            OutputFormat::Human,
478            1,
479            true,
480            true,
481        );
482        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
483        assert!(
484            resolved
485                .ignore_patterns
486                .is_match("packages/ui/dist/index.js")
487        );
488    }
489
490    #[test]
491    fn resolve_default_ignores_root_build_only() {
492        let resolved = make_config(false).resolve(
493            PathBuf::from("/project"),
494            OutputFormat::Human,
495            1,
496            true,
497            true,
498        );
499        assert!(
500            resolved.ignore_patterns.is_match("build/output.js"),
501            "root build/ should be ignored"
502        );
503        // The pattern is `build/**` (root-only), not `**/build/**`
504        assert!(
505            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
506            "nested build/ should NOT be ignored by default"
507        );
508    }
509
510    #[test]
511    fn resolve_default_ignores_minified_files() {
512        let resolved = make_config(false).resolve(
513            PathBuf::from("/project"),
514            OutputFormat::Human,
515            1,
516            true,
517            true,
518        );
519        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
520        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
521    }
522
523    #[test]
524    fn resolve_default_ignores_git() {
525        let resolved = make_config(false).resolve(
526            PathBuf::from("/project"),
527            OutputFormat::Human,
528            1,
529            true,
530            true,
531        );
532        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
533    }
534
535    #[test]
536    fn resolve_default_ignores_coverage() {
537        let resolved = make_config(false).resolve(
538            PathBuf::from("/project"),
539            OutputFormat::Human,
540            1,
541            true,
542            true,
543        );
544        assert!(
545            resolved
546                .ignore_patterns
547                .is_match("coverage/lcov-report/index.js")
548        );
549    }
550
551    #[test]
552    fn resolve_source_files_not_ignored_by_default() {
553        let resolved = make_config(false).resolve(
554            PathBuf::from("/project"),
555            OutputFormat::Human,
556            1,
557            true,
558            true,
559        );
560        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
561        assert!(
562            !resolved
563                .ignore_patterns
564                .is_match("src/components/Button.tsx")
565        );
566        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
567    }
568
569    // ── Custom ignore patterns ──────────────────────────────────────
570
571    #[test]
572    fn resolve_custom_ignore_patterns_merged_with_defaults() {
573        let mut config = make_config(false);
574        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
575        let resolved = config.resolve(
576            PathBuf::from("/project"),
577            OutputFormat::Human,
578            1,
579            true,
580            true,
581        );
582        // Custom pattern works
583        assert!(
584            resolved
585                .ignore_patterns
586                .is_match("src/__generated__/types.ts")
587        );
588        // Default patterns still work
589        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
590    }
591
592    // ── Config fields passthrough ───────────────────────────────────
593
594    #[test]
595    fn resolve_passes_through_entry_patterns() {
596        let mut config = make_config(false);
597        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
598        let resolved = config.resolve(
599            PathBuf::from("/project"),
600            OutputFormat::Human,
601            1,
602            true,
603            true,
604        );
605        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
606    }
607
608    #[test]
609    fn resolve_passes_through_ignore_dependencies() {
610        let mut config = make_config(false);
611        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
612        let resolved = config.resolve(
613            PathBuf::from("/project"),
614            OutputFormat::Human,
615            1,
616            true,
617            true,
618        );
619        assert_eq!(
620            resolved.ignore_dependencies,
621            vec!["postcss", "autoprefixer"]
622        );
623    }
624
625    #[test]
626    fn resolve_sets_cache_dir() {
627        let resolved = make_config(false).resolve(
628            PathBuf::from("/my/project"),
629            OutputFormat::Human,
630            1,
631            true,
632            true,
633        );
634        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
635    }
636
637    #[test]
638    fn resolve_passes_through_thread_count() {
639        let resolved = make_config(false).resolve(
640            PathBuf::from("/project"),
641            OutputFormat::Human,
642            8,
643            true,
644            true,
645        );
646        assert_eq!(resolved.threads, 8);
647    }
648
649    #[test]
650    fn resolve_passes_through_quiet_flag() {
651        let resolved = make_config(false).resolve(
652            PathBuf::from("/project"),
653            OutputFormat::Human,
654            1,
655            true,
656            false,
657        );
658        assert!(!resolved.quiet);
659
660        let resolved2 = make_config(false).resolve(
661            PathBuf::from("/project"),
662            OutputFormat::Human,
663            1,
664            true,
665            true,
666        );
667        assert!(resolved2.quiet);
668    }
669
670    #[test]
671    fn resolve_passes_through_no_cache_flag() {
672        let resolved_no_cache = make_config(false).resolve(
673            PathBuf::from("/project"),
674            OutputFormat::Human,
675            1,
676            true,
677            true,
678        );
679        assert!(resolved_no_cache.no_cache);
680
681        let resolved_with_cache = make_config(false).resolve(
682            PathBuf::from("/project"),
683            OutputFormat::Human,
684            1,
685            false,
686            true,
687        );
688        assert!(!resolved_with_cache.no_cache);
689    }
690
691    // ── Override resolution edge cases ───────────────────────────────
692
693    #[test]
694    fn resolve_override_with_invalid_glob_skipped() {
695        let mut config = make_config(false);
696        config.overrides = vec![ConfigOverride {
697            files: vec!["[invalid".to_string()],
698            rules: PartialRulesConfig {
699                unused_files: Some(Severity::Off),
700                ..Default::default()
701            },
702        }];
703        let resolved = config.resolve(
704            PathBuf::from("/project"),
705            OutputFormat::Human,
706            1,
707            true,
708            true,
709        );
710        // Invalid glob should be skipped, so no overrides should be compiled
711        assert!(
712            resolved.overrides.is_empty(),
713            "override with invalid glob should be skipped"
714        );
715    }
716
717    #[test]
718    fn resolve_override_with_empty_files_skipped() {
719        let mut config = make_config(false);
720        config.overrides = vec![ConfigOverride {
721            files: vec![],
722            rules: PartialRulesConfig {
723                unused_files: Some(Severity::Off),
724                ..Default::default()
725            },
726        }];
727        let resolved = config.resolve(
728            PathBuf::from("/project"),
729            OutputFormat::Human,
730            1,
731            true,
732            true,
733        );
734        assert!(
735            resolved.overrides.is_empty(),
736            "override with no file patterns should be skipped"
737        );
738    }
739
740    #[test]
741    fn resolve_multiple_valid_overrides() {
742        let mut config = make_config(false);
743        config.overrides = vec![
744            ConfigOverride {
745                files: vec!["*.test.ts".to_string()],
746                rules: PartialRulesConfig {
747                    unused_exports: Some(Severity::Off),
748                    ..Default::default()
749                },
750            },
751            ConfigOverride {
752                files: vec!["*.stories.tsx".to_string()],
753                rules: PartialRulesConfig {
754                    unused_files: Some(Severity::Off),
755                    ..Default::default()
756                },
757            },
758        ];
759        let resolved = config.resolve(
760            PathBuf::from("/project"),
761            OutputFormat::Human,
762            1,
763            true,
764            true,
765        );
766        assert_eq!(resolved.overrides.len(), 2);
767    }
768
769    // ── IgnoreExportRule ────────────────────────────────────────────
770
771    #[test]
772    fn ignore_export_rule_deserialize() {
773        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
774        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
775        assert_eq!(rule.file, "src/types/*.ts");
776        assert_eq!(rule.exports, vec!["*"]);
777    }
778
779    #[test]
780    fn ignore_export_rule_specific_exports() {
781        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
782        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
783        assert_eq!(rule.exports.len(), 3);
784        assert!(rule.exports.contains(&"FOO".to_string()));
785    }
786
787    mod proptests {
788        use super::*;
789        use proptest::prelude::*;
790
791        fn arb_resolved_config(production: bool) -> ResolvedConfig {
792            make_config(production).resolve(
793                PathBuf::from("/project"),
794                OutputFormat::Human,
795                1,
796                true,
797                true,
798            )
799        }
800
801        proptest! {
802            /// Resolved config always has non-empty ignore patterns (defaults are always added).
803            #[test]
804            fn resolved_config_has_default_ignores(production in any::<bool>()) {
805                let resolved = arb_resolved_config(production);
806                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
807                prop_assert!(
808                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
809                    "Default ignore should match node_modules"
810                );
811                prop_assert!(
812                    resolved.ignore_patterns.is_match("dist/bundle.js"),
813                    "Default ignore should match dist"
814                );
815            }
816
817            /// Production mode always forces dev and optional deps to Off.
818            #[test]
819            fn production_forces_dev_deps_off(_unused in Just(())) {
820                let resolved = arb_resolved_config(true);
821                prop_assert_eq!(
822                    resolved.rules.unused_dev_dependencies,
823                    Severity::Off,
824                    "Production should force unused_dev_dependencies off"
825                );
826                prop_assert_eq!(
827                    resolved.rules.unused_optional_dependencies,
828                    Severity::Off,
829                    "Production should force unused_optional_dependencies off"
830                );
831            }
832
833            /// Non-production mode preserves default severity for dev deps.
834            #[test]
835            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
836                let resolved = arb_resolved_config(false);
837                prop_assert_eq!(
838                    resolved.rules.unused_dev_dependencies,
839                    Severity::Error,
840                    "Non-production should keep default dev dep severity"
841                );
842            }
843
844            /// Cache dir is always root/.fallow.
845            #[test]
846            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
847                let root = PathBuf::from(format!("/project/{dir_suffix}"));
848                let expected_cache = root.join(".fallow");
849                let resolved = make_config(false).resolve(
850                    root,
851                    OutputFormat::Human,
852                    1,
853                    true,
854                    true,
855                );
856                prop_assert_eq!(
857                    resolved.cache_dir, expected_cache,
858                    "Cache dir should be root/.fallow"
859                );
860            }
861
862            /// Thread count is always passed through exactly.
863            #[test]
864            fn threads_passed_through(threads in 1..64usize) {
865                let resolved = make_config(false).resolve(
866                    PathBuf::from("/project"),
867                    OutputFormat::Human,
868                    threads,
869                    true,
870                    true,
871                );
872                prop_assert_eq!(
873                    resolved.threads, threads,
874                    "Thread count should be passed through"
875                );
876            }
877
878            /// Custom ignore patterns are merged with defaults, not replacing them.
879            #[test]
880            fn custom_ignores_dont_replace_defaults(pattern in "[a-zA-Z0-9_*/.]{1,30}") {
881                let mut config = make_config(false);
882                config.ignore_patterns = vec![pattern];
883                let resolved = config.resolve(
884                    PathBuf::from("/project"),
885                    OutputFormat::Human,
886                    1,
887                    true,
888                    true,
889                );
890                // Defaults should still be present
891                prop_assert!(
892                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
893                    "Default node_modules ignore should still be active"
894                );
895            }
896        }
897    }
898}