Skip to main content

fallow_config/config/
resolution.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::FallowConfig;
22use super::IgnoreExportsUsedInFileConfig;
23
24/// Process-local dedup state for inter-file rule warnings.
25///
26/// Workspace mode calls `FallowConfig::resolve()` once per package, so a single
27/// top-level config with `overrides.rules.{duplicate-exports,circular-dependency}`
28/// would otherwise emit the same warning N times. The set is keyed on a stable
29/// hash of (rule name, sorted glob list) so logically identical override blocks
30/// dedupe across all package resolves.
31///
32/// The state persists across resolves within a single process. That matches the
33/// CLI's "one warning per invocation" expectation. In long-running hosts
34/// (`fallow watch`, the LSP server, NAPI consumers re-using a worker, the MCP
35/// server) the same set survives between re-runs and re-loads, so a user who
36/// edits the config and triggers a re-analysis sees the warning at most once
37/// per process lifetime. That is the documented behavior; restarting the host
38/// re-arms the warning.
39static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
40
41/// Stable hash of `(rule_name, sorted glob list)`.
42///
43/// Sorting deduplicates `["a/*", "b/*"]` against `["b/*", "a/*"]`. The element-
44/// wise hash loop is explicit so the lint sees the sorted Vec as read.
45fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
46    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
47    sorted.sort_unstable();
48    let mut hasher = DefaultHasher::new();
49    rule_name.hash(&mut hasher);
50    for s in &sorted {
51        s.hash(&mut hasher);
52    }
53    hasher.finish()
54}
55
56/// Returns `true` if this `(rule_name, files)` warning has not yet been recorded
57/// in the current process; `false` if it has already fired (or the mutex was
58/// poisoned, in which case we behave as if the warning had not fired yet so the
59/// user still sees one warning).
60fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
61    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
62    let key = inter_file_warn_key(rule_name, files);
63    seen.lock().map_or(true, |mut set| set.insert(key))
64}
65
66#[cfg(test)]
67fn reset_inter_file_warn_dedup_for_test() {
68    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
69        && let Ok(mut set) = seen.lock()
70    {
71        set.clear();
72    }
73}
74
75/// Rule for ignoring specific exports.
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
77pub struct IgnoreExportRule {
78    /// Glob pattern for files.
79    pub file: String,
80    /// Export names to ignore (`*` for all).
81    pub exports: Vec<String>,
82}
83
84/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
85///
86/// Workspace mode runs `find_unused_exports` and `find_duplicate_exports` once
87/// per package, each of which previously re-compiled the same set of globs from
88/// `ignore_export_rules`. Compiling once at `ResolvedConfig` construction and
89/// reading `&[CompiledIgnoreExportRule]` from both detectors removes that work.
90#[derive(Debug)]
91pub struct CompiledIgnoreExportRule {
92    pub matcher: globset::GlobMatcher,
93    pub exports: Vec<String>,
94}
95
96/// Rule for suppressing an `unresolved-catalog-reference` finding.
97///
98/// A finding is suppressed when ALL provided fields match the finding:
99/// - `package` matches the consumed package name exactly (case-sensitive).
100/// - `catalog`, if set, matches the referenced catalog name (`"default"` for
101///   bare `catalog:` references; named catalogs use their declared key). When
102///   omitted, any catalog matches.
103/// - `consumer`, if set, is a glob matched against the consumer `package.json`
104///   path relative to the project root. When omitted, any consumer matches.
105///
106/// Typical use cases:
107/// - Staged migrations: catalog entry is being added in a separate PR
108/// - Library-internal placeholder packages whose target catalog isn't ready yet
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct IgnoreCatalogReferenceRule {
112    /// Package name being referenced via the catalog protocol (exact match).
113    pub package: String,
114    /// Catalog name to scope the suppression to. `None` matches any catalog.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub catalog: Option<String>,
117    /// Glob (root-relative) for the consumer `package.json`. `None` matches any consumer.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub consumer: Option<String>,
120}
121
122/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
123#[derive(Debug)]
124pub struct CompiledIgnoreCatalogReferenceRule {
125    pub package: String,
126    pub catalog: Option<String>,
127    /// `None` means "match any consumer path"; `Some` matches only paths the glob accepts.
128    pub consumer_matcher: Option<globset::GlobMatcher>,
129}
130
131impl CompiledIgnoreCatalogReferenceRule {
132    /// Whether this rule suppresses an `unresolved-catalog-reference` finding
133    /// for the given (package, catalog, consumer-path) triple. The consumer
134    /// path must be project-root-relative.
135    #[must_use]
136    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
137        if self.package != package {
138            return false;
139        }
140        if let Some(catalog_filter) = &self.catalog
141            && catalog_filter != catalog
142        {
143            return false;
144        }
145        if let Some(matcher) = &self.consumer_matcher
146            && !matcher.is_match(consumer_path)
147        {
148            return false;
149        }
150        true
151    }
152}
153
154/// Rule for suppressing an `unused-dependency-override` or
155/// `misconfigured-dependency-override` finding.
156///
157/// A finding is suppressed when ALL provided fields match the finding:
158/// - `package` matches the override's target package name exactly
159///   (case-sensitive). For parent-chain overrides (`react>react-dom`), the
160///   target is the rightmost segment (`react-dom`).
161/// - `source`, if set, scopes the suppression to overrides declared in that
162///   source file. Accepts `"pnpm-workspace.yaml"` or `"package.json"`.
163///   When omitted, both sources match.
164///
165/// Typical use cases:
166/// - Library-internal CI tooling overrides we cannot drop yet
167/// - Overrides targeting purely-transitive packages (CVE-fix pattern)
168#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct IgnoreDependencyOverrideRule {
171    /// Override target package name (exact match; case-sensitive).
172    pub package: String,
173    /// Source file scope: `"pnpm-workspace.yaml"` or `"package.json"`.
174    /// `None` matches both sources.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<String>,
177}
178
179/// `IgnoreDependencyOverrideRule` ready for matching.
180#[derive(Debug)]
181pub struct CompiledIgnoreDependencyOverrideRule {
182    pub package: String,
183    /// `None` matches any source; `Some` matches only the named source.
184    pub source: Option<String>,
185}
186
187impl CompiledIgnoreDependencyOverrideRule {
188    /// Whether this rule suppresses a dependency-override finding for the
189    /// given (target_package, source_label) pair. `source_label` should be
190    /// `"pnpm-workspace.yaml"` or `"package.json"`.
191    #[must_use]
192    pub fn matches(&self, package: &str, source_label: &str) -> bool {
193        if self.package != package {
194            return false;
195        }
196        if let Some(source_filter) = &self.source
197            && source_filter != source_label
198        {
199            return false;
200        }
201        true
202    }
203}
204
205/// Per-file override entry.
206#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct ConfigOverride {
209    /// Glob patterns to match files against (relative to config file location).
210    pub files: Vec<String>,
211    /// Partial rules — only specified fields override the base rules.
212    #[serde(default)]
213    pub rules: PartialRulesConfig,
214}
215
216/// Resolved override with pre-compiled glob matchers.
217#[derive(Debug)]
218pub struct ResolvedOverride {
219    pub matchers: Vec<globset::GlobMatcher>,
220    pub rules: PartialRulesConfig,
221}
222
223/// Fully resolved configuration with all globs pre-compiled.
224#[derive(Debug)]
225pub struct ResolvedConfig {
226    pub root: PathBuf,
227    pub entry_patterns: Vec<String>,
228    pub ignore_patterns: GlobSet,
229    pub output: OutputFormat,
230    pub cache_dir: PathBuf,
231    pub threads: usize,
232    pub no_cache: bool,
233    pub ignore_dependencies: Vec<String>,
234    pub ignore_export_rules: Vec<IgnoreExportRule>,
235    /// Pre-compiled glob matchers for `ignoreExports`.
236    ///
237    /// Populated alongside `ignore_export_rules` so detectors that need to test
238    /// "does this file match a configured `ignoreExports` glob?" can read the
239    /// compiled matchers without re-running `globset::Glob::new` per call.
240    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
241    /// Pre-compiled rules for suppressing `unresolved-catalog-reference` findings.
242    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
243    /// Pre-compiled rules for suppressing dependency-override findings (both
244    /// `unused-dependency-override` and `misconfigured-dependency-override`).
245    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
246    /// Whether same-file references should suppress unused-export findings.
247    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
248    /// Class member names that should never be flagged as unused-class-members.
249    /// Union of top-level config and active plugin contributions; merged during
250    /// config resolution so analysis code reads a single list.
251    pub used_class_members: Vec<UsedClassMemberRule>,
252    pub duplicates: DuplicatesConfig,
253    pub health: HealthConfig,
254    pub rules: RulesConfig,
255    /// Resolved architecture boundary configuration with pre-compiled glob matchers.
256    pub boundaries: ResolvedBoundaryConfig,
257    /// Whether production mode is active.
258    pub production: bool,
259    /// Suppress progress output and non-essential stderr messages.
260    pub quiet: bool,
261    /// External plugin definitions (from plugin files + inline framework definitions).
262    pub external_plugins: Vec<ExternalPluginDef>,
263    /// Glob patterns for dynamically loaded files (treated as always-used).
264    pub dynamically_loaded: Vec<String>,
265    /// Per-file rule overrides with pre-compiled glob matchers.
266    pub overrides: Vec<ResolvedOverride>,
267    /// Regression config (passed through from user config, not resolved).
268    pub regression: Option<super::RegressionConfig>,
269    /// Audit baseline paths (passed through from user config, not resolved).
270    pub audit: super::AuditConfig,
271    /// Optional CODEOWNERS file path (passed through for `--group-by owner`).
272    pub codeowners: Option<String>,
273    /// Workspace package name patterns that are public libraries.
274    /// Exported API surface from these packages is not flagged as unused.
275    pub public_packages: Vec<String>,
276    /// Feature flag detection configuration.
277    pub flags: FlagsConfig,
278    /// Module resolver configuration (user-supplied import/export conditions).
279    pub resolve: ResolveConfig,
280    /// When true, entry file exports are subject to unused-export detection
281    /// instead of being automatically marked as used. Set via the global CLI flag
282    /// `--include-entry-exports` or via `includeEntryExports: true` in the fallow
283    /// config file; the CLI flag ORs with the config value (CLI wins when set).
284    pub include_entry_exports: bool,
285}
286
287impl FallowConfig {
288    /// Resolve into a fully resolved config with compiled globs.
289    pub fn resolve(
290        self,
291        root: PathBuf,
292        output: OutputFormat,
293        threads: usize,
294        no_cache: bool,
295        quiet: bool,
296    ) -> ResolvedConfig {
297        let mut ignore_builder = GlobSetBuilder::new();
298        for pattern in &self.ignore_patterns {
299            match Glob::new(pattern) {
300                Ok(glob) => {
301                    ignore_builder.add(glob);
302                }
303                Err(e) => {
304                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
305                }
306            }
307        }
308
309        // Default ignores
310        // Note: `build/` is only ignored at the project root (not `**/build/**`)
311        // because nested `build/` directories like `test/build/` may contain source files.
312        let default_ignores = [
313            "**/node_modules/**",
314            "**/dist/**",
315            "build/**",
316            "**/.git/**",
317            "**/coverage/**",
318            "**/*.min.js",
319            "**/*.min.mjs",
320        ];
321        for pattern in &default_ignores {
322            if let Ok(glob) = Glob::new(pattern) {
323                ignore_builder.add(glob);
324            }
325        }
326
327        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
328        let cache_dir = root.join(".fallow");
329
330        let mut rules = self.rules;
331
332        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
333        let production = self.production.global();
334        if production {
335            rules.unused_dev_dependencies = Severity::Off;
336            rules.unused_optional_dependencies = Severity::Off;
337        }
338
339        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
340        // Merge inline framework definitions into external plugins
341        external_plugins.extend(self.framework);
342
343        // Expand boundary preset (if configured) before validation.
344        // Detect source root from tsconfig.json, falling back to "src".
345        let mut boundaries = self.boundaries;
346        if boundaries.preset.is_some() {
347            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
348                .filter(|r| {
349                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
350                })
351                .unwrap_or_else(|| "src".to_owned());
352            if source_root != "src" {
353                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
354            }
355            boundaries.expand(&source_root);
356        }
357        // MUST run AFTER `expand` and BEFORE `validate_zone_references`. Presets
358        // like Bulletproof emit a rule whose `from` is the logical group name
359        // (`features`) that auto-discovery later replaces with concrete child
360        // zones (`features/auth`, `features/billing`). Moving validation above
361        // expansion makes the preset look like it references undefined zones.
362        boundaries.expand_auto_discover(&root);
363
364        // Validate and compile architecture boundary config
365        let validation_errors = boundaries.validate_zone_references();
366        for (rule_idx, zone_name) in &validation_errors {
367            tracing::error!(
368                "boundary rule {} references undefined zone '{zone_name}'",
369                rule_idx
370            );
371        }
372        for message in boundaries.validate_root_prefixes() {
373            tracing::error!("{message}");
374        }
375        let boundaries = boundaries.resolve();
376
377        // Pre-compile override glob matchers
378        let overrides = self
379            .overrides
380            .into_iter()
381            .filter_map(|o| {
382                // Inter-file rules group findings across multiple files (a
383                // single duplicate-exports finding spans N files; a single
384                // circular-dependency finding spans M files in a cycle), so a
385                // per-file `overrides.rules` setting cannot meaningfully turn
386                // them off: the override only fires when the path being looked
387                // up matches, but the finding belongs to a group of paths, not
388                // to one. Warn at load time and point users at the working
389                // escape hatch (`ignoreExports` for duplicates, file-level
390                // `// fallow-ignore-file circular-dependency` for cycles).
391                if o.rules.duplicate_exports.is_some()
392                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
393                {
394                    let files = o.files.join(", ");
395                    tracing::warn!(
396                        "overrides.rules.duplicate-exports has no effect for files matching [{files}]: duplicate-exports is an inter-file rule. Use top-level `ignoreExports` to exclude these files from duplicate-export grouping."
397                    );
398                }
399                if o.rules.circular_dependencies.is_some()
400                    && record_inter_file_warn_seen("circular-dependency", &o.files)
401                {
402                    let files = o.files.join(", ");
403                    tracing::warn!(
404                        "overrides.rules.circular-dependency has no effect for files matching [{files}]: circular-dependency is an inter-file rule. Use a file-level `// fallow-ignore-file circular-dependency` comment in one participating file instead."
405                    );
406                }
407                let matchers: Vec<globset::GlobMatcher> = o
408                    .files
409                    .iter()
410                    .filter_map(|pattern| match Glob::new(pattern) {
411                        Ok(glob) => Some(glob.compile_matcher()),
412                        Err(e) => {
413                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
414                            None
415                        }
416                    })
417                    .collect();
418                if matchers.is_empty() {
419                    None
420                } else {
421                    Some(ResolvedOverride {
422                        matchers,
423                        rules: o.rules,
424                    })
425                }
426            })
427            .collect();
428
429        // Compile `ignoreExports` once at resolve time so both `find_unused_exports`
430        // and `find_duplicate_exports` can read pre-built matchers from
431        // `ResolvedConfig` instead of re-compiling per call. Invalid globs warn
432        // once here, replacing the previous "warn-on-every-detector-call" path.
433        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
434            .ignore_exports
435            .iter()
436            .filter_map(|rule| match Glob::new(&rule.file) {
437                Ok(g) => Some(CompiledIgnoreExportRule {
438                    matcher: g.compile_matcher(),
439                    exports: rule.exports.clone(),
440                }),
441                Err(e) => {
442                    tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
443                    None
444                }
445            })
446            .collect();
447
448        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
449            .ignore_catalog_references
450            .iter()
451            .filter_map(|rule| {
452                let consumer_matcher = match &rule.consumer {
453                    Some(pattern) => match Glob::new(pattern) {
454                        Ok(g) => Some(g.compile_matcher()),
455                        Err(e) => {
456                            tracing::warn!(
457                                "invalid ignoreCatalogReferences consumer glob '{pattern}': {e}"
458                            );
459                            return None;
460                        }
461                    },
462                    None => None,
463                };
464                Some(CompiledIgnoreCatalogReferenceRule {
465                    package: rule.package.clone(),
466                    catalog: rule.catalog.clone(),
467                    consumer_matcher,
468                })
469            })
470            .collect();
471
472        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
473            .ignore_dependency_overrides
474            .iter()
475            .map(|rule| CompiledIgnoreDependencyOverrideRule {
476                package: rule.package.clone(),
477                source: rule.source.clone(),
478            })
479            .collect();
480
481        ResolvedConfig {
482            root,
483            entry_patterns: self.entry,
484            ignore_patterns: compiled_ignore_patterns,
485            output,
486            cache_dir,
487            threads,
488            no_cache,
489            ignore_dependencies: self.ignore_dependencies,
490            ignore_export_rules: self.ignore_exports,
491            compiled_ignore_exports,
492            compiled_ignore_catalog_references,
493            compiled_ignore_dependency_overrides,
494            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
495            used_class_members: self.used_class_members,
496            duplicates: self.duplicates,
497            health: self.health,
498            rules,
499            boundaries,
500            production,
501            quiet,
502            external_plugins,
503            dynamically_loaded: self.dynamically_loaded,
504            overrides,
505            regression: self.regression,
506            audit: self.audit,
507            codeowners: self.codeowners,
508            public_packages: self.public_packages,
509            flags: self.flags,
510            resolve: self.resolve,
511            include_entry_exports: self.include_entry_exports,
512        }
513    }
514}
515
516impl ResolvedConfig {
517    /// Resolve the effective rules for a given file path.
518    /// Starts with base rules and applies matching overrides in order.
519    #[must_use]
520    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
521        if self.overrides.is_empty() {
522            return self.rules.clone();
523        }
524
525        let relative = path.strip_prefix(&self.root).unwrap_or(path);
526        let relative_str = relative.to_string_lossy();
527
528        let mut rules = self.rules.clone();
529        for override_entry in &self.overrides {
530            let matches = override_entry
531                .matchers
532                .iter()
533                .any(|m| m.is_match(relative_str.as_ref()));
534            if matches {
535                rules.apply_partial(&override_entry.rules);
536            }
537        }
538        rules
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::config::boundaries::BoundaryConfig;
546    use crate::config::health::HealthConfig;
547
548    #[test]
549    fn overrides_deserialize() {
550        let json_str = r#"{
551            "overrides": [{
552                "files": ["*.test.ts"],
553                "rules": {
554                    "unused-exports": "off"
555                }
556            }]
557        }"#;
558        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
559        assert_eq!(config.overrides.len(), 1);
560        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
561        assert_eq!(
562            config.overrides[0].rules.unused_exports,
563            Some(Severity::Off)
564        );
565        assert_eq!(config.overrides[0].rules.unused_files, None);
566    }
567
568    #[test]
569    fn resolve_rules_for_path_no_overrides() {
570        let config = FallowConfig {
571            schema: None,
572            extends: vec![],
573            entry: vec![],
574            ignore_patterns: vec![],
575            framework: vec![],
576            workspaces: None,
577            ignore_dependencies: vec![],
578            ignore_exports: vec![],
579            ignore_catalog_references: vec![],
580            ignore_dependency_overrides: vec![],
581            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
582            used_class_members: vec![],
583            duplicates: DuplicatesConfig::default(),
584            health: HealthConfig::default(),
585            rules: RulesConfig::default(),
586            boundaries: BoundaryConfig::default(),
587            production: false.into(),
588            plugins: vec![],
589            dynamically_loaded: vec![],
590            overrides: vec![],
591            regression: None,
592            audit: crate::config::AuditConfig::default(),
593            codeowners: None,
594            public_packages: vec![],
595            flags: FlagsConfig::default(),
596            resolve: ResolveConfig::default(),
597            sealed: false,
598            include_entry_exports: false,
599        };
600        let resolved = config.resolve(
601            PathBuf::from("/project"),
602            OutputFormat::Human,
603            1,
604            true,
605            true,
606        );
607        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
608        assert_eq!(rules.unused_files, Severity::Error);
609    }
610
611    #[test]
612    fn resolve_rules_for_path_with_matching_override() {
613        let config = FallowConfig {
614            schema: None,
615            extends: vec![],
616            entry: vec![],
617            ignore_patterns: vec![],
618            framework: vec![],
619            workspaces: None,
620            ignore_dependencies: vec![],
621            ignore_exports: vec![],
622            ignore_catalog_references: vec![],
623            ignore_dependency_overrides: vec![],
624            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
625            used_class_members: vec![],
626            duplicates: DuplicatesConfig::default(),
627            health: HealthConfig::default(),
628            rules: RulesConfig::default(),
629            boundaries: BoundaryConfig::default(),
630            production: false.into(),
631            plugins: vec![],
632            dynamically_loaded: vec![],
633            overrides: vec![ConfigOverride {
634                files: vec!["*.test.ts".to_string()],
635                rules: PartialRulesConfig {
636                    unused_exports: Some(Severity::Off),
637                    ..Default::default()
638                },
639            }],
640            regression: None,
641            audit: crate::config::AuditConfig::default(),
642            codeowners: None,
643            public_packages: vec![],
644            flags: FlagsConfig::default(),
645            resolve: ResolveConfig::default(),
646            sealed: false,
647            include_entry_exports: false,
648        };
649        let resolved = config.resolve(
650            PathBuf::from("/project"),
651            OutputFormat::Human,
652            1,
653            true,
654            true,
655        );
656
657        // Test file matches override
658        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
659        assert_eq!(test_rules.unused_exports, Severity::Off);
660        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
661
662        // Non-test file does not match
663        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
664        assert_eq!(src_rules.unused_exports, Severity::Error);
665    }
666
667    #[test]
668    fn resolve_rules_for_path_later_override_wins() {
669        let config = FallowConfig {
670            schema: None,
671            extends: vec![],
672            entry: vec![],
673            ignore_patterns: vec![],
674            framework: vec![],
675            workspaces: None,
676            ignore_dependencies: vec![],
677            ignore_exports: vec![],
678            ignore_catalog_references: vec![],
679            ignore_dependency_overrides: vec![],
680            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
681            used_class_members: vec![],
682            duplicates: DuplicatesConfig::default(),
683            health: HealthConfig::default(),
684            rules: RulesConfig::default(),
685            boundaries: BoundaryConfig::default(),
686            production: false.into(),
687            plugins: vec![],
688            dynamically_loaded: vec![],
689            overrides: vec![
690                ConfigOverride {
691                    files: vec!["*.ts".to_string()],
692                    rules: PartialRulesConfig {
693                        unused_files: Some(Severity::Warn),
694                        ..Default::default()
695                    },
696                },
697                ConfigOverride {
698                    files: vec!["*.test.ts".to_string()],
699                    rules: PartialRulesConfig {
700                        unused_files: Some(Severity::Off),
701                        ..Default::default()
702                    },
703                },
704            ],
705            regression: None,
706            audit: crate::config::AuditConfig::default(),
707            codeowners: None,
708            public_packages: vec![],
709            flags: FlagsConfig::default(),
710            resolve: ResolveConfig::default(),
711            sealed: false,
712            include_entry_exports: false,
713        };
714        let resolved = config.resolve(
715            PathBuf::from("/project"),
716            OutputFormat::Human,
717            1,
718            true,
719            true,
720        );
721
722        // First override matches *.ts, second matches *.test.ts; second wins
723        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
724        assert_eq!(rules.unused_files, Severity::Off);
725
726        // Non-test .ts file only matches first override
727        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
728        assert_eq!(rules2.unused_files, Severity::Warn);
729    }
730
731    #[test]
732    fn resolve_keeps_inter_file_rule_override_after_warning() {
733        // Setting `overrides.rules.duplicate-exports` for a file glob is a no-op
734        // at finding-time (duplicate-exports groups span multiple files), but the
735        // override must still resolve cleanly so other co-located rule settings
736        // on the same override are honored. The resolver emits a tracing warning;
737        // here we assert the override is still installed for non-inter-file rules.
738        let config = FallowConfig {
739            schema: None,
740            extends: vec![],
741            entry: vec![],
742            ignore_patterns: vec![],
743            framework: vec![],
744            workspaces: None,
745            ignore_dependencies: vec![],
746            ignore_exports: vec![],
747            ignore_catalog_references: vec![],
748            ignore_dependency_overrides: vec![],
749            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
750            used_class_members: vec![],
751            duplicates: DuplicatesConfig::default(),
752            health: HealthConfig::default(),
753            rules: RulesConfig::default(),
754            boundaries: BoundaryConfig::default(),
755            production: false.into(),
756            plugins: vec![],
757            dynamically_loaded: vec![],
758            overrides: vec![ConfigOverride {
759                files: vec!["**/ui/**".to_string()],
760                rules: PartialRulesConfig {
761                    duplicate_exports: Some(Severity::Off),
762                    unused_files: Some(Severity::Warn),
763                    ..Default::default()
764                },
765            }],
766            regression: None,
767            audit: crate::config::AuditConfig::default(),
768            codeowners: None,
769            public_packages: vec![],
770            flags: FlagsConfig::default(),
771            resolve: ResolveConfig::default(),
772            sealed: false,
773            include_entry_exports: false,
774        };
775        let resolved = config.resolve(
776            PathBuf::from("/project"),
777            OutputFormat::Human,
778            1,
779            true,
780            true,
781        );
782        assert_eq!(
783            resolved.overrides.len(),
784            1,
785            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
786        );
787        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
788        assert_eq!(rules.unused_files, Severity::Warn);
789    }
790
791    #[test]
792    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
793        // Reset shared state so test ordering does not affect the assertions
794        // below. Uses unique glob strings (`__test_dedup_*`) so other tests in
795        // this module that exercise the warn path do not collide.
796        reset_inter_file_warn_dedup_for_test();
797        let files_a = vec!["__test_dedup_a/*".to_string()];
798        let files_b = vec!["__test_dedup_b/*".to_string()];
799
800        // First call fires; subsequent identical calls do not.
801        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
802        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
803        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
804
805        // Different rule name is a distinct key.
806        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
807        assert!(!record_inter_file_warn_seen(
808            "circular-dependency",
809            &files_a
810        ));
811
812        // Different glob list is a distinct key.
813        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
814
815        // Order-insensitive glob list collapses to the same key.
816        let files_reordered = vec![
817            "__test_dedup_b/*".to_string(),
818            "__test_dedup_a/*".to_string(),
819        ];
820        let files_natural = vec![
821            "__test_dedup_a/*".to_string(),
822            "__test_dedup_b/*".to_string(),
823        ];
824        reset_inter_file_warn_dedup_for_test();
825        assert!(record_inter_file_warn_seen(
826            "duplicate-exports",
827            &files_natural
828        ));
829        assert!(!record_inter_file_warn_seen(
830            "duplicate-exports",
831            &files_reordered
832        ));
833    }
834
835    #[test]
836    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
837        // Drive `FallowConfig::resolve()` ten times with identical
838        // `overrides.rules.duplicate-exports` to mirror workspace mode (one
839        // resolve per package). The dedup must surface the warn key as
840        // already-seen on every call after the first.
841        reset_inter_file_warn_dedup_for_test();
842        let files = vec!["__test_resolve_dedup/**".to_string()];
843        let build_config = || FallowConfig {
844            schema: None,
845            extends: vec![],
846            entry: vec![],
847            ignore_patterns: vec![],
848            framework: vec![],
849            workspaces: None,
850            ignore_dependencies: vec![],
851            ignore_exports: vec![],
852            ignore_catalog_references: vec![],
853            ignore_dependency_overrides: vec![],
854            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
855            used_class_members: vec![],
856            duplicates: DuplicatesConfig::default(),
857            health: HealthConfig::default(),
858            rules: RulesConfig::default(),
859            boundaries: BoundaryConfig::default(),
860            production: false.into(),
861            plugins: vec![],
862            dynamically_loaded: vec![],
863            overrides: vec![ConfigOverride {
864                files: files.clone(),
865                rules: PartialRulesConfig {
866                    duplicate_exports: Some(Severity::Off),
867                    ..Default::default()
868                },
869            }],
870            regression: None,
871            audit: crate::config::AuditConfig::default(),
872            codeowners: None,
873            public_packages: vec![],
874            flags: FlagsConfig::default(),
875            resolve: ResolveConfig::default(),
876            sealed: false,
877            include_entry_exports: false,
878        };
879        for _ in 0..10 {
880            let _ = build_config().resolve(
881                PathBuf::from("/project"),
882                OutputFormat::Human,
883                1,
884                true,
885                true,
886            );
887        }
888        // After 10 resolves the dedup state holds the warn key. Asking the
889        // dedup helper for the SAME key returns false (already seen) instead
890        // of true (would fire).
891        assert!(
892            !record_inter_file_warn_seen("duplicate-exports", &files),
893            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
894        );
895    }
896
897    /// Helper to build a FallowConfig with minimal boilerplate.
898    fn make_config(production: bool) -> FallowConfig {
899        FallowConfig {
900            schema: None,
901            extends: vec![],
902            entry: vec![],
903            ignore_patterns: vec![],
904            framework: vec![],
905            workspaces: None,
906            ignore_dependencies: vec![],
907            ignore_exports: vec![],
908            ignore_catalog_references: vec![],
909            ignore_dependency_overrides: vec![],
910            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
911            used_class_members: vec![],
912            duplicates: DuplicatesConfig::default(),
913            health: HealthConfig::default(),
914            rules: RulesConfig::default(),
915            boundaries: BoundaryConfig::default(),
916            production: production.into(),
917            plugins: vec![],
918            dynamically_loaded: vec![],
919            overrides: vec![],
920            regression: None,
921            audit: crate::config::AuditConfig::default(),
922            codeowners: None,
923            public_packages: vec![],
924            flags: FlagsConfig::default(),
925            resolve: ResolveConfig::default(),
926            sealed: false,
927            include_entry_exports: false,
928        }
929    }
930
931    // ── Production mode ─────────────────────────────────────────────
932
933    #[test]
934    fn resolve_production_forces_dev_deps_off() {
935        let resolved = make_config(true).resolve(
936            PathBuf::from("/project"),
937            OutputFormat::Human,
938            1,
939            true,
940            true,
941        );
942        assert_eq!(
943            resolved.rules.unused_dev_dependencies,
944            Severity::Off,
945            "production mode should force unused_dev_dependencies to off"
946        );
947    }
948
949    #[test]
950    fn resolve_production_forces_optional_deps_off() {
951        let resolved = make_config(true).resolve(
952            PathBuf::from("/project"),
953            OutputFormat::Human,
954            1,
955            true,
956            true,
957        );
958        assert_eq!(
959            resolved.rules.unused_optional_dependencies,
960            Severity::Off,
961            "production mode should force unused_optional_dependencies to off"
962        );
963    }
964
965    #[test]
966    fn resolve_production_preserves_other_rules() {
967        let resolved = make_config(true).resolve(
968            PathBuf::from("/project"),
969            OutputFormat::Human,
970            1,
971            true,
972            true,
973        );
974        // Other rules should remain at their defaults
975        assert_eq!(resolved.rules.unused_files, Severity::Error);
976        assert_eq!(resolved.rules.unused_exports, Severity::Error);
977        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
978    }
979
980    #[test]
981    fn resolve_non_production_keeps_dev_deps_default() {
982        let resolved = make_config(false).resolve(
983            PathBuf::from("/project"),
984            OutputFormat::Human,
985            1,
986            true,
987            true,
988        );
989        assert_eq!(
990            resolved.rules.unused_dev_dependencies,
991            Severity::Warn,
992            "non-production should keep default severity"
993        );
994        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
995    }
996
997    #[test]
998    fn resolve_production_flag_stored() {
999        let resolved = make_config(true).resolve(
1000            PathBuf::from("/project"),
1001            OutputFormat::Human,
1002            1,
1003            true,
1004            true,
1005        );
1006        assert!(resolved.production);
1007
1008        let resolved2 = make_config(false).resolve(
1009            PathBuf::from("/project"),
1010            OutputFormat::Human,
1011            1,
1012            true,
1013            true,
1014        );
1015        assert!(!resolved2.production);
1016    }
1017
1018    // ── Default ignore patterns ─────────────────────────────────────
1019
1020    #[test]
1021    fn resolve_default_ignores_node_modules() {
1022        let resolved = make_config(false).resolve(
1023            PathBuf::from("/project"),
1024            OutputFormat::Human,
1025            1,
1026            true,
1027            true,
1028        );
1029        assert!(
1030            resolved
1031                .ignore_patterns
1032                .is_match("node_modules/lodash/index.js")
1033        );
1034        assert!(
1035            resolved
1036                .ignore_patterns
1037                .is_match("packages/a/node_modules/react/index.js")
1038        );
1039    }
1040
1041    #[test]
1042    fn resolve_default_ignores_dist() {
1043        let resolved = make_config(false).resolve(
1044            PathBuf::from("/project"),
1045            OutputFormat::Human,
1046            1,
1047            true,
1048            true,
1049        );
1050        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1051        assert!(
1052            resolved
1053                .ignore_patterns
1054                .is_match("packages/ui/dist/index.js")
1055        );
1056    }
1057
1058    #[test]
1059    fn resolve_default_ignores_root_build_only() {
1060        let resolved = make_config(false).resolve(
1061            PathBuf::from("/project"),
1062            OutputFormat::Human,
1063            1,
1064            true,
1065            true,
1066        );
1067        assert!(
1068            resolved.ignore_patterns.is_match("build/output.js"),
1069            "root build/ should be ignored"
1070        );
1071        // The pattern is `build/**` (root-only), not `**/build/**`
1072        assert!(
1073            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1074            "nested build/ should NOT be ignored by default"
1075        );
1076    }
1077
1078    #[test]
1079    fn resolve_default_ignores_minified_files() {
1080        let resolved = make_config(false).resolve(
1081            PathBuf::from("/project"),
1082            OutputFormat::Human,
1083            1,
1084            true,
1085            true,
1086        );
1087        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1088        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1089    }
1090
1091    #[test]
1092    fn resolve_default_ignores_git() {
1093        let resolved = make_config(false).resolve(
1094            PathBuf::from("/project"),
1095            OutputFormat::Human,
1096            1,
1097            true,
1098            true,
1099        );
1100        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1101    }
1102
1103    #[test]
1104    fn resolve_default_ignores_coverage() {
1105        let resolved = make_config(false).resolve(
1106            PathBuf::from("/project"),
1107            OutputFormat::Human,
1108            1,
1109            true,
1110            true,
1111        );
1112        assert!(
1113            resolved
1114                .ignore_patterns
1115                .is_match("coverage/lcov-report/index.js")
1116        );
1117    }
1118
1119    #[test]
1120    fn resolve_source_files_not_ignored_by_default() {
1121        let resolved = make_config(false).resolve(
1122            PathBuf::from("/project"),
1123            OutputFormat::Human,
1124            1,
1125            true,
1126            true,
1127        );
1128        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1129        assert!(
1130            !resolved
1131                .ignore_patterns
1132                .is_match("src/components/Button.tsx")
1133        );
1134        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1135    }
1136
1137    // ── Custom ignore patterns ──────────────────────────────────────
1138
1139    #[test]
1140    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1141        let mut config = make_config(false);
1142        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1143        let resolved = config.resolve(
1144            PathBuf::from("/project"),
1145            OutputFormat::Human,
1146            1,
1147            true,
1148            true,
1149        );
1150        // Custom pattern works
1151        assert!(
1152            resolved
1153                .ignore_patterns
1154                .is_match("src/__generated__/types.ts")
1155        );
1156        // Default patterns still work
1157        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1158    }
1159
1160    // ── Config fields passthrough ───────────────────────────────────
1161
1162    #[test]
1163    fn resolve_passes_through_entry_patterns() {
1164        let mut config = make_config(false);
1165        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1166        let resolved = config.resolve(
1167            PathBuf::from("/project"),
1168            OutputFormat::Human,
1169            1,
1170            true,
1171            true,
1172        );
1173        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1174    }
1175
1176    #[test]
1177    fn resolve_passes_through_ignore_dependencies() {
1178        let mut config = make_config(false);
1179        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1180        let resolved = config.resolve(
1181            PathBuf::from("/project"),
1182            OutputFormat::Human,
1183            1,
1184            true,
1185            true,
1186        );
1187        assert_eq!(
1188            resolved.ignore_dependencies,
1189            vec!["postcss", "autoprefixer"]
1190        );
1191    }
1192
1193    #[test]
1194    fn resolve_sets_cache_dir() {
1195        let resolved = make_config(false).resolve(
1196            PathBuf::from("/my/project"),
1197            OutputFormat::Human,
1198            1,
1199            true,
1200            true,
1201        );
1202        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1203    }
1204
1205    #[test]
1206    fn resolve_passes_through_thread_count() {
1207        let resolved = make_config(false).resolve(
1208            PathBuf::from("/project"),
1209            OutputFormat::Human,
1210            8,
1211            true,
1212            true,
1213        );
1214        assert_eq!(resolved.threads, 8);
1215    }
1216
1217    #[test]
1218    fn resolve_passes_through_quiet_flag() {
1219        let resolved = make_config(false).resolve(
1220            PathBuf::from("/project"),
1221            OutputFormat::Human,
1222            1,
1223            true,
1224            false,
1225        );
1226        assert!(!resolved.quiet);
1227
1228        let resolved2 = make_config(false).resolve(
1229            PathBuf::from("/project"),
1230            OutputFormat::Human,
1231            1,
1232            true,
1233            true,
1234        );
1235        assert!(resolved2.quiet);
1236    }
1237
1238    #[test]
1239    fn resolve_passes_through_no_cache_flag() {
1240        let resolved_no_cache = make_config(false).resolve(
1241            PathBuf::from("/project"),
1242            OutputFormat::Human,
1243            1,
1244            true,
1245            true,
1246        );
1247        assert!(resolved_no_cache.no_cache);
1248
1249        let resolved_with_cache = make_config(false).resolve(
1250            PathBuf::from("/project"),
1251            OutputFormat::Human,
1252            1,
1253            false,
1254            true,
1255        );
1256        assert!(!resolved_with_cache.no_cache);
1257    }
1258
1259    // ── Override resolution edge cases ───────────────────────────────
1260
1261    #[test]
1262    fn resolve_override_with_invalid_glob_skipped() {
1263        let mut config = make_config(false);
1264        config.overrides = vec![ConfigOverride {
1265            files: vec!["[invalid".to_string()],
1266            rules: PartialRulesConfig {
1267                unused_files: Some(Severity::Off),
1268                ..Default::default()
1269            },
1270        }];
1271        let resolved = config.resolve(
1272            PathBuf::from("/project"),
1273            OutputFormat::Human,
1274            1,
1275            true,
1276            true,
1277        );
1278        // Invalid glob should be skipped, so no overrides should be compiled
1279        assert!(
1280            resolved.overrides.is_empty(),
1281            "override with invalid glob should be skipped"
1282        );
1283    }
1284
1285    #[test]
1286    fn resolve_override_with_empty_files_skipped() {
1287        let mut config = make_config(false);
1288        config.overrides = vec![ConfigOverride {
1289            files: vec![],
1290            rules: PartialRulesConfig {
1291                unused_files: Some(Severity::Off),
1292                ..Default::default()
1293            },
1294        }];
1295        let resolved = config.resolve(
1296            PathBuf::from("/project"),
1297            OutputFormat::Human,
1298            1,
1299            true,
1300            true,
1301        );
1302        assert!(
1303            resolved.overrides.is_empty(),
1304            "override with no file patterns should be skipped"
1305        );
1306    }
1307
1308    #[test]
1309    fn resolve_multiple_valid_overrides() {
1310        let mut config = make_config(false);
1311        config.overrides = vec![
1312            ConfigOverride {
1313                files: vec!["*.test.ts".to_string()],
1314                rules: PartialRulesConfig {
1315                    unused_exports: Some(Severity::Off),
1316                    ..Default::default()
1317                },
1318            },
1319            ConfigOverride {
1320                files: vec!["*.stories.tsx".to_string()],
1321                rules: PartialRulesConfig {
1322                    unused_files: Some(Severity::Off),
1323                    ..Default::default()
1324                },
1325            },
1326        ];
1327        let resolved = config.resolve(
1328            PathBuf::from("/project"),
1329            OutputFormat::Human,
1330            1,
1331            true,
1332            true,
1333        );
1334        assert_eq!(resolved.overrides.len(), 2);
1335    }
1336
1337    // ── IgnoreExportRule ────────────────────────────────────────────
1338
1339    #[test]
1340    fn ignore_export_rule_deserialize() {
1341        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1342        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1343        assert_eq!(rule.file, "src/types/*.ts");
1344        assert_eq!(rule.exports, vec!["*"]);
1345    }
1346
1347    #[test]
1348    fn ignore_export_rule_specific_exports() {
1349        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1350        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1351        assert_eq!(rule.exports.len(), 3);
1352        assert!(rule.exports.contains(&"FOO".to_string()));
1353    }
1354
1355    mod proptests {
1356        use super::*;
1357        use proptest::prelude::*;
1358
1359        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1360            make_config(production).resolve(
1361                PathBuf::from("/project"),
1362                OutputFormat::Human,
1363                1,
1364                true,
1365                true,
1366            )
1367        }
1368
1369        proptest! {
1370            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1371            #[test]
1372            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1373                let resolved = arb_resolved_config(production);
1374                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
1375                prop_assert!(
1376                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1377                    "Default ignore should match node_modules"
1378                );
1379                prop_assert!(
1380                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1381                    "Default ignore should match dist"
1382                );
1383            }
1384
1385            /// Production mode always forces dev and optional deps to Off.
1386            #[test]
1387            fn production_forces_dev_deps_off(_unused in Just(())) {
1388                let resolved = arb_resolved_config(true);
1389                prop_assert_eq!(
1390                    resolved.rules.unused_dev_dependencies,
1391                    Severity::Off,
1392                    "Production should force unused_dev_dependencies off"
1393                );
1394                prop_assert_eq!(
1395                    resolved.rules.unused_optional_dependencies,
1396                    Severity::Off,
1397                    "Production should force unused_optional_dependencies off"
1398                );
1399            }
1400
1401            /// Non-production mode preserves default severity for dev deps.
1402            #[test]
1403            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1404                let resolved = arb_resolved_config(false);
1405                prop_assert_eq!(
1406                    resolved.rules.unused_dev_dependencies,
1407                    Severity::Warn,
1408                    "Non-production should keep default dev dep severity"
1409                );
1410            }
1411
1412            /// Cache dir is always root/.fallow.
1413            #[test]
1414            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1415                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1416                let expected_cache = root.join(".fallow");
1417                let resolved = make_config(false).resolve(
1418                    root,
1419                    OutputFormat::Human,
1420                    1,
1421                    true,
1422                    true,
1423                );
1424                prop_assert_eq!(
1425                    resolved.cache_dir, expected_cache,
1426                    "Cache dir should be root/.fallow"
1427                );
1428            }
1429
1430            /// Thread count is always passed through exactly.
1431            #[test]
1432            fn threads_passed_through(threads in 1..64usize) {
1433                let resolved = make_config(false).resolve(
1434                    PathBuf::from("/project"),
1435                    OutputFormat::Human,
1436                    threads,
1437                    true,
1438                    true,
1439                );
1440                prop_assert_eq!(
1441                    resolved.threads, threads,
1442                    "Thread count should be passed through"
1443                );
1444            }
1445
1446            /// Custom ignore patterns are merged with defaults, not replacing them.
1447            /// Uses a pattern regex that cannot match node_modules paths, so the
1448            /// assertion proves the default pattern is what provides the match.
1449            #[test]
1450            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1451                let mut config = make_config(false);
1452                config.ignore_patterns = vec![pattern];
1453                let resolved = config.resolve(
1454                    PathBuf::from("/project"),
1455                    OutputFormat::Human,
1456                    1,
1457                    true,
1458                    true,
1459                );
1460                // Defaults should still be present (the custom pattern cannot
1461                // match this path, so only the default **/node_modules/** can)
1462                prop_assert!(
1463                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1464                    "Default node_modules ignore should still be active"
1465                );
1466            }
1467        }
1468    }
1469
1470    // ── Boundary preset expansion ──────────────────────────────────
1471
1472    #[test]
1473    fn resolve_expands_boundary_preset() {
1474        use crate::config::boundaries::BoundaryPreset;
1475
1476        let mut config = make_config(false);
1477        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1478        let resolved = config.resolve(
1479            PathBuf::from("/project"),
1480            OutputFormat::Human,
1481            1,
1482            true,
1483            true,
1484        );
1485        // Preset should have been expanded into zones (no tsconfig → fallback to "src")
1486        assert_eq!(resolved.boundaries.zones.len(), 3);
1487        assert_eq!(resolved.boundaries.rules.len(), 3);
1488        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1489        assert_eq!(
1490            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1491            Some("adapters")
1492        );
1493    }
1494
1495    #[test]
1496    fn resolve_boundary_preset_with_user_override() {
1497        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1498
1499        let mut config = make_config(false);
1500        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1501        config.boundaries.zones = vec![BoundaryZone {
1502            name: "domain".to_string(),
1503            patterns: vec!["src/core/**".to_string()],
1504            auto_discover: vec![],
1505            root: None,
1506        }];
1507        let resolved = config.resolve(
1508            PathBuf::from("/project"),
1509            OutputFormat::Human,
1510            1,
1511            true,
1512            true,
1513        );
1514        // User zone "domain" replaced preset zone "domain"
1515        assert_eq!(resolved.boundaries.zones.len(), 3);
1516        // The user's pattern should be used for domain zone
1517        assert_eq!(
1518            resolved.boundaries.classify_zone("src/core/user.ts"),
1519            Some("domain")
1520        );
1521        // Original preset pattern should NOT match
1522        assert_eq!(
1523            resolved.boundaries.classify_zone("src/domain/user.ts"),
1524            None
1525        );
1526    }
1527
1528    #[test]
1529    fn resolve_no_preset_unchanged() {
1530        let config = make_config(false);
1531        let resolved = config.resolve(
1532            PathBuf::from("/project"),
1533            OutputFormat::Human,
1534            1,
1535            true,
1536            true,
1537        );
1538        assert!(resolved.boundaries.is_empty());
1539    }
1540}