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