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