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, GlobMatcher, 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::IgnoreExportsUsedInFileConfig;
22use super::{FallowConfig, SecurityConfig};
23
24/// Process-local dedup state for inter-file rule warnings.
25static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
26
27/// Stable hash of `(rule_name, sorted glob list)`.
28fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
29    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
30    sorted.sort_unstable();
31    let mut hasher = DefaultHasher::new();
32    rule_name.hash(&mut hasher);
33    for s in &sorted {
34        s.hash(&mut hasher);
35    }
36    hasher.finish()
37}
38
39/// Returns `true` if this warning has not yet fired in the current process.
40fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
41    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
42    let key = inter_file_warn_key(rule_name, files);
43    seen.lock().map_or(true, |mut set| set.insert(key))
44}
45
46#[cfg(test)]
47fn reset_inter_file_warn_dedup_for_test() {
48    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
49        && let Ok(mut set) = seen.lock()
50    {
51        set.clear();
52    }
53}
54
55/// Rule for ignoring specific exports.
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
57pub struct IgnoreExportRule {
58    /// Glob pattern for files.
59    pub file: String,
60    /// Export names to ignore (`*` for all).
61    pub exports: Vec<String>,
62}
63
64/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
65#[derive(Debug)]
66pub struct CompiledIgnoreExportRule {
67    pub matcher: globset::GlobMatcher,
68    pub exports: Vec<String>,
69}
70
71/// Rule for suppressing an `unresolved-catalog-reference` finding.
72#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct IgnoreCatalogReferenceRule {
75    pub package: String,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub catalog: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub consumer: Option<String>,
80}
81
82/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
83#[derive(Debug)]
84pub struct CompiledIgnoreCatalogReferenceRule {
85    pub package: String,
86    pub catalog: Option<String>,
87    pub consumer_matcher: Option<globset::GlobMatcher>,
88}
89
90impl CompiledIgnoreCatalogReferenceRule {
91    /// Whether this rule suppresses an `unresolved-catalog-reference` finding.
92    #[must_use]
93    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
94        if self.package != package {
95            return false;
96        }
97        if let Some(catalog_filter) = &self.catalog
98            && catalog_filter != catalog
99        {
100            return false;
101        }
102        if let Some(matcher) = &self.consumer_matcher
103            && !matcher.is_match(consumer_path)
104        {
105            return false;
106        }
107        true
108    }
109}
110
111/// Rule for suppressing dependency-override findings.
112#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114pub struct IgnoreDependencyOverrideRule {
115    pub package: String,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub source: Option<String>,
118}
119
120/// `IgnoreDependencyOverrideRule` ready for matching.
121#[derive(Debug)]
122pub struct CompiledIgnoreDependencyOverrideRule {
123    pub package: String,
124    pub source: Option<String>,
125}
126
127impl CompiledIgnoreDependencyOverrideRule {
128    /// Whether this rule suppresses a dependency-override finding.
129    #[must_use]
130    pub fn matches(&self, package: &str, source_label: &str) -> bool {
131        if self.package != package {
132            return false;
133        }
134        if let Some(source_filter) = &self.source
135            && source_filter != source_label
136        {
137            return false;
138        }
139        true
140    }
141}
142
143/// Per-file override entry.
144#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
145#[serde(rename_all = "camelCase")]
146pub struct ConfigOverride {
147    pub files: Vec<String>,
148    #[serde(default)]
149    pub rules: PartialRulesConfig,
150}
151
152/// Resolved override with pre-compiled glob matchers.
153#[derive(Debug)]
154pub struct ResolvedOverride {
155    pub matchers: Vec<globset::GlobMatcher>,
156    pub rules: PartialRulesConfig,
157}
158
159/// Fully resolved configuration with all globs pre-compiled.
160#[derive(Debug)]
161pub struct ResolvedConfig {
162    pub root: PathBuf,
163    pub entry_patterns: Vec<String>,
164    pub ignore_patterns: GlobSet,
165    pub output: OutputFormat,
166    pub cache_dir: PathBuf,
167    pub threads: usize,
168    pub no_cache: bool,
169    pub cache_max_size_mb: Option<u32>,
170    pub cache_config_hash: u64,
171    pub ignore_dependencies: Vec<String>,
172    pub ignore_unresolved_imports: Vec<GlobMatcher>,
173    pub ignore_export_rules: Vec<IgnoreExportRule>,
174    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
175    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
176    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
177    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
178    pub used_class_members: Vec<UsedClassMemberRule>,
179    pub ignore_decorators: Vec<String>,
180    pub duplicates: DuplicatesConfig,
181    pub health: HealthConfig,
182    pub rules: RulesConfig,
183    pub boundaries: ResolvedBoundaryConfig,
184    /// Rule packs loaded from the `rulePacks` config key, in config order.
185    /// Validated at config load (`load_rule_packs` is also the validation
186    /// gate in the CLI and programmatic entry points); a pack that fails to
187    /// load here is skipped with a `tracing::error!` as defense in depth.
188    pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
189    pub production: bool,
190    pub quiet: bool,
191    pub external_plugins: Vec<ExternalPluginDef>,
192    pub dynamically_loaded: Vec<String>,
193    pub overrides: Vec<ResolvedOverride>,
194    pub regression: Option<super::RegressionConfig>,
195    pub audit: super::AuditConfig,
196    pub codeowners: Option<String>,
197    pub public_packages: Vec<String>,
198    pub flags: FlagsConfig,
199    pub security: SecurityConfig,
200    pub fix: super::FixConfig,
201    pub resolve: ResolveConfig,
202    pub include_entry_exports: bool,
203    pub auto_imports: bool,
204    /// Source files strictly larger than this many bytes are skipped at
205    /// discovery (never read, parsed, or analyzed), guarding against the
206    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
207    /// causes (issue #1086). `None` means no limit. Declaration files
208    /// (`.d.ts`/`.d.mts`/`.d.cts`) are exempt regardless of size because they
209    /// are reachability roots for global types. Defaults to
210    /// [`DEFAULT_MAX_FILE_SIZE_MB`] MB; the CLI overrides it post-resolve from
211    /// `--max-file-size` / `FALLOW_MAX_FILE_SIZE` (`0` = unlimited).
212    pub max_file_size_bytes: Option<u64>,
213}
214
215/// Default per-file size ceiling (in megabytes) for source discovery. A value
216/// chosen so hand-written source effectively never reaches it while generated
217/// API clients, vendored bundles, and minified blobs do. See issue #1086.
218pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
219
220/// [`DEFAULT_MAX_FILE_SIZE_MB`] expressed in bytes.
221pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
222
223/// Convert a user-supplied megabyte ceiling into the byte limit stored on
224/// [`ResolvedConfig::max_file_size_bytes`]. `Some(0)` means "no limit"
225/// (`None`); any other `Some(n)` is `n` MB in bytes; `None` (unset) keeps the
226/// built-in [`DEFAULT_MAX_FILE_SIZE_BYTES`].
227#[must_use]
228pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
229    match max_file_size_mb {
230        None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
231        Some(0) => None,
232        Some(mb) => Some(u64::from(mb) * 1024 * 1024),
233    }
234}
235
236/// Compute the cache-invalidation hash over extraction-affecting config fields.
237fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
238    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
239    names.sort_unstable();
240    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
241    for name in names {
242        hasher.update(&(name.len() as u32).to_le_bytes());
243        hasher.update(name.as_bytes());
244    }
245    hasher.digest()
246}
247
248fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
249    let Some(dir) = configured else {
250        return root.join(".fallow");
251    };
252    if dir.is_absolute() {
253        dir
254    } else {
255        root.join(dir)
256    }
257}
258
259impl FallowConfig {
260    /// Resolve into a fully resolved config with compiled globs.
261    #[expect(
262        clippy::expect_used,
263        reason = "user glob patterns are validated before config resolution"
264    )]
265    pub fn resolve(
266        self,
267        root: PathBuf,
268        output: OutputFormat,
269        threads: usize,
270        no_cache: bool,
271        quiet: bool,
272        cache_max_size_mb: Option<u32>,
273    ) -> ResolvedConfig {
274        let mut ignore_builder = GlobSetBuilder::new();
275        for pattern in &self.ignore_patterns {
276            ignore_builder.add(
277                Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
278            );
279        }
280
281        let default_ignores = [
282            "**/node_modules/**",
283            "**/dist/**",
284            "build/**",
285            "**/.git/**",
286            "**/coverage/**",
287            "**/*.min.js",
288            "**/*.min.mjs",
289            "**/*.min.cjs",
290            "**/*.bundle.js",
291        ];
292        for pattern in &default_ignores {
293            ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
294        }
295
296        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
297        let ignore_unresolved_imports: Vec<GlobMatcher> = self
298            .ignore_unresolved_imports
299            .iter()
300            .map(|pattern| {
301                Glob::new(pattern)
302                    .expect("ignoreUnresolvedImports entry was validated at config load time")
303                    .compile_matcher()
304            })
305            .collect();
306        let cache_dir = resolve_cache_dir(&root, self.cache.dir.clone());
307
308        let mut rules = self.rules;
309
310        let production = self.production.global();
311        if production {
312            rules.unused_dev_dependencies = Severity::Off;
313            rules.unused_optional_dependencies = Severity::Off;
314        }
315
316        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
317        external_plugins.extend(self.framework);
318
319        let rule_packs =
320            crate::rule_pack::load_rule_packs(&root, &self.rule_packs).unwrap_or_else(|errors| {
321                for error in &errors {
322                    tracing::error!("invalid rule pack: {error}");
323                }
324                Vec::new()
325            });
326
327        let mut boundaries = self.boundaries;
328        if boundaries.preset.is_some() {
329            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
330                .filter(|r| {
331                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
332                })
333                .unwrap_or_else(|| "src".to_owned());
334            if source_root != "src" {
335                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
336            }
337            boundaries.expand(&source_root);
338        }
339        let logical_groups = boundaries.expand_auto_discover(&root);
340
341        let mut boundaries = boundaries.resolve();
342        boundaries.logical_groups = logical_groups;
343
344        let overrides = self
345            .overrides
346            .into_iter()
347            .filter_map(|o| {
348                if o.rules.duplicate_exports.is_some()
349                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
350                {
351                    let files = o.files.join(", ");
352                    tracing::warn!(
353                        "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."
354                    );
355                }
356                if o.rules.circular_dependencies.is_some()
357                    && record_inter_file_warn_seen("circular-dependency", &o.files)
358                {
359                    let files = o.files.join(", ");
360                    tracing::warn!(
361                        "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."
362                    );
363                }
364                if o.rules.re_export_cycle.is_some()
365                    && record_inter_file_warn_seen("re-export-cycle", &o.files)
366                {
367                    let files = o.files.join(", ");
368                    tracing::warn!(
369                        "overrides.rules.re-export-cycle has no effect for files matching [{files}]: re-export-cycle is an inter-file rule (the cycle spans multiple barrels). Use a file-level `// fallow-ignore-file re-export-cycle` comment in one participating file instead, or set `rules.re-export-cycle: off` at the top level."
370                    );
371                }
372                let matchers: Vec<globset::GlobMatcher> = o
373                    .files
374                    .iter()
375                    .map(|pattern| {
376                        Glob::new(pattern)
377                            .expect("overrides[].files pattern was validated at config load time")
378                            .compile_matcher()
379                    })
380                    .collect();
381                if matchers.is_empty() {
382                    None
383                } else {
384                    Some(ResolvedOverride {
385                        matchers,
386                        rules: o.rules,
387                    })
388                }
389            })
390            .collect();
391
392        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
393            .ignore_exports
394            .iter()
395            .map(|rule| CompiledIgnoreExportRule {
396                matcher: Glob::new(&rule.file)
397                    .expect("ignoreExports[].file was validated at config load time")
398                    .compile_matcher(),
399                exports: rule.exports.clone(),
400            })
401            .collect();
402
403        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
404            .ignore_catalog_references
405            .iter()
406            .map(|rule| CompiledIgnoreCatalogReferenceRule {
407                package: rule.package.clone(),
408                catalog: rule.catalog.clone(),
409                consumer_matcher: rule.consumer.as_ref().map(|pattern| {
410                    Glob::new(pattern)
411                        .expect(
412                            "ignoreCatalogReferences[].consumer was validated at config load time",
413                        )
414                        .compile_matcher()
415                }),
416            })
417            .collect();
418
419        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
420            .ignore_dependency_overrides
421            .iter()
422            .map(|rule| CompiledIgnoreDependencyOverrideRule {
423                package: rule.package.clone(),
424                source: rule.source.clone(),
425            })
426            .collect();
427
428        let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
429
430        let cache_config_hash = if no_cache {
431            0
432        } else {
433            compute_cache_config_hash(&external_plugins)
434        };
435
436        let security = SecurityConfig {
437            request_receivers: self.security.normalized_request_receivers(),
438            ..self.security
439        };
440
441        ResolvedConfig {
442            root,
443            entry_patterns: self.entry,
444            ignore_patterns: compiled_ignore_patterns,
445            output,
446            cache_dir,
447            threads,
448            no_cache,
449            cache_max_size_mb,
450            cache_config_hash,
451            ignore_dependencies: self.ignore_dependencies,
452            ignore_unresolved_imports,
453            ignore_export_rules: self.ignore_exports,
454            compiled_ignore_exports,
455            compiled_ignore_catalog_references,
456            compiled_ignore_dependency_overrides,
457            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
458            used_class_members: self.used_class_members,
459            ignore_decorators: self.ignore_decorators,
460            duplicates: self.duplicates,
461            health: self.health,
462            rules,
463            boundaries,
464            rule_packs,
465            production,
466            quiet,
467            external_plugins,
468            dynamically_loaded: self.dynamically_loaded,
469            overrides,
470            regression: self.regression,
471            audit: self.audit,
472            codeowners: self.codeowners,
473            public_packages: self.public_packages,
474            flags: self.flags,
475            security,
476            fix: self.fix,
477            resolve: self.resolve,
478            include_entry_exports: self.include_entry_exports,
479            auto_imports: self.auto_imports,
480            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
481        }
482    }
483}
484
485impl ResolvedConfig {
486    /// Resolve the effective rules for a given file path.
487    /// Starts with base rules and applies matching overrides in order.
488    #[must_use]
489    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
490        if self.overrides.is_empty() {
491            return self.rules.clone();
492        }
493
494        let relative = path.strip_prefix(&self.root).unwrap_or(path);
495        let relative_str = relative.to_string_lossy();
496
497        let mut rules = self.rules.clone();
498        for override_entry in &self.overrides {
499            let matches = override_entry
500                .matchers
501                .iter()
502                .any(|m| m.is_match(relative_str.as_ref()));
503            if matches {
504                rules.apply_partial(&override_entry.rules);
505            }
506        }
507        rules
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::CacheConfig;
515    use crate::config::boundaries::BoundaryConfig;
516    use crate::config::health::HealthConfig;
517
518    #[test]
519    fn overrides_deserialize() {
520        let json_str = r#"{
521            "overrides": [{
522                "files": ["*.test.ts"],
523                "rules": {
524                    "unused-exports": "off"
525                }
526            }]
527        }"#;
528        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
529        assert_eq!(config.overrides.len(), 1);
530        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
531        assert_eq!(
532            config.overrides[0].rules.unused_exports,
533            Some(Severity::Off)
534        );
535        assert_eq!(config.overrides[0].rules.unused_files, None);
536    }
537
538    #[test]
539    fn resolve_rules_for_path_no_overrides() {
540        let config = FallowConfig {
541            schema: None,
542            extends: vec![],
543            entry: vec![],
544            ignore_patterns: vec![],
545            framework: vec![],
546            workspaces: None,
547            ignore_dependencies: vec![],
548            ignore_unresolved_imports: vec![],
549            ignore_exports: vec![],
550            ignore_catalog_references: vec![],
551            ignore_dependency_overrides: vec![],
552            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
553            used_class_members: vec![],
554            ignore_decorators: vec![],
555            duplicates: DuplicatesConfig::default(),
556            health: HealthConfig::default(),
557            rules: RulesConfig::default(),
558            boundaries: BoundaryConfig::default(),
559            production: false.into(),
560            plugins: vec![],
561            rule_packs: vec![],
562            dynamically_loaded: vec![],
563            overrides: vec![],
564            regression: None,
565            audit: crate::config::AuditConfig::default(),
566            codeowners: None,
567            public_packages: vec![],
568            flags: FlagsConfig::default(),
569            security: SecurityConfig::default(),
570            fix: crate::config::FixConfig::default(),
571            resolve: ResolveConfig::default(),
572            sealed: false,
573            include_entry_exports: false,
574            auto_imports: false,
575            cache: CacheConfig::default(),
576        };
577        let resolved = config.resolve(
578            PathBuf::from("/project"),
579            OutputFormat::Human,
580            1,
581            true,
582            true,
583            None,
584        );
585        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
586        assert_eq!(rules.unused_files, Severity::Error);
587    }
588
589    #[test]
590    fn resolve_rules_for_path_with_matching_override() {
591        let config = FallowConfig {
592            schema: None,
593            extends: vec![],
594            entry: vec![],
595            ignore_patterns: vec![],
596            framework: vec![],
597            workspaces: None,
598            ignore_dependencies: vec![],
599            ignore_unresolved_imports: vec![],
600            ignore_exports: vec![],
601            ignore_catalog_references: vec![],
602            ignore_dependency_overrides: vec![],
603            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
604            used_class_members: vec![],
605            ignore_decorators: vec![],
606            duplicates: DuplicatesConfig::default(),
607            health: HealthConfig::default(),
608            rules: RulesConfig::default(),
609            boundaries: BoundaryConfig::default(),
610            production: false.into(),
611            plugins: vec![],
612            rule_packs: vec![],
613            dynamically_loaded: vec![],
614            overrides: vec![ConfigOverride {
615                files: vec!["*.test.ts".to_string()],
616                rules: PartialRulesConfig {
617                    unused_exports: Some(Severity::Off),
618                    ..Default::default()
619                },
620            }],
621            regression: None,
622            audit: crate::config::AuditConfig::default(),
623            codeowners: None,
624            public_packages: vec![],
625            flags: FlagsConfig::default(),
626            security: SecurityConfig::default(),
627            fix: crate::config::FixConfig::default(),
628            resolve: ResolveConfig::default(),
629            sealed: false,
630            include_entry_exports: false,
631            auto_imports: false,
632            cache: CacheConfig::default(),
633        };
634        let resolved = config.resolve(
635            PathBuf::from("/project"),
636            OutputFormat::Human,
637            1,
638            true,
639            true,
640            None,
641        );
642
643        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
644        assert_eq!(test_rules.unused_exports, Severity::Off);
645        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
646
647        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
648        assert_eq!(src_rules.unused_exports, Severity::Error);
649    }
650
651    #[test]
652    fn resolve_rules_for_path_later_override_wins() {
653        let config = FallowConfig {
654            schema: None,
655            extends: vec![],
656            entry: vec![],
657            ignore_patterns: vec![],
658            framework: vec![],
659            workspaces: None,
660            ignore_dependencies: vec![],
661            ignore_unresolved_imports: vec![],
662            ignore_exports: vec![],
663            ignore_catalog_references: vec![],
664            ignore_dependency_overrides: vec![],
665            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
666            used_class_members: vec![],
667            ignore_decorators: vec![],
668            duplicates: DuplicatesConfig::default(),
669            health: HealthConfig::default(),
670            rules: RulesConfig::default(),
671            boundaries: BoundaryConfig::default(),
672            production: false.into(),
673            plugins: vec![],
674            rule_packs: vec![],
675            dynamically_loaded: vec![],
676            overrides: vec![
677                ConfigOverride {
678                    files: vec!["*.ts".to_string()],
679                    rules: PartialRulesConfig {
680                        unused_files: Some(Severity::Warn),
681                        ..Default::default()
682                    },
683                },
684                ConfigOverride {
685                    files: vec!["*.test.ts".to_string()],
686                    rules: PartialRulesConfig {
687                        unused_files: Some(Severity::Off),
688                        ..Default::default()
689                    },
690                },
691            ],
692            regression: None,
693            audit: crate::config::AuditConfig::default(),
694            codeowners: None,
695            public_packages: vec![],
696            flags: FlagsConfig::default(),
697            security: SecurityConfig::default(),
698            fix: crate::config::FixConfig::default(),
699            resolve: ResolveConfig::default(),
700            sealed: false,
701            include_entry_exports: false,
702            auto_imports: false,
703            cache: CacheConfig::default(),
704        };
705        let resolved = config.resolve(
706            PathBuf::from("/project"),
707            OutputFormat::Human,
708            1,
709            true,
710            true,
711            None,
712        );
713
714        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
715        assert_eq!(rules.unused_files, Severity::Off);
716
717        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
718        assert_eq!(rules2.unused_files, Severity::Warn);
719    }
720
721    #[test]
722    fn resolve_keeps_inter_file_rule_override_after_warning() {
723        let config = FallowConfig {
724            schema: None,
725            extends: vec![],
726            entry: vec![],
727            ignore_patterns: vec![],
728            framework: vec![],
729            workspaces: None,
730            ignore_dependencies: vec![],
731            ignore_unresolved_imports: vec![],
732            ignore_exports: vec![],
733            ignore_catalog_references: vec![],
734            ignore_dependency_overrides: vec![],
735            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
736            used_class_members: vec![],
737            ignore_decorators: vec![],
738            duplicates: DuplicatesConfig::default(),
739            health: HealthConfig::default(),
740            rules: RulesConfig::default(),
741            boundaries: BoundaryConfig::default(),
742            production: false.into(),
743            plugins: vec![],
744            rule_packs: vec![],
745            dynamically_loaded: vec![],
746            overrides: vec![ConfigOverride {
747                files: vec!["**/ui/**".to_string()],
748                rules: PartialRulesConfig {
749                    duplicate_exports: Some(Severity::Off),
750                    unused_files: Some(Severity::Warn),
751                    ..Default::default()
752                },
753            }],
754            regression: None,
755            audit: crate::config::AuditConfig::default(),
756            codeowners: None,
757            public_packages: vec![],
758            flags: FlagsConfig::default(),
759            security: SecurityConfig::default(),
760            fix: crate::config::FixConfig::default(),
761            resolve: ResolveConfig::default(),
762            sealed: false,
763            include_entry_exports: false,
764            auto_imports: false,
765            cache: CacheConfig::default(),
766        };
767        let resolved = config.resolve(
768            PathBuf::from("/project"),
769            OutputFormat::Human,
770            1,
771            true,
772            true,
773            None,
774        );
775        assert_eq!(
776            resolved.overrides.len(),
777            1,
778            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
779        );
780        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
781        assert_eq!(rules.unused_files, Severity::Warn);
782    }
783
784    #[test]
785    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
786        reset_inter_file_warn_dedup_for_test();
787        let files_a = vec!["__test_dedup_a/*".to_string()];
788        let files_b = vec!["__test_dedup_b/*".to_string()];
789
790        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
791        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
792        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
793
794        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
795        assert!(!record_inter_file_warn_seen(
796            "circular-dependency",
797            &files_a
798        ));
799
800        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
801
802        let files_reordered = vec![
803            "__test_dedup_b/*".to_string(),
804            "__test_dedup_a/*".to_string(),
805        ];
806        let files_natural = vec![
807            "__test_dedup_a/*".to_string(),
808            "__test_dedup_b/*".to_string(),
809        ];
810        reset_inter_file_warn_dedup_for_test();
811        assert!(record_inter_file_warn_seen(
812            "duplicate-exports",
813            &files_natural
814        ));
815        assert!(!record_inter_file_warn_seen(
816            "duplicate-exports",
817            &files_reordered
818        ));
819    }
820
821    #[test]
822    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
823        reset_inter_file_warn_dedup_for_test();
824        let files = vec!["__test_resolve_dedup/**".to_string()];
825        let build_config = || FallowConfig {
826            schema: None,
827            extends: vec![],
828            entry: vec![],
829            ignore_patterns: vec![],
830            framework: vec![],
831            workspaces: None,
832            ignore_dependencies: vec![],
833            ignore_unresolved_imports: vec![],
834            ignore_exports: vec![],
835            ignore_catalog_references: vec![],
836            ignore_dependency_overrides: vec![],
837            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
838            used_class_members: vec![],
839            ignore_decorators: vec![],
840            duplicates: DuplicatesConfig::default(),
841            health: HealthConfig::default(),
842            rules: RulesConfig::default(),
843            boundaries: BoundaryConfig::default(),
844            production: false.into(),
845            plugins: vec![],
846            rule_packs: vec![],
847            dynamically_loaded: vec![],
848            overrides: vec![ConfigOverride {
849                files: files.clone(),
850                rules: PartialRulesConfig {
851                    duplicate_exports: Some(Severity::Off),
852                    ..Default::default()
853                },
854            }],
855            regression: None,
856            audit: crate::config::AuditConfig::default(),
857            codeowners: None,
858            public_packages: vec![],
859            flags: FlagsConfig::default(),
860            security: SecurityConfig::default(),
861            fix: crate::config::FixConfig::default(),
862            resolve: ResolveConfig::default(),
863            sealed: false,
864            include_entry_exports: false,
865            auto_imports: false,
866            cache: CacheConfig::default(),
867        };
868        for _ in 0..10 {
869            let _ = build_config().resolve(
870                PathBuf::from("/project"),
871                OutputFormat::Human,
872                1,
873                true,
874                true,
875                None,
876            );
877        }
878        assert!(
879            !record_inter_file_warn_seen("duplicate-exports", &files),
880            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
881        );
882    }
883
884    /// Helper to build a FallowConfig with minimal boilerplate.
885    fn make_config(production: bool) -> FallowConfig {
886        FallowConfig {
887            schema: None,
888            extends: vec![],
889            entry: vec![],
890            ignore_patterns: vec![],
891            framework: vec![],
892            workspaces: None,
893            ignore_dependencies: vec![],
894            ignore_unresolved_imports: vec![],
895            ignore_exports: vec![],
896            ignore_catalog_references: vec![],
897            ignore_dependency_overrides: vec![],
898            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
899            used_class_members: vec![],
900            ignore_decorators: vec![],
901            duplicates: DuplicatesConfig::default(),
902            health: HealthConfig::default(),
903            rules: RulesConfig::default(),
904            boundaries: BoundaryConfig::default(),
905            production: production.into(),
906            plugins: vec![],
907            rule_packs: vec![],
908            dynamically_loaded: vec![],
909            overrides: vec![],
910            regression: None,
911            audit: crate::config::AuditConfig::default(),
912            codeowners: None,
913            public_packages: vec![],
914            flags: FlagsConfig::default(),
915            security: SecurityConfig::default(),
916            fix: crate::config::FixConfig::default(),
917            resolve: ResolveConfig::default(),
918            sealed: false,
919            include_entry_exports: false,
920            auto_imports: false,
921            cache: CacheConfig::default(),
922        }
923    }
924
925    #[test]
926    fn resolve_production_forces_dev_deps_off() {
927        let resolved = make_config(true).resolve(
928            PathBuf::from("/project"),
929            OutputFormat::Human,
930            1,
931            true,
932            true,
933            None,
934        );
935        assert_eq!(
936            resolved.rules.unused_dev_dependencies,
937            Severity::Off,
938            "production mode should force unused_dev_dependencies to off"
939        );
940    }
941
942    #[test]
943    fn resolve_production_forces_optional_deps_off() {
944        let resolved = make_config(true).resolve(
945            PathBuf::from("/project"),
946            OutputFormat::Human,
947            1,
948            true,
949            true,
950            None,
951        );
952        assert_eq!(
953            resolved.rules.unused_optional_dependencies,
954            Severity::Off,
955            "production mode should force unused_optional_dependencies to off"
956        );
957    }
958
959    #[test]
960    fn resolve_production_preserves_other_rules() {
961        let resolved = make_config(true).resolve(
962            PathBuf::from("/project"),
963            OutputFormat::Human,
964            1,
965            true,
966            true,
967            None,
968        );
969        assert_eq!(resolved.rules.unused_files, Severity::Error);
970        assert_eq!(resolved.rules.unused_exports, Severity::Error);
971        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
972    }
973
974    #[test]
975    fn resolve_non_production_keeps_dev_deps_default() {
976        let resolved = make_config(false).resolve(
977            PathBuf::from("/project"),
978            OutputFormat::Human,
979            1,
980            true,
981            true,
982            None,
983        );
984        assert_eq!(
985            resolved.rules.unused_dev_dependencies,
986            Severity::Warn,
987            "non-production should keep default severity"
988        );
989        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
990    }
991
992    #[test]
993    fn resolve_production_flag_stored() {
994        let resolved = make_config(true).resolve(
995            PathBuf::from("/project"),
996            OutputFormat::Human,
997            1,
998            true,
999            true,
1000            None,
1001        );
1002        assert!(resolved.production);
1003
1004        let resolved2 = make_config(false).resolve(
1005            PathBuf::from("/project"),
1006            OutputFormat::Human,
1007            1,
1008            true,
1009            true,
1010            None,
1011        );
1012        assert!(!resolved2.production);
1013    }
1014
1015    #[test]
1016    fn resolve_default_ignores_node_modules() {
1017        let resolved = make_config(false).resolve(
1018            PathBuf::from("/project"),
1019            OutputFormat::Human,
1020            1,
1021            true,
1022            true,
1023            None,
1024        );
1025        assert!(
1026            resolved
1027                .ignore_patterns
1028                .is_match("node_modules/lodash/index.js")
1029        );
1030        assert!(
1031            resolved
1032                .ignore_patterns
1033                .is_match("packages/a/node_modules/react/index.js")
1034        );
1035    }
1036
1037    #[test]
1038    fn resolve_default_ignores_dist() {
1039        let resolved = make_config(false).resolve(
1040            PathBuf::from("/project"),
1041            OutputFormat::Human,
1042            1,
1043            true,
1044            true,
1045            None,
1046        );
1047        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1048        assert!(
1049            resolved
1050                .ignore_patterns
1051                .is_match("packages/ui/dist/index.js")
1052        );
1053    }
1054
1055    #[test]
1056    fn resolve_default_ignores_root_build_only() {
1057        let resolved = make_config(false).resolve(
1058            PathBuf::from("/project"),
1059            OutputFormat::Human,
1060            1,
1061            true,
1062            true,
1063            None,
1064        );
1065        assert!(
1066            resolved.ignore_patterns.is_match("build/output.js"),
1067            "root build/ should be ignored"
1068        );
1069        assert!(
1070            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1071            "nested build/ should NOT be ignored by default"
1072        );
1073    }
1074
1075    #[test]
1076    fn resolve_default_ignores_minified_files() {
1077        let resolved = make_config(false).resolve(
1078            PathBuf::from("/project"),
1079            OutputFormat::Human,
1080            1,
1081            true,
1082            true,
1083            None,
1084        );
1085        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1086        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1087        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1088        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1089        assert!(
1090            resolved
1091                .ignore_patterns
1092                .is_match("src/vendor/app.bundle.js")
1093        );
1094        // Hand-written source with a similar name stays analyzed.
1095        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1096        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1097    }
1098
1099    #[test]
1100    fn resolve_max_file_size_bytes_default_and_unlimited() {
1101        // Unset keeps the built-in default.
1102        assert_eq!(
1103            resolve_max_file_size_bytes(None),
1104            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1105        );
1106        // `0` means no limit.
1107        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1108        // Any other value is that many megabytes in bytes.
1109        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1110        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1111    }
1112
1113    #[test]
1114    fn resolve_sets_default_max_file_size() {
1115        let resolved = make_config(false).resolve(
1116            PathBuf::from("/project"),
1117            OutputFormat::Human,
1118            1,
1119            true,
1120            true,
1121            None,
1122        );
1123        assert_eq!(
1124            resolved.max_file_size_bytes,
1125            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1126        );
1127    }
1128
1129    #[test]
1130    fn resolve_default_ignores_git() {
1131        let resolved = make_config(false).resolve(
1132            PathBuf::from("/project"),
1133            OutputFormat::Human,
1134            1,
1135            true,
1136            true,
1137            None,
1138        );
1139        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1140    }
1141
1142    #[test]
1143    fn resolve_default_ignores_coverage() {
1144        let resolved = make_config(false).resolve(
1145            PathBuf::from("/project"),
1146            OutputFormat::Human,
1147            1,
1148            true,
1149            true,
1150            None,
1151        );
1152        assert!(
1153            resolved
1154                .ignore_patterns
1155                .is_match("coverage/lcov-report/index.js")
1156        );
1157    }
1158
1159    #[test]
1160    fn resolve_source_files_not_ignored_by_default() {
1161        let resolved = make_config(false).resolve(
1162            PathBuf::from("/project"),
1163            OutputFormat::Human,
1164            1,
1165            true,
1166            true,
1167            None,
1168        );
1169        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1170        assert!(
1171            !resolved
1172                .ignore_patterns
1173                .is_match("src/components/Button.tsx")
1174        );
1175        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1176    }
1177
1178    #[test]
1179    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1180        let mut config = make_config(false);
1181        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1182        let resolved = config.resolve(
1183            PathBuf::from("/project"),
1184            OutputFormat::Human,
1185            1,
1186            true,
1187            true,
1188            None,
1189        );
1190        assert!(
1191            resolved
1192                .ignore_patterns
1193                .is_match("src/__generated__/types.ts")
1194        );
1195        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1196    }
1197
1198    #[test]
1199    fn resolve_passes_through_entry_patterns() {
1200        let mut config = make_config(false);
1201        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1202        let resolved = config.resolve(
1203            PathBuf::from("/project"),
1204            OutputFormat::Human,
1205            1,
1206            true,
1207            true,
1208            None,
1209        );
1210        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1211    }
1212
1213    #[test]
1214    fn resolve_passes_through_ignore_dependencies() {
1215        let mut config = make_config(false);
1216        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1217        let resolved = config.resolve(
1218            PathBuf::from("/project"),
1219            OutputFormat::Human,
1220            1,
1221            true,
1222            true,
1223            None,
1224        );
1225        assert_eq!(
1226            resolved.ignore_dependencies,
1227            vec!["postcss", "autoprefixer"]
1228        );
1229    }
1230
1231    #[test]
1232    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1233        let mut config = make_config(false);
1234        config.ignore_unresolved_imports = vec![
1235            "@example/icons".to_string(),
1236            "@example/icons/**".to_string(),
1237            "../generated/**".to_string(),
1238        ];
1239        let resolved = config.resolve(
1240            PathBuf::from("/project"),
1241            OutputFormat::Human,
1242            1,
1243            true,
1244            true,
1245            None,
1246        );
1247
1248        assert!(
1249            resolved
1250                .ignore_unresolved_imports
1251                .iter()
1252                .any(|matcher| matcher.is_match("@example/icons"))
1253        );
1254        assert!(
1255            resolved
1256                .ignore_unresolved_imports
1257                .iter()
1258                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1259        );
1260        assert!(
1261            resolved
1262                .ignore_unresolved_imports
1263                .iter()
1264                .any(|matcher| matcher.is_match("../generated/client"))
1265        );
1266    }
1267
1268    #[test]
1269    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1270        let mut config = make_config(false);
1271        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1272        let resolved = config.resolve(
1273            PathBuf::from("/project"),
1274            OutputFormat::Human,
1275            1,
1276            true,
1277            true,
1278            None,
1279        );
1280
1281        assert!(
1282            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1283            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1284        );
1285        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1286    }
1287
1288    #[test]
1289    fn resolve_sets_cache_dir() {
1290        let resolved = make_config(false).resolve(
1291            PathBuf::from("/my/project"),
1292            OutputFormat::Human,
1293            1,
1294            true,
1295            true,
1296            None,
1297        );
1298        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1299    }
1300
1301    #[test]
1302    fn resolve_uses_relative_configured_cache_dir_from_root() {
1303        let config = FallowConfig {
1304            cache: crate::CacheConfig {
1305                dir: Some(PathBuf::from(".cache/fallow")),
1306                ..Default::default()
1307            },
1308            ..make_config(false)
1309        };
1310        let resolved = config.resolve(
1311            PathBuf::from("/my/project"),
1312            OutputFormat::Human,
1313            1,
1314            false,
1315            true,
1316            None,
1317        );
1318        assert_eq!(
1319            resolved.cache_dir,
1320            PathBuf::from("/my/project/.cache/fallow")
1321        );
1322    }
1323
1324    #[test]
1325    fn resolve_keeps_absolute_configured_cache_dir() {
1326        let config = FallowConfig {
1327            cache: crate::CacheConfig {
1328                dir: Some(PathBuf::from("/tmp/fallow-cache")),
1329                ..Default::default()
1330            },
1331            ..make_config(false)
1332        };
1333        let resolved = config.resolve(
1334            PathBuf::from("/my/project"),
1335            OutputFormat::Human,
1336            1,
1337            false,
1338            true,
1339            None,
1340        );
1341        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1342    }
1343
1344    #[test]
1345    fn resolve_passes_through_thread_count() {
1346        let resolved = make_config(false).resolve(
1347            PathBuf::from("/project"),
1348            OutputFormat::Human,
1349            8,
1350            true,
1351            true,
1352            None,
1353        );
1354        assert_eq!(resolved.threads, 8);
1355    }
1356
1357    #[test]
1358    fn resolve_passes_through_quiet_flag() {
1359        let resolved = make_config(false).resolve(
1360            PathBuf::from("/project"),
1361            OutputFormat::Human,
1362            1,
1363            true,
1364            false,
1365            None,
1366        );
1367        assert!(!resolved.quiet);
1368
1369        let resolved2 = make_config(false).resolve(
1370            PathBuf::from("/project"),
1371            OutputFormat::Human,
1372            1,
1373            true,
1374            true,
1375            None,
1376        );
1377        assert!(resolved2.quiet);
1378    }
1379
1380    #[test]
1381    fn resolve_passes_through_no_cache_flag() {
1382        let resolved_no_cache = make_config(false).resolve(
1383            PathBuf::from("/project"),
1384            OutputFormat::Human,
1385            1,
1386            true,
1387            true,
1388            None,
1389        );
1390        assert!(resolved_no_cache.no_cache);
1391
1392        let resolved_with_cache = make_config(false).resolve(
1393            PathBuf::from("/project"),
1394            OutputFormat::Human,
1395            1,
1396            false,
1397            true,
1398            None,
1399        );
1400        assert!(!resolved_with_cache.no_cache);
1401    }
1402
1403    #[test]
1404    #[should_panic(expected = "validated at config load time")]
1405    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1406        let mut config = make_config(false);
1407        config.overrides = vec![ConfigOverride {
1408            files: vec!["[invalid".to_string()],
1409            rules: PartialRulesConfig {
1410                unused_files: Some(Severity::Off),
1411                ..Default::default()
1412            },
1413        }];
1414        let _ = config.resolve(
1415            PathBuf::from("/project"),
1416            OutputFormat::Human,
1417            1,
1418            true,
1419            true,
1420            None,
1421        );
1422    }
1423
1424    #[test]
1425    fn resolve_override_with_empty_files_skipped() {
1426        let mut config = make_config(false);
1427        config.overrides = vec![ConfigOverride {
1428            files: vec![],
1429            rules: PartialRulesConfig {
1430                unused_files: Some(Severity::Off),
1431                ..Default::default()
1432            },
1433        }];
1434        let resolved = config.resolve(
1435            PathBuf::from("/project"),
1436            OutputFormat::Human,
1437            1,
1438            true,
1439            true,
1440            None,
1441        );
1442        assert!(
1443            resolved.overrides.is_empty(),
1444            "override with no file patterns should be skipped"
1445        );
1446    }
1447
1448    #[test]
1449    fn resolve_multiple_valid_overrides() {
1450        let mut config = make_config(false);
1451        config.overrides = vec![
1452            ConfigOverride {
1453                files: vec!["*.test.ts".to_string()],
1454                rules: PartialRulesConfig {
1455                    unused_exports: Some(Severity::Off),
1456                    ..Default::default()
1457                },
1458            },
1459            ConfigOverride {
1460                files: vec!["*.stories.tsx".to_string()],
1461                rules: PartialRulesConfig {
1462                    unused_files: Some(Severity::Off),
1463                    ..Default::default()
1464                },
1465            },
1466        ];
1467        let resolved = config.resolve(
1468            PathBuf::from("/project"),
1469            OutputFormat::Human,
1470            1,
1471            true,
1472            true,
1473            None,
1474        );
1475        assert_eq!(resolved.overrides.len(), 2);
1476    }
1477
1478    #[test]
1479    fn ignore_export_rule_deserialize() {
1480        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1481        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1482        assert_eq!(rule.file, "src/types/*.ts");
1483        assert_eq!(rule.exports, vec!["*"]);
1484    }
1485
1486    #[test]
1487    fn ignore_export_rule_specific_exports() {
1488        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1489        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1490        assert_eq!(rule.exports.len(), 3);
1491        assert!(rule.exports.contains(&"FOO".to_string()));
1492    }
1493
1494    mod proptests {
1495        use super::*;
1496        use proptest::prelude::*;
1497
1498        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1499            make_config(production).resolve(
1500                PathBuf::from("/project"),
1501                OutputFormat::Human,
1502                1,
1503                true,
1504                true,
1505                None,
1506            )
1507        }
1508
1509        proptest! {
1510            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1511            #[test]
1512            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1513                let resolved = arb_resolved_config(production);
1514                prop_assert!(
1515                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1516                    "Default ignore should match node_modules"
1517                );
1518                prop_assert!(
1519                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1520                    "Default ignore should match dist"
1521                );
1522            }
1523
1524            /// Production mode always forces dev and optional deps to Off.
1525            #[test]
1526            fn production_forces_dev_deps_off(_unused in Just(())) {
1527                let resolved = arb_resolved_config(true);
1528                prop_assert_eq!(
1529                    resolved.rules.unused_dev_dependencies,
1530                    Severity::Off,
1531                    "Production should force unused_dev_dependencies off"
1532                );
1533                prop_assert_eq!(
1534                    resolved.rules.unused_optional_dependencies,
1535                    Severity::Off,
1536                    "Production should force unused_optional_dependencies off"
1537                );
1538            }
1539
1540            /// Non-production mode preserves default severity for dev deps.
1541            #[test]
1542            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1543                let resolved = arb_resolved_config(false);
1544                prop_assert_eq!(
1545                    resolved.rules.unused_dev_dependencies,
1546                    Severity::Warn,
1547                    "Non-production should keep default dev dep severity"
1548                );
1549            }
1550
1551            /// Default cache dir is root/.fallow.
1552            #[test]
1553            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1554                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1555                let expected_cache = root.join(".fallow");
1556                let resolved = make_config(false).resolve(
1557                    root,
1558                    OutputFormat::Human,
1559                    1,
1560                    true,
1561                    true,
1562                    None,
1563                );
1564                prop_assert_eq!(
1565                    resolved.cache_dir, expected_cache,
1566                    "Default cache dir should be root/.fallow"
1567                );
1568            }
1569
1570            /// Thread count is always passed through exactly.
1571            #[test]
1572            fn threads_passed_through(threads in 1..64usize) {
1573                let resolved = make_config(false).resolve(
1574                    PathBuf::from("/project"),
1575                    OutputFormat::Human,
1576                    threads,
1577                    true,
1578                    true, None,
1579                );
1580                prop_assert_eq!(
1581                    resolved.threads, threads,
1582                    "Thread count should be passed through"
1583                );
1584            }
1585
1586            /// Custom ignore patterns are merged with defaults, not replacing them.
1587            /// Uses a pattern regex that cannot match node_modules paths, so the
1588            /// assertion proves the default pattern is what provides the match.
1589            #[test]
1590            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1591                let mut config = make_config(false);
1592                config.ignore_patterns = vec![pattern];
1593                let resolved = config.resolve(
1594                    PathBuf::from("/project"),
1595                    OutputFormat::Human,
1596                    1,
1597                    true,
1598                    true, None,
1599                );
1600                prop_assert!(
1601                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1602                    "Default node_modules ignore should still be active"
1603                );
1604            }
1605        }
1606    }
1607
1608    #[test]
1609    fn resolve_expands_boundary_preset() {
1610        use crate::config::boundaries::BoundaryPreset;
1611
1612        let mut config = make_config(false);
1613        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1614        let resolved = config.resolve(
1615            PathBuf::from("/project"),
1616            OutputFormat::Human,
1617            1,
1618            true,
1619            true,
1620            None,
1621        );
1622        assert_eq!(resolved.boundaries.zones.len(), 3);
1623        assert_eq!(resolved.boundaries.rules.len(), 3);
1624        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1625        assert_eq!(
1626            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1627            Some("adapters")
1628        );
1629    }
1630
1631    #[test]
1632    fn resolve_boundary_preset_with_user_override() {
1633        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1634
1635        let mut config = make_config(false);
1636        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1637        config.boundaries.zones = vec![BoundaryZone {
1638            name: "domain".to_string(),
1639            patterns: vec!["src/core/**".to_string()],
1640            auto_discover: vec![],
1641            root: None,
1642        }];
1643        let resolved = config.resolve(
1644            PathBuf::from("/project"),
1645            OutputFormat::Human,
1646            1,
1647            true,
1648            true,
1649            None,
1650        );
1651        assert_eq!(resolved.boundaries.zones.len(), 3);
1652        assert_eq!(
1653            resolved.boundaries.classify_zone("src/core/user.ts"),
1654            Some("domain")
1655        );
1656        assert_eq!(
1657            resolved.boundaries.classify_zone("src/domain/user.ts"),
1658            None
1659        );
1660    }
1661
1662    #[test]
1663    fn resolve_no_preset_unchanged() {
1664        let config = make_config(false);
1665        let resolved = config.resolve(
1666            PathBuf::from("/project"),
1667            OutputFormat::Human,
1668            1,
1669            true,
1670            true,
1671            None,
1672        );
1673        assert!(resolved.boundaries.is_empty());
1674    }
1675}