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