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