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