Skip to main content

fallow_config/config/
resolution.rs

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