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    /// Auto-fix behavior settings.
279    pub fix: super::FixConfig,
280    /// Module resolver configuration (user-supplied import/export conditions).
281    pub resolve: ResolveConfig,
282    /// When true, entry file exports are subject to unused-export detection
283    /// instead of being automatically marked as used. Set via the global CLI flag
284    /// `--include-entry-exports` or via `includeEntryExports: true` in the fallow
285    /// config file; the CLI flag ORs with the config value (CLI wins when set).
286    pub include_entry_exports: bool,
287}
288
289impl FallowConfig {
290    /// Resolve into a fully resolved config with compiled globs.
291    pub fn resolve(
292        self,
293        root: PathBuf,
294        output: OutputFormat,
295        threads: usize,
296        no_cache: bool,
297        quiet: bool,
298    ) -> ResolvedConfig {
299        let mut ignore_builder = GlobSetBuilder::new();
300        for pattern in &self.ignore_patterns {
301            match Glob::new(pattern) {
302                Ok(glob) => {
303                    ignore_builder.add(glob);
304                }
305                Err(e) => {
306                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
307                }
308            }
309        }
310
311        // Default ignores
312        // Note: `build/` is only ignored at the project root (not `**/build/**`)
313        // because nested `build/` directories like `test/build/` may contain source files.
314        let default_ignores = [
315            "**/node_modules/**",
316            "**/dist/**",
317            "build/**",
318            "**/.git/**",
319            "**/coverage/**",
320            "**/*.min.js",
321            "**/*.min.mjs",
322        ];
323        for pattern in &default_ignores {
324            if let Ok(glob) = Glob::new(pattern) {
325                ignore_builder.add(glob);
326            }
327        }
328
329        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
330        let cache_dir = root.join(".fallow");
331
332        let mut rules = self.rules;
333
334        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
335        let production = self.production.global();
336        if production {
337            rules.unused_dev_dependencies = Severity::Off;
338            rules.unused_optional_dependencies = Severity::Off;
339        }
340
341        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
342        // Merge inline framework definitions into external plugins
343        external_plugins.extend(self.framework);
344
345        // Expand boundary preset (if configured) before validation.
346        // Detect source root from tsconfig.json, falling back to "src".
347        let mut boundaries = self.boundaries;
348        if boundaries.preset.is_some() {
349            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
350                .filter(|r| {
351                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
352                })
353                .unwrap_or_else(|| "src".to_owned());
354            if source_root != "src" {
355                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
356            }
357            boundaries.expand(&source_root);
358        }
359        // MUST run AFTER `expand` and BEFORE `validate_zone_references`. Presets
360        // like Bulletproof emit a rule whose `from` is the logical group name
361        // (`features`) that auto-discovery later replaces with concrete child
362        // zones (`features/auth`, `features/billing`). Moving validation above
363        // expansion makes the preset look like it references undefined zones.
364        //
365        // The returned `logical_groups` records the pre-expansion parent
366        // identity (name, children, the user's verbatim `autoDiscover` paths,
367        // the authored rule, and discovery status). It is stashed onto
368        // `ResolvedBoundaryConfig` further down so `fallow list --boundaries
369        // --format json` can surface the user's grouping intent even after
370        // the parent name is flattened out of `zones[]`. Closes issue #373.
371        let logical_groups = boundaries.expand_auto_discover(&root);
372
373        // Validate and compile architecture boundary config
374        let validation_errors = boundaries.validate_zone_references();
375        for (rule_idx, zone_name) in &validation_errors {
376            tracing::error!(
377                "boundary rule {} references undefined zone '{zone_name}'",
378                rule_idx
379            );
380        }
381        for message in boundaries.validate_root_prefixes() {
382            tracing::error!("{message}");
383        }
384        let mut boundaries = boundaries.resolve();
385        // `expand_auto_discover` is the only producer of `logical_groups`;
386        // `resolve()` has no view of the pre-expansion state and leaves the
387        // field empty. Stitch it back together here.
388        boundaries.logical_groups = logical_groups;
389
390        // Pre-compile override glob matchers
391        let overrides = self
392            .overrides
393            .into_iter()
394            .filter_map(|o| {
395                // Inter-file rules group findings across multiple files (a
396                // single duplicate-exports finding spans N files; a single
397                // circular-dependency finding spans M files in a cycle), so a
398                // per-file `overrides.rules` setting cannot meaningfully turn
399                // them off: the override only fires when the path being looked
400                // up matches, but the finding belongs to a group of paths, not
401                // to one. Warn at load time and point users at the working
402                // escape hatch (`ignoreExports` for duplicates, file-level
403                // `// fallow-ignore-file circular-dependency` for cycles).
404                if o.rules.duplicate_exports.is_some()
405                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
406                {
407                    let files = o.files.join(", ");
408                    tracing::warn!(
409                        "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."
410                    );
411                }
412                if o.rules.circular_dependencies.is_some()
413                    && record_inter_file_warn_seen("circular-dependency", &o.files)
414                {
415                    let files = o.files.join(", ");
416                    tracing::warn!(
417                        "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."
418                    );
419                }
420                let matchers: Vec<globset::GlobMatcher> = o
421                    .files
422                    .iter()
423                    .filter_map(|pattern| match Glob::new(pattern) {
424                        Ok(glob) => Some(glob.compile_matcher()),
425                        Err(e) => {
426                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
427                            None
428                        }
429                    })
430                    .collect();
431                if matchers.is_empty() {
432                    None
433                } else {
434                    Some(ResolvedOverride {
435                        matchers,
436                        rules: o.rules,
437                    })
438                }
439            })
440            .collect();
441
442        // Compile `ignoreExports` once at resolve time so both `find_unused_exports`
443        // and `find_duplicate_exports` can read pre-built matchers from
444        // `ResolvedConfig` instead of re-compiling per call. Invalid globs warn
445        // once here, replacing the previous "warn-on-every-detector-call" path.
446        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
447            .ignore_exports
448            .iter()
449            .filter_map(|rule| match Glob::new(&rule.file) {
450                Ok(g) => Some(CompiledIgnoreExportRule {
451                    matcher: g.compile_matcher(),
452                    exports: rule.exports.clone(),
453                }),
454                Err(e) => {
455                    tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
456                    None
457                }
458            })
459            .collect();
460
461        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
462            .ignore_catalog_references
463            .iter()
464            .filter_map(|rule| {
465                let consumer_matcher = match &rule.consumer {
466                    Some(pattern) => match Glob::new(pattern) {
467                        Ok(g) => Some(g.compile_matcher()),
468                        Err(e) => {
469                            tracing::warn!(
470                                "invalid ignoreCatalogReferences consumer glob '{pattern}': {e}"
471                            );
472                            return None;
473                        }
474                    },
475                    None => None,
476                };
477                Some(CompiledIgnoreCatalogReferenceRule {
478                    package: rule.package.clone(),
479                    catalog: rule.catalog.clone(),
480                    consumer_matcher,
481                })
482            })
483            .collect();
484
485        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
486            .ignore_dependency_overrides
487            .iter()
488            .map(|rule| CompiledIgnoreDependencyOverrideRule {
489                package: rule.package.clone(),
490                source: rule.source.clone(),
491            })
492            .collect();
493
494        ResolvedConfig {
495            root,
496            entry_patterns: self.entry,
497            ignore_patterns: compiled_ignore_patterns,
498            output,
499            cache_dir,
500            threads,
501            no_cache,
502            ignore_dependencies: self.ignore_dependencies,
503            ignore_export_rules: self.ignore_exports,
504            compiled_ignore_exports,
505            compiled_ignore_catalog_references,
506            compiled_ignore_dependency_overrides,
507            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
508            used_class_members: self.used_class_members,
509            duplicates: self.duplicates,
510            health: self.health,
511            rules,
512            boundaries,
513            production,
514            quiet,
515            external_plugins,
516            dynamically_loaded: self.dynamically_loaded,
517            overrides,
518            regression: self.regression,
519            audit: self.audit,
520            codeowners: self.codeowners,
521            public_packages: self.public_packages,
522            flags: self.flags,
523            fix: self.fix,
524            resolve: self.resolve,
525            include_entry_exports: self.include_entry_exports,
526        }
527    }
528}
529
530impl ResolvedConfig {
531    /// Resolve the effective rules for a given file path.
532    /// Starts with base rules and applies matching overrides in order.
533    #[must_use]
534    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
535        if self.overrides.is_empty() {
536            return self.rules.clone();
537        }
538
539        let relative = path.strip_prefix(&self.root).unwrap_or(path);
540        let relative_str = relative.to_string_lossy();
541
542        let mut rules = self.rules.clone();
543        for override_entry in &self.overrides {
544            let matches = override_entry
545                .matchers
546                .iter()
547                .any(|m| m.is_match(relative_str.as_ref()));
548            if matches {
549                rules.apply_partial(&override_entry.rules);
550            }
551        }
552        rules
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::config::boundaries::BoundaryConfig;
560    use crate::config::health::HealthConfig;
561
562    #[test]
563    fn overrides_deserialize() {
564        let json_str = r#"{
565            "overrides": [{
566                "files": ["*.test.ts"],
567                "rules": {
568                    "unused-exports": "off"
569                }
570            }]
571        }"#;
572        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
573        assert_eq!(config.overrides.len(), 1);
574        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
575        assert_eq!(
576            config.overrides[0].rules.unused_exports,
577            Some(Severity::Off)
578        );
579        assert_eq!(config.overrides[0].rules.unused_files, None);
580    }
581
582    #[test]
583    fn resolve_rules_for_path_no_overrides() {
584        let config = FallowConfig {
585            schema: None,
586            extends: vec![],
587            entry: vec![],
588            ignore_patterns: vec![],
589            framework: vec![],
590            workspaces: None,
591            ignore_dependencies: vec![],
592            ignore_exports: vec![],
593            ignore_catalog_references: vec![],
594            ignore_dependency_overrides: vec![],
595            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
596            used_class_members: vec![],
597            duplicates: DuplicatesConfig::default(),
598            health: HealthConfig::default(),
599            rules: RulesConfig::default(),
600            boundaries: BoundaryConfig::default(),
601            production: false.into(),
602            plugins: vec![],
603            dynamically_loaded: vec![],
604            overrides: vec![],
605            regression: None,
606            audit: crate::config::AuditConfig::default(),
607            codeowners: None,
608            public_packages: vec![],
609            flags: FlagsConfig::default(),
610            fix: crate::config::FixConfig::default(),
611            resolve: ResolveConfig::default(),
612            sealed: false,
613            include_entry_exports: false,
614        };
615        let resolved = config.resolve(
616            PathBuf::from("/project"),
617            OutputFormat::Human,
618            1,
619            true,
620            true,
621        );
622        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
623        assert_eq!(rules.unused_files, Severity::Error);
624    }
625
626    #[test]
627    fn resolve_rules_for_path_with_matching_override() {
628        let config = FallowConfig {
629            schema: None,
630            extends: vec![],
631            entry: vec![],
632            ignore_patterns: vec![],
633            framework: vec![],
634            workspaces: None,
635            ignore_dependencies: vec![],
636            ignore_exports: vec![],
637            ignore_catalog_references: vec![],
638            ignore_dependency_overrides: vec![],
639            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
640            used_class_members: vec![],
641            duplicates: DuplicatesConfig::default(),
642            health: HealthConfig::default(),
643            rules: RulesConfig::default(),
644            boundaries: BoundaryConfig::default(),
645            production: false.into(),
646            plugins: vec![],
647            dynamically_loaded: vec![],
648            overrides: vec![ConfigOverride {
649                files: vec!["*.test.ts".to_string()],
650                rules: PartialRulesConfig {
651                    unused_exports: Some(Severity::Off),
652                    ..Default::default()
653                },
654            }],
655            regression: None,
656            audit: crate::config::AuditConfig::default(),
657            codeowners: None,
658            public_packages: vec![],
659            flags: FlagsConfig::default(),
660            fix: crate::config::FixConfig::default(),
661            resolve: ResolveConfig::default(),
662            sealed: false,
663            include_entry_exports: false,
664        };
665        let resolved = config.resolve(
666            PathBuf::from("/project"),
667            OutputFormat::Human,
668            1,
669            true,
670            true,
671        );
672
673        // Test file matches override
674        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
675        assert_eq!(test_rules.unused_exports, Severity::Off);
676        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
677
678        // Non-test file does not match
679        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
680        assert_eq!(src_rules.unused_exports, Severity::Error);
681    }
682
683    #[test]
684    fn resolve_rules_for_path_later_override_wins() {
685        let config = FallowConfig {
686            schema: None,
687            extends: vec![],
688            entry: vec![],
689            ignore_patterns: vec![],
690            framework: vec![],
691            workspaces: None,
692            ignore_dependencies: vec![],
693            ignore_exports: vec![],
694            ignore_catalog_references: vec![],
695            ignore_dependency_overrides: vec![],
696            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
697            used_class_members: vec![],
698            duplicates: DuplicatesConfig::default(),
699            health: HealthConfig::default(),
700            rules: RulesConfig::default(),
701            boundaries: BoundaryConfig::default(),
702            production: false.into(),
703            plugins: vec![],
704            dynamically_loaded: vec![],
705            overrides: vec![
706                ConfigOverride {
707                    files: vec!["*.ts".to_string()],
708                    rules: PartialRulesConfig {
709                        unused_files: Some(Severity::Warn),
710                        ..Default::default()
711                    },
712                },
713                ConfigOverride {
714                    files: vec!["*.test.ts".to_string()],
715                    rules: PartialRulesConfig {
716                        unused_files: Some(Severity::Off),
717                        ..Default::default()
718                    },
719                },
720            ],
721            regression: None,
722            audit: crate::config::AuditConfig::default(),
723            codeowners: None,
724            public_packages: vec![],
725            flags: FlagsConfig::default(),
726            fix: crate::config::FixConfig::default(),
727            resolve: ResolveConfig::default(),
728            sealed: false,
729            include_entry_exports: false,
730        };
731        let resolved = config.resolve(
732            PathBuf::from("/project"),
733            OutputFormat::Human,
734            1,
735            true,
736            true,
737        );
738
739        // First override matches *.ts, second matches *.test.ts; second wins
740        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
741        assert_eq!(rules.unused_files, Severity::Off);
742
743        // Non-test .ts file only matches first override
744        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
745        assert_eq!(rules2.unused_files, Severity::Warn);
746    }
747
748    #[test]
749    fn resolve_keeps_inter_file_rule_override_after_warning() {
750        // Setting `overrides.rules.duplicate-exports` for a file glob is a no-op
751        // at finding-time (duplicate-exports groups span multiple files), but the
752        // override must still resolve cleanly so other co-located rule settings
753        // on the same override are honored. The resolver emits a tracing warning;
754        // here we assert the override is still installed for non-inter-file rules.
755        let config = FallowConfig {
756            schema: None,
757            extends: vec![],
758            entry: vec![],
759            ignore_patterns: vec![],
760            framework: vec![],
761            workspaces: None,
762            ignore_dependencies: vec![],
763            ignore_exports: vec![],
764            ignore_catalog_references: vec![],
765            ignore_dependency_overrides: vec![],
766            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
767            used_class_members: vec![],
768            duplicates: DuplicatesConfig::default(),
769            health: HealthConfig::default(),
770            rules: RulesConfig::default(),
771            boundaries: BoundaryConfig::default(),
772            production: false.into(),
773            plugins: vec![],
774            dynamically_loaded: vec![],
775            overrides: vec![ConfigOverride {
776                files: vec!["**/ui/**".to_string()],
777                rules: PartialRulesConfig {
778                    duplicate_exports: Some(Severity::Off),
779                    unused_files: Some(Severity::Warn),
780                    ..Default::default()
781                },
782            }],
783            regression: None,
784            audit: crate::config::AuditConfig::default(),
785            codeowners: None,
786            public_packages: vec![],
787            flags: FlagsConfig::default(),
788            fix: crate::config::FixConfig::default(),
789            resolve: ResolveConfig::default(),
790            sealed: false,
791            include_entry_exports: false,
792        };
793        let resolved = config.resolve(
794            PathBuf::from("/project"),
795            OutputFormat::Human,
796            1,
797            true,
798            true,
799        );
800        assert_eq!(
801            resolved.overrides.len(),
802            1,
803            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
804        );
805        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
806        assert_eq!(rules.unused_files, Severity::Warn);
807    }
808
809    #[test]
810    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
811        // Reset shared state so test ordering does not affect the assertions
812        // below. Uses unique glob strings (`__test_dedup_*`) so other tests in
813        // this module that exercise the warn path do not collide.
814        reset_inter_file_warn_dedup_for_test();
815        let files_a = vec!["__test_dedup_a/*".to_string()];
816        let files_b = vec!["__test_dedup_b/*".to_string()];
817
818        // First call fires; subsequent identical calls do not.
819        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
820        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
821        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
822
823        // Different rule name is a distinct key.
824        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
825        assert!(!record_inter_file_warn_seen(
826            "circular-dependency",
827            &files_a
828        ));
829
830        // Different glob list is a distinct key.
831        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
832
833        // Order-insensitive glob list collapses to the same key.
834        let files_reordered = vec![
835            "__test_dedup_b/*".to_string(),
836            "__test_dedup_a/*".to_string(),
837        ];
838        let files_natural = vec![
839            "__test_dedup_a/*".to_string(),
840            "__test_dedup_b/*".to_string(),
841        ];
842        reset_inter_file_warn_dedup_for_test();
843        assert!(record_inter_file_warn_seen(
844            "duplicate-exports",
845            &files_natural
846        ));
847        assert!(!record_inter_file_warn_seen(
848            "duplicate-exports",
849            &files_reordered
850        ));
851    }
852
853    #[test]
854    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
855        // Drive `FallowConfig::resolve()` ten times with identical
856        // `overrides.rules.duplicate-exports` to mirror workspace mode (one
857        // resolve per package). The dedup must surface the warn key as
858        // already-seen on every call after the first.
859        reset_inter_file_warn_dedup_for_test();
860        let files = vec!["__test_resolve_dedup/**".to_string()];
861        let build_config = || FallowConfig {
862            schema: None,
863            extends: vec![],
864            entry: vec![],
865            ignore_patterns: vec![],
866            framework: vec![],
867            workspaces: None,
868            ignore_dependencies: vec![],
869            ignore_exports: vec![],
870            ignore_catalog_references: vec![],
871            ignore_dependency_overrides: vec![],
872            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
873            used_class_members: vec![],
874            duplicates: DuplicatesConfig::default(),
875            health: HealthConfig::default(),
876            rules: RulesConfig::default(),
877            boundaries: BoundaryConfig::default(),
878            production: false.into(),
879            plugins: vec![],
880            dynamically_loaded: vec![],
881            overrides: vec![ConfigOverride {
882                files: files.clone(),
883                rules: PartialRulesConfig {
884                    duplicate_exports: Some(Severity::Off),
885                    ..Default::default()
886                },
887            }],
888            regression: None,
889            audit: crate::config::AuditConfig::default(),
890            codeowners: None,
891            public_packages: vec![],
892            flags: FlagsConfig::default(),
893            fix: crate::config::FixConfig::default(),
894            resolve: ResolveConfig::default(),
895            sealed: false,
896            include_entry_exports: false,
897        };
898        for _ in 0..10 {
899            let _ = build_config().resolve(
900                PathBuf::from("/project"),
901                OutputFormat::Human,
902                1,
903                true,
904                true,
905            );
906        }
907        // After 10 resolves the dedup state holds the warn key. Asking the
908        // dedup helper for the SAME key returns false (already seen) instead
909        // of true (would fire).
910        assert!(
911            !record_inter_file_warn_seen("duplicate-exports", &files),
912            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
913        );
914    }
915
916    /// Helper to build a FallowConfig with minimal boilerplate.
917    fn make_config(production: bool) -> FallowConfig {
918        FallowConfig {
919            schema: None,
920            extends: vec![],
921            entry: vec![],
922            ignore_patterns: vec![],
923            framework: vec![],
924            workspaces: None,
925            ignore_dependencies: vec![],
926            ignore_exports: vec![],
927            ignore_catalog_references: vec![],
928            ignore_dependency_overrides: vec![],
929            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
930            used_class_members: vec![],
931            duplicates: DuplicatesConfig::default(),
932            health: HealthConfig::default(),
933            rules: RulesConfig::default(),
934            boundaries: BoundaryConfig::default(),
935            production: production.into(),
936            plugins: vec![],
937            dynamically_loaded: vec![],
938            overrides: vec![],
939            regression: None,
940            audit: crate::config::AuditConfig::default(),
941            codeowners: None,
942            public_packages: vec![],
943            flags: FlagsConfig::default(),
944            fix: crate::config::FixConfig::default(),
945            resolve: ResolveConfig::default(),
946            sealed: false,
947            include_entry_exports: false,
948        }
949    }
950
951    // ── Production mode ─────────────────────────────────────────────
952
953    #[test]
954    fn resolve_production_forces_dev_deps_off() {
955        let resolved = make_config(true).resolve(
956            PathBuf::from("/project"),
957            OutputFormat::Human,
958            1,
959            true,
960            true,
961        );
962        assert_eq!(
963            resolved.rules.unused_dev_dependencies,
964            Severity::Off,
965            "production mode should force unused_dev_dependencies to off"
966        );
967    }
968
969    #[test]
970    fn resolve_production_forces_optional_deps_off() {
971        let resolved = make_config(true).resolve(
972            PathBuf::from("/project"),
973            OutputFormat::Human,
974            1,
975            true,
976            true,
977        );
978        assert_eq!(
979            resolved.rules.unused_optional_dependencies,
980            Severity::Off,
981            "production mode should force unused_optional_dependencies to off"
982        );
983    }
984
985    #[test]
986    fn resolve_production_preserves_other_rules() {
987        let resolved = make_config(true).resolve(
988            PathBuf::from("/project"),
989            OutputFormat::Human,
990            1,
991            true,
992            true,
993        );
994        // Other rules should remain at their defaults
995        assert_eq!(resolved.rules.unused_files, Severity::Error);
996        assert_eq!(resolved.rules.unused_exports, Severity::Error);
997        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
998    }
999
1000    #[test]
1001    fn resolve_non_production_keeps_dev_deps_default() {
1002        let resolved = make_config(false).resolve(
1003            PathBuf::from("/project"),
1004            OutputFormat::Human,
1005            1,
1006            true,
1007            true,
1008        );
1009        assert_eq!(
1010            resolved.rules.unused_dev_dependencies,
1011            Severity::Warn,
1012            "non-production should keep default severity"
1013        );
1014        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1015    }
1016
1017    #[test]
1018    fn resolve_production_flag_stored() {
1019        let resolved = make_config(true).resolve(
1020            PathBuf::from("/project"),
1021            OutputFormat::Human,
1022            1,
1023            true,
1024            true,
1025        );
1026        assert!(resolved.production);
1027
1028        let resolved2 = make_config(false).resolve(
1029            PathBuf::from("/project"),
1030            OutputFormat::Human,
1031            1,
1032            true,
1033            true,
1034        );
1035        assert!(!resolved2.production);
1036    }
1037
1038    // ── Default ignore patterns ─────────────────────────────────────
1039
1040    #[test]
1041    fn resolve_default_ignores_node_modules() {
1042        let resolved = make_config(false).resolve(
1043            PathBuf::from("/project"),
1044            OutputFormat::Human,
1045            1,
1046            true,
1047            true,
1048        );
1049        assert!(
1050            resolved
1051                .ignore_patterns
1052                .is_match("node_modules/lodash/index.js")
1053        );
1054        assert!(
1055            resolved
1056                .ignore_patterns
1057                .is_match("packages/a/node_modules/react/index.js")
1058        );
1059    }
1060
1061    #[test]
1062    fn resolve_default_ignores_dist() {
1063        let resolved = make_config(false).resolve(
1064            PathBuf::from("/project"),
1065            OutputFormat::Human,
1066            1,
1067            true,
1068            true,
1069        );
1070        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1071        assert!(
1072            resolved
1073                .ignore_patterns
1074                .is_match("packages/ui/dist/index.js")
1075        );
1076    }
1077
1078    #[test]
1079    fn resolve_default_ignores_root_build_only() {
1080        let resolved = make_config(false).resolve(
1081            PathBuf::from("/project"),
1082            OutputFormat::Human,
1083            1,
1084            true,
1085            true,
1086        );
1087        assert!(
1088            resolved.ignore_patterns.is_match("build/output.js"),
1089            "root build/ should be ignored"
1090        );
1091        // The pattern is `build/**` (root-only), not `**/build/**`
1092        assert!(
1093            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1094            "nested build/ should NOT be ignored by default"
1095        );
1096    }
1097
1098    #[test]
1099    fn resolve_default_ignores_minified_files() {
1100        let resolved = make_config(false).resolve(
1101            PathBuf::from("/project"),
1102            OutputFormat::Human,
1103            1,
1104            true,
1105            true,
1106        );
1107        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1108        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1109    }
1110
1111    #[test]
1112    fn resolve_default_ignores_git() {
1113        let resolved = make_config(false).resolve(
1114            PathBuf::from("/project"),
1115            OutputFormat::Human,
1116            1,
1117            true,
1118            true,
1119        );
1120        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1121    }
1122
1123    #[test]
1124    fn resolve_default_ignores_coverage() {
1125        let resolved = make_config(false).resolve(
1126            PathBuf::from("/project"),
1127            OutputFormat::Human,
1128            1,
1129            true,
1130            true,
1131        );
1132        assert!(
1133            resolved
1134                .ignore_patterns
1135                .is_match("coverage/lcov-report/index.js")
1136        );
1137    }
1138
1139    #[test]
1140    fn resolve_source_files_not_ignored_by_default() {
1141        let resolved = make_config(false).resolve(
1142            PathBuf::from("/project"),
1143            OutputFormat::Human,
1144            1,
1145            true,
1146            true,
1147        );
1148        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1149        assert!(
1150            !resolved
1151                .ignore_patterns
1152                .is_match("src/components/Button.tsx")
1153        );
1154        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1155    }
1156
1157    // ── Custom ignore patterns ──────────────────────────────────────
1158
1159    #[test]
1160    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1161        let mut config = make_config(false);
1162        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1163        let resolved = config.resolve(
1164            PathBuf::from("/project"),
1165            OutputFormat::Human,
1166            1,
1167            true,
1168            true,
1169        );
1170        // Custom pattern works
1171        assert!(
1172            resolved
1173                .ignore_patterns
1174                .is_match("src/__generated__/types.ts")
1175        );
1176        // Default patterns still work
1177        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1178    }
1179
1180    // ── Config fields passthrough ───────────────────────────────────
1181
1182    #[test]
1183    fn resolve_passes_through_entry_patterns() {
1184        let mut config = make_config(false);
1185        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1186        let resolved = config.resolve(
1187            PathBuf::from("/project"),
1188            OutputFormat::Human,
1189            1,
1190            true,
1191            true,
1192        );
1193        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1194    }
1195
1196    #[test]
1197    fn resolve_passes_through_ignore_dependencies() {
1198        let mut config = make_config(false);
1199        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1200        let resolved = config.resolve(
1201            PathBuf::from("/project"),
1202            OutputFormat::Human,
1203            1,
1204            true,
1205            true,
1206        );
1207        assert_eq!(
1208            resolved.ignore_dependencies,
1209            vec!["postcss", "autoprefixer"]
1210        );
1211    }
1212
1213    #[test]
1214    fn resolve_sets_cache_dir() {
1215        let resolved = make_config(false).resolve(
1216            PathBuf::from("/my/project"),
1217            OutputFormat::Human,
1218            1,
1219            true,
1220            true,
1221        );
1222        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1223    }
1224
1225    #[test]
1226    fn resolve_passes_through_thread_count() {
1227        let resolved = make_config(false).resolve(
1228            PathBuf::from("/project"),
1229            OutputFormat::Human,
1230            8,
1231            true,
1232            true,
1233        );
1234        assert_eq!(resolved.threads, 8);
1235    }
1236
1237    #[test]
1238    fn resolve_passes_through_quiet_flag() {
1239        let resolved = make_config(false).resolve(
1240            PathBuf::from("/project"),
1241            OutputFormat::Human,
1242            1,
1243            true,
1244            false,
1245        );
1246        assert!(!resolved.quiet);
1247
1248        let resolved2 = make_config(false).resolve(
1249            PathBuf::from("/project"),
1250            OutputFormat::Human,
1251            1,
1252            true,
1253            true,
1254        );
1255        assert!(resolved2.quiet);
1256    }
1257
1258    #[test]
1259    fn resolve_passes_through_no_cache_flag() {
1260        let resolved_no_cache = make_config(false).resolve(
1261            PathBuf::from("/project"),
1262            OutputFormat::Human,
1263            1,
1264            true,
1265            true,
1266        );
1267        assert!(resolved_no_cache.no_cache);
1268
1269        let resolved_with_cache = make_config(false).resolve(
1270            PathBuf::from("/project"),
1271            OutputFormat::Human,
1272            1,
1273            false,
1274            true,
1275        );
1276        assert!(!resolved_with_cache.no_cache);
1277    }
1278
1279    // ── Override resolution edge cases ───────────────────────────────
1280
1281    #[test]
1282    fn resolve_override_with_invalid_glob_skipped() {
1283        let mut config = make_config(false);
1284        config.overrides = vec![ConfigOverride {
1285            files: vec!["[invalid".to_string()],
1286            rules: PartialRulesConfig {
1287                unused_files: Some(Severity::Off),
1288                ..Default::default()
1289            },
1290        }];
1291        let resolved = config.resolve(
1292            PathBuf::from("/project"),
1293            OutputFormat::Human,
1294            1,
1295            true,
1296            true,
1297        );
1298        // Invalid glob should be skipped, so no overrides should be compiled
1299        assert!(
1300            resolved.overrides.is_empty(),
1301            "override with invalid glob should be skipped"
1302        );
1303    }
1304
1305    #[test]
1306    fn resolve_override_with_empty_files_skipped() {
1307        let mut config = make_config(false);
1308        config.overrides = vec![ConfigOverride {
1309            files: vec![],
1310            rules: PartialRulesConfig {
1311                unused_files: Some(Severity::Off),
1312                ..Default::default()
1313            },
1314        }];
1315        let resolved = config.resolve(
1316            PathBuf::from("/project"),
1317            OutputFormat::Human,
1318            1,
1319            true,
1320            true,
1321        );
1322        assert!(
1323            resolved.overrides.is_empty(),
1324            "override with no file patterns should be skipped"
1325        );
1326    }
1327
1328    #[test]
1329    fn resolve_multiple_valid_overrides() {
1330        let mut config = make_config(false);
1331        config.overrides = vec![
1332            ConfigOverride {
1333                files: vec!["*.test.ts".to_string()],
1334                rules: PartialRulesConfig {
1335                    unused_exports: Some(Severity::Off),
1336                    ..Default::default()
1337                },
1338            },
1339            ConfigOverride {
1340                files: vec!["*.stories.tsx".to_string()],
1341                rules: PartialRulesConfig {
1342                    unused_files: Some(Severity::Off),
1343                    ..Default::default()
1344                },
1345            },
1346        ];
1347        let resolved = config.resolve(
1348            PathBuf::from("/project"),
1349            OutputFormat::Human,
1350            1,
1351            true,
1352            true,
1353        );
1354        assert_eq!(resolved.overrides.len(), 2);
1355    }
1356
1357    // ── IgnoreExportRule ────────────────────────────────────────────
1358
1359    #[test]
1360    fn ignore_export_rule_deserialize() {
1361        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1362        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1363        assert_eq!(rule.file, "src/types/*.ts");
1364        assert_eq!(rule.exports, vec!["*"]);
1365    }
1366
1367    #[test]
1368    fn ignore_export_rule_specific_exports() {
1369        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1370        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1371        assert_eq!(rule.exports.len(), 3);
1372        assert!(rule.exports.contains(&"FOO".to_string()));
1373    }
1374
1375    mod proptests {
1376        use super::*;
1377        use proptest::prelude::*;
1378
1379        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1380            make_config(production).resolve(
1381                PathBuf::from("/project"),
1382                OutputFormat::Human,
1383                1,
1384                true,
1385                true,
1386            )
1387        }
1388
1389        proptest! {
1390            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1391            #[test]
1392            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1393                let resolved = arb_resolved_config(production);
1394                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
1395                prop_assert!(
1396                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1397                    "Default ignore should match node_modules"
1398                );
1399                prop_assert!(
1400                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1401                    "Default ignore should match dist"
1402                );
1403            }
1404
1405            /// Production mode always forces dev and optional deps to Off.
1406            #[test]
1407            fn production_forces_dev_deps_off(_unused in Just(())) {
1408                let resolved = arb_resolved_config(true);
1409                prop_assert_eq!(
1410                    resolved.rules.unused_dev_dependencies,
1411                    Severity::Off,
1412                    "Production should force unused_dev_dependencies off"
1413                );
1414                prop_assert_eq!(
1415                    resolved.rules.unused_optional_dependencies,
1416                    Severity::Off,
1417                    "Production should force unused_optional_dependencies off"
1418                );
1419            }
1420
1421            /// Non-production mode preserves default severity for dev deps.
1422            #[test]
1423            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1424                let resolved = arb_resolved_config(false);
1425                prop_assert_eq!(
1426                    resolved.rules.unused_dev_dependencies,
1427                    Severity::Warn,
1428                    "Non-production should keep default dev dep severity"
1429                );
1430            }
1431
1432            /// Cache dir is always root/.fallow.
1433            #[test]
1434            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1435                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1436                let expected_cache = root.join(".fallow");
1437                let resolved = make_config(false).resolve(
1438                    root,
1439                    OutputFormat::Human,
1440                    1,
1441                    true,
1442                    true,
1443                );
1444                prop_assert_eq!(
1445                    resolved.cache_dir, expected_cache,
1446                    "Cache dir should be root/.fallow"
1447                );
1448            }
1449
1450            /// Thread count is always passed through exactly.
1451            #[test]
1452            fn threads_passed_through(threads in 1..64usize) {
1453                let resolved = make_config(false).resolve(
1454                    PathBuf::from("/project"),
1455                    OutputFormat::Human,
1456                    threads,
1457                    true,
1458                    true,
1459                );
1460                prop_assert_eq!(
1461                    resolved.threads, threads,
1462                    "Thread count should be passed through"
1463                );
1464            }
1465
1466            /// Custom ignore patterns are merged with defaults, not replacing them.
1467            /// Uses a pattern regex that cannot match node_modules paths, so the
1468            /// assertion proves the default pattern is what provides the match.
1469            #[test]
1470            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1471                let mut config = make_config(false);
1472                config.ignore_patterns = vec![pattern];
1473                let resolved = config.resolve(
1474                    PathBuf::from("/project"),
1475                    OutputFormat::Human,
1476                    1,
1477                    true,
1478                    true,
1479                );
1480                // Defaults should still be present (the custom pattern cannot
1481                // match this path, so only the default **/node_modules/** can)
1482                prop_assert!(
1483                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1484                    "Default node_modules ignore should still be active"
1485                );
1486            }
1487        }
1488    }
1489
1490    // ── Boundary preset expansion ──────────────────────────────────
1491
1492    #[test]
1493    fn resolve_expands_boundary_preset() {
1494        use crate::config::boundaries::BoundaryPreset;
1495
1496        let mut config = make_config(false);
1497        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1498        let resolved = config.resolve(
1499            PathBuf::from("/project"),
1500            OutputFormat::Human,
1501            1,
1502            true,
1503            true,
1504        );
1505        // Preset should have been expanded into zones (no tsconfig → fallback to "src")
1506        assert_eq!(resolved.boundaries.zones.len(), 3);
1507        assert_eq!(resolved.boundaries.rules.len(), 3);
1508        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1509        assert_eq!(
1510            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1511            Some("adapters")
1512        );
1513    }
1514
1515    #[test]
1516    fn resolve_boundary_preset_with_user_override() {
1517        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1518
1519        let mut config = make_config(false);
1520        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1521        config.boundaries.zones = vec![BoundaryZone {
1522            name: "domain".to_string(),
1523            patterns: vec!["src/core/**".to_string()],
1524            auto_discover: vec![],
1525            root: None,
1526        }];
1527        let resolved = config.resolve(
1528            PathBuf::from("/project"),
1529            OutputFormat::Human,
1530            1,
1531            true,
1532            true,
1533        );
1534        // User zone "domain" replaced preset zone "domain"
1535        assert_eq!(resolved.boundaries.zones.len(), 3);
1536        // The user's pattern should be used for domain zone
1537        assert_eq!(
1538            resolved.boundaries.classify_zone("src/core/user.ts"),
1539            Some("domain")
1540        );
1541        // Original preset pattern should NOT match
1542        assert_eq!(
1543            resolved.boundaries.classify_zone("src/domain/user.ts"),
1544            None
1545        );
1546    }
1547
1548    #[test]
1549    fn resolve_no_preset_unchanged() {
1550        let config = make_config(false);
1551        let resolved = config.resolve(
1552            PathBuf::from("/project"),
1553            OutputFormat::Human,
1554            1,
1555            true,
1556            true,
1557        );
1558        assert!(resolved.boundaries.is_empty());
1559    }
1560}