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