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