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
573fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
574    pattern.and_then(|pattern| match regex::Regex::new(pattern) {
575        Ok(re) => Some(re),
576        Err(error) => {
577            tracing::warn!(
578                %error,
579                "ignoring invalid unusedComponentProps.ignorePattern; this config was \
580                 not validated through FallowConfig::load"
581            );
582            None
583        }
584    })
585}
586
587impl FallowConfig {
588    /// Resolve into a fully resolved config with compiled globs.
589    #[expect(
590        clippy::too_many_arguments,
591        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"
592    )]
593    pub fn resolve(
594        self,
595        root: PathBuf,
596        output: OutputFormat,
597        threads: usize,
598        no_cache: bool,
599        quiet: bool,
600        cache_max_size_mb: Option<u32>,
601    ) -> ResolvedConfig {
602        let compiled_ignores = compile_ignore_settings(&self);
603
604        let production_rules = resolve_production_rules(self.production, self.rules);
605
606        let plugins =
607            resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
608
609        let cache = resolve_cache_settings(
610            &root,
611            self.cache.dir,
612            self.cache.max_size_mb,
613            cache_max_size_mb,
614            no_cache,
615            &plugins.external_plugins,
616        );
617
618        let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
619
620        let unused_component_props_ignore = compile_unused_component_props_ignore(
621            self.unused_component_props.ignore_pattern.as_deref(),
622        );
623
624        ResolvedConfig {
625            root,
626            entry_patterns: self.entry,
627            ignore_patterns: compiled_ignores.patterns,
628            output,
629            cache_dir: cache.dir,
630            threads,
631            no_cache,
632            cache_max_size_mb: cache.max_size_mb,
633            cache_config_hash: cache.config_hash,
634            ignore_dependencies: self.ignore_dependencies,
635            ignore_unresolved_imports: compiled_ignores.unresolved_imports,
636            ignore_export_rules: self.ignore_exports,
637            compiled_ignore_exports: compiled_ignores.exports,
638            compiled_ignore_catalog_references: compiled_ignores.catalog_references,
639            compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
640            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
641            used_class_members: self.used_class_members,
642            ignore_decorators: self.ignore_decorators,
643            unused_component_props_ignore,
644            duplicates: self.duplicates,
645            health: self.health,
646            rules: production_rules.rules,
647            boundaries: path_policy.boundaries,
648            rule_packs: plugins.rule_packs,
649            rule_pack_sources: plugins.rule_pack_sources,
650            production: production_rules.production,
651            quiet,
652            external_plugins: plugins.external_plugins,
653            dynamically_loaded: self.dynamically_loaded,
654            overrides: path_policy.overrides,
655            regression: self.regression,
656            audit: self.audit,
657            codeowners: self.codeowners,
658            public_packages: self.public_packages,
659            flags: self.flags,
660            security: normalize_security_config(self.security),
661            fix: self.fix,
662            resolve: self.resolve,
663            include_entry_exports: self.include_entry_exports,
664            auto_imports: self.auto_imports,
665            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
666        }
667    }
668}
669
670impl ResolvedConfig {
671    /// Resolve the effective rules for a given file path.
672    /// Starts with base rules and applies matching overrides in order.
673    #[must_use]
674    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
675        if self.overrides.is_empty() {
676            return self.rules.clone();
677        }
678
679        let relative = path.strip_prefix(&self.root).unwrap_or(path);
680        let relative_str = relative.to_string_lossy();
681
682        let mut rules = self.rules.clone();
683        for override_entry in &self.overrides {
684            let matches = override_entry
685                .matchers
686                .iter()
687                .any(|m| m.is_match(relative_str.as_ref()));
688            if matches {
689                rules.apply_partial(&override_entry.rules);
690            }
691        }
692        rules
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::CacheConfig;
700    use crate::config::boundaries::BoundaryConfig;
701    use crate::config::health::HealthConfig;
702
703    #[test]
704    fn overrides_deserialize() {
705        let json_str = r#"{
706            "overrides": [{
707                "files": ["*.test.ts"],
708                "rules": {
709                    "unused-exports": "off"
710                }
711            }]
712        }"#;
713        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
714        assert_eq!(config.overrides.len(), 1);
715        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
716        assert_eq!(
717            config.overrides[0].rules.unused_exports,
718            Some(Severity::Off)
719        );
720        assert_eq!(config.overrides[0].rules.unused_files, None);
721    }
722
723    #[test]
724    fn resolve_rules_for_path_no_overrides() {
725        let config = FallowConfig {
726            schema: None,
727            extends: vec![],
728            entry: vec![],
729            ignore_patterns: vec![],
730            framework: vec![],
731            workspaces: None,
732            ignore_dependencies: vec![],
733            ignore_unresolved_imports: vec![],
734            ignore_exports: vec![],
735            ignore_catalog_references: vec![],
736            ignore_dependency_overrides: vec![],
737            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
738            used_class_members: vec![],
739            ignore_decorators: vec![],
740            unused_component_props: crate::UnusedComponentPropsConfig::default(),
741            duplicates: DuplicatesConfig::default(),
742            health: HealthConfig::default(),
743            rules: RulesConfig::default(),
744            boundaries: BoundaryConfig::default(),
745            production: false.into(),
746            plugins: vec![],
747            rule_packs: vec![],
748            dynamically_loaded: vec![],
749            overrides: vec![],
750            regression: None,
751            audit: crate::config::AuditConfig::default(),
752            codeowners: None,
753            public_packages: vec![],
754            flags: FlagsConfig::default(),
755            security: SecurityConfig::default(),
756            fix: crate::config::FixConfig::default(),
757            resolve: ResolveConfig::default(),
758            sealed: false,
759            include_entry_exports: false,
760            auto_imports: false,
761            cache: CacheConfig::default(),
762        };
763        let resolved = config.resolve(
764            PathBuf::from("/project"),
765            OutputFormat::Human,
766            1,
767            true,
768            true,
769            None,
770        );
771        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
772        assert_eq!(rules.unused_files, Severity::Error);
773    }
774
775    #[test]
776    fn resolve_rules_for_path_with_matching_override() {
777        let config = FallowConfig {
778            schema: None,
779            extends: vec![],
780            entry: vec![],
781            ignore_patterns: vec![],
782            framework: vec![],
783            workspaces: None,
784            ignore_dependencies: vec![],
785            ignore_unresolved_imports: vec![],
786            ignore_exports: vec![],
787            ignore_catalog_references: vec![],
788            ignore_dependency_overrides: vec![],
789            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
790            used_class_members: vec![],
791            ignore_decorators: vec![],
792            unused_component_props: crate::UnusedComponentPropsConfig::default(),
793            duplicates: DuplicatesConfig::default(),
794            health: HealthConfig::default(),
795            rules: RulesConfig::default(),
796            boundaries: BoundaryConfig::default(),
797            production: false.into(),
798            plugins: vec![],
799            rule_packs: vec![],
800            dynamically_loaded: vec![],
801            overrides: vec![ConfigOverride {
802                files: vec!["*.test.ts".to_string()],
803                rules: PartialRulesConfig {
804                    unused_exports: Some(Severity::Off),
805                    ..Default::default()
806                },
807            }],
808            regression: None,
809            audit: crate::config::AuditConfig::default(),
810            codeowners: None,
811            public_packages: vec![],
812            flags: FlagsConfig::default(),
813            security: SecurityConfig::default(),
814            fix: crate::config::FixConfig::default(),
815            resolve: ResolveConfig::default(),
816            sealed: false,
817            include_entry_exports: false,
818            auto_imports: false,
819            cache: CacheConfig::default(),
820        };
821        let resolved = config.resolve(
822            PathBuf::from("/project"),
823            OutputFormat::Human,
824            1,
825            true,
826            true,
827            None,
828        );
829
830        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
831        assert_eq!(test_rules.unused_exports, Severity::Off);
832        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
833
834        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
835        assert_eq!(src_rules.unused_exports, Severity::Error);
836    }
837
838    #[test]
839    fn resolve_rules_for_path_later_override_wins() {
840        let config = FallowConfig {
841            schema: None,
842            extends: vec![],
843            entry: vec![],
844            ignore_patterns: vec![],
845            framework: vec![],
846            workspaces: None,
847            ignore_dependencies: vec![],
848            ignore_unresolved_imports: vec![],
849            ignore_exports: vec![],
850            ignore_catalog_references: vec![],
851            ignore_dependency_overrides: vec![],
852            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
853            used_class_members: vec![],
854            ignore_decorators: vec![],
855            unused_component_props: crate::UnusedComponentPropsConfig::default(),
856            duplicates: DuplicatesConfig::default(),
857            health: HealthConfig::default(),
858            rules: RulesConfig::default(),
859            boundaries: BoundaryConfig::default(),
860            production: false.into(),
861            plugins: vec![],
862            rule_packs: vec![],
863            dynamically_loaded: vec![],
864            overrides: vec![
865                ConfigOverride {
866                    files: vec!["*.ts".to_string()],
867                    rules: PartialRulesConfig {
868                        unused_files: Some(Severity::Warn),
869                        ..Default::default()
870                    },
871                },
872                ConfigOverride {
873                    files: vec!["*.test.ts".to_string()],
874                    rules: PartialRulesConfig {
875                        unused_files: Some(Severity::Off),
876                        ..Default::default()
877                    },
878                },
879            ],
880            regression: None,
881            audit: crate::config::AuditConfig::default(),
882            codeowners: None,
883            public_packages: vec![],
884            flags: FlagsConfig::default(),
885            security: SecurityConfig::default(),
886            fix: crate::config::FixConfig::default(),
887            resolve: ResolveConfig::default(),
888            sealed: false,
889            include_entry_exports: false,
890            auto_imports: false,
891            cache: CacheConfig::default(),
892        };
893        let resolved = config.resolve(
894            PathBuf::from("/project"),
895            OutputFormat::Human,
896            1,
897            true,
898            true,
899            None,
900        );
901
902        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
903        assert_eq!(rules.unused_files, Severity::Off);
904
905        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
906        assert_eq!(rules2.unused_files, Severity::Warn);
907    }
908
909    #[test]
910    fn resolve_keeps_inter_file_rule_override_after_warning() {
911        let config = FallowConfig {
912            schema: None,
913            extends: vec![],
914            entry: vec![],
915            ignore_patterns: vec![],
916            framework: vec![],
917            workspaces: None,
918            ignore_dependencies: vec![],
919            ignore_unresolved_imports: vec![],
920            ignore_exports: vec![],
921            ignore_catalog_references: vec![],
922            ignore_dependency_overrides: vec![],
923            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
924            used_class_members: vec![],
925            ignore_decorators: vec![],
926            unused_component_props: crate::UnusedComponentPropsConfig::default(),
927            duplicates: DuplicatesConfig::default(),
928            health: HealthConfig::default(),
929            rules: RulesConfig::default(),
930            boundaries: BoundaryConfig::default(),
931            production: false.into(),
932            plugins: vec![],
933            rule_packs: vec![],
934            dynamically_loaded: vec![],
935            overrides: vec![ConfigOverride {
936                files: vec!["**/ui/**".to_string()],
937                rules: PartialRulesConfig {
938                    duplicate_exports: Some(Severity::Off),
939                    unused_files: Some(Severity::Warn),
940                    ..Default::default()
941                },
942            }],
943            regression: None,
944            audit: crate::config::AuditConfig::default(),
945            codeowners: None,
946            public_packages: vec![],
947            flags: FlagsConfig::default(),
948            security: SecurityConfig::default(),
949            fix: crate::config::FixConfig::default(),
950            resolve: ResolveConfig::default(),
951            sealed: false,
952            include_entry_exports: false,
953            auto_imports: false,
954            cache: CacheConfig::default(),
955        };
956        let resolved = config.resolve(
957            PathBuf::from("/project"),
958            OutputFormat::Human,
959            1,
960            true,
961            true,
962            None,
963        );
964        assert_eq!(
965            resolved.overrides.len(),
966            1,
967            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
968        );
969        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
970        assert_eq!(rules.unused_files, Severity::Warn);
971    }
972
973    #[test]
974    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
975        reset_inter_file_warn_dedup_for_test();
976        let files_a = vec!["__test_dedup_a/*".to_string()];
977        let files_b = vec!["__test_dedup_b/*".to_string()];
978
979        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
980        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
981        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
982
983        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
984        assert!(!record_inter_file_warn_seen(
985            "circular-dependency",
986            &files_a
987        ));
988
989        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
990
991        let files_reordered = vec![
992            "__test_dedup_b/*".to_string(),
993            "__test_dedup_a/*".to_string(),
994        ];
995        let files_natural = vec![
996            "__test_dedup_a/*".to_string(),
997            "__test_dedup_b/*".to_string(),
998        ];
999        reset_inter_file_warn_dedup_for_test();
1000        assert!(record_inter_file_warn_seen(
1001            "duplicate-exports",
1002            &files_natural
1003        ));
1004        assert!(!record_inter_file_warn_seen(
1005            "duplicate-exports",
1006            &files_reordered
1007        ));
1008    }
1009
1010    #[test]
1011    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1012        reset_inter_file_warn_dedup_for_test();
1013        let files = vec!["__test_resolve_dedup/**".to_string()];
1014        let build_config = || FallowConfig {
1015            schema: None,
1016            extends: vec![],
1017            entry: vec![],
1018            ignore_patterns: vec![],
1019            framework: vec![],
1020            workspaces: None,
1021            ignore_dependencies: vec![],
1022            ignore_unresolved_imports: vec![],
1023            ignore_exports: vec![],
1024            ignore_catalog_references: vec![],
1025            ignore_dependency_overrides: vec![],
1026            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1027            used_class_members: vec![],
1028            ignore_decorators: vec![],
1029            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1030            duplicates: DuplicatesConfig::default(),
1031            health: HealthConfig::default(),
1032            rules: RulesConfig::default(),
1033            boundaries: BoundaryConfig::default(),
1034            production: false.into(),
1035            plugins: vec![],
1036            rule_packs: vec![],
1037            dynamically_loaded: vec![],
1038            overrides: vec![ConfigOverride {
1039                files: files.clone(),
1040                rules: PartialRulesConfig {
1041                    duplicate_exports: Some(Severity::Off),
1042                    ..Default::default()
1043                },
1044            }],
1045            regression: None,
1046            audit: crate::config::AuditConfig::default(),
1047            codeowners: None,
1048            public_packages: vec![],
1049            flags: FlagsConfig::default(),
1050            security: SecurityConfig::default(),
1051            fix: crate::config::FixConfig::default(),
1052            resolve: ResolveConfig::default(),
1053            sealed: false,
1054            include_entry_exports: false,
1055            auto_imports: false,
1056            cache: CacheConfig::default(),
1057        };
1058        for _ in 0..10 {
1059            let _ = build_config().resolve(
1060                PathBuf::from("/project"),
1061                OutputFormat::Human,
1062                1,
1063                true,
1064                true,
1065                None,
1066            );
1067        }
1068        assert!(
1069            !record_inter_file_warn_seen("duplicate-exports", &files),
1070            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1071        );
1072    }
1073
1074    /// Helper to build a FallowConfig with minimal boilerplate.
1075    fn make_config(production: bool) -> FallowConfig {
1076        FallowConfig {
1077            schema: None,
1078            extends: vec![],
1079            entry: vec![],
1080            ignore_patterns: vec![],
1081            framework: vec![],
1082            workspaces: None,
1083            ignore_dependencies: vec![],
1084            ignore_unresolved_imports: vec![],
1085            ignore_exports: vec![],
1086            ignore_catalog_references: vec![],
1087            ignore_dependency_overrides: vec![],
1088            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1089            used_class_members: vec![],
1090            ignore_decorators: vec![],
1091            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1092            duplicates: DuplicatesConfig::default(),
1093            health: HealthConfig::default(),
1094            rules: RulesConfig::default(),
1095            boundaries: BoundaryConfig::default(),
1096            production: production.into(),
1097            plugins: vec![],
1098            rule_packs: vec![],
1099            dynamically_loaded: vec![],
1100            overrides: vec![],
1101            regression: None,
1102            audit: crate::config::AuditConfig::default(),
1103            codeowners: None,
1104            public_packages: vec![],
1105            flags: FlagsConfig::default(),
1106            security: SecurityConfig::default(),
1107            fix: crate::config::FixConfig::default(),
1108            resolve: ResolveConfig::default(),
1109            sealed: false,
1110            include_entry_exports: false,
1111            auto_imports: false,
1112            cache: CacheConfig::default(),
1113        }
1114    }
1115
1116    #[test]
1117    fn resolve_tracks_rule_pack_sources_in_config_order() {
1118        let dir = tempfile::tempdir().unwrap();
1119        std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1120        std::fs::write(
1121            dir.path().join("rule-packs/team-policy.jsonc"),
1122            r#"{
1123  "version": 1,
1124  "name": "team-policy",
1125  "rules": [
1126    {
1127      "id": "no-moment",
1128      "kind": "banned-import",
1129      "specifiers": ["moment"]
1130    }
1131  ]
1132}
1133"#,
1134        )
1135        .unwrap();
1136
1137        let mut config = make_config(false);
1138        config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1139
1140        let resolved = config.resolve(
1141            dir.path().to_path_buf(),
1142            OutputFormat::Human,
1143            1,
1144            true,
1145            true,
1146            None,
1147        );
1148
1149        assert_eq!(resolved.rule_packs.len(), 1);
1150        assert_eq!(resolved.rule_packs[0].name, "team-policy");
1151        assert_eq!(
1152            resolved.rule_pack_sources,
1153            vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1154        );
1155    }
1156
1157    #[test]
1158    fn resolve_production_forces_dev_deps_off() {
1159        let resolved = make_config(true).resolve(
1160            PathBuf::from("/project"),
1161            OutputFormat::Human,
1162            1,
1163            true,
1164            true,
1165            None,
1166        );
1167        assert_eq!(
1168            resolved.rules.unused_dev_dependencies,
1169            Severity::Off,
1170            "production mode should force unused_dev_dependencies to off"
1171        );
1172    }
1173
1174    #[test]
1175    fn resolve_production_forces_optional_deps_off() {
1176        let resolved = make_config(true).resolve(
1177            PathBuf::from("/project"),
1178            OutputFormat::Human,
1179            1,
1180            true,
1181            true,
1182            None,
1183        );
1184        assert_eq!(
1185            resolved.rules.unused_optional_dependencies,
1186            Severity::Off,
1187            "production mode should force unused_optional_dependencies to off"
1188        );
1189    }
1190
1191    #[test]
1192    fn resolve_production_preserves_other_rules() {
1193        let resolved = make_config(true).resolve(
1194            PathBuf::from("/project"),
1195            OutputFormat::Human,
1196            1,
1197            true,
1198            true,
1199            None,
1200        );
1201        assert_eq!(resolved.rules.unused_files, Severity::Error);
1202        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1203        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1204    }
1205
1206    #[test]
1207    fn resolve_non_production_keeps_dev_deps_default() {
1208        let resolved = make_config(false).resolve(
1209            PathBuf::from("/project"),
1210            OutputFormat::Human,
1211            1,
1212            true,
1213            true,
1214            None,
1215        );
1216        assert_eq!(
1217            resolved.rules.unused_dev_dependencies,
1218            Severity::Warn,
1219            "non-production should keep default severity"
1220        );
1221        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1222    }
1223
1224    #[test]
1225    fn resolve_production_flag_stored() {
1226        let resolved = make_config(true).resolve(
1227            PathBuf::from("/project"),
1228            OutputFormat::Human,
1229            1,
1230            true,
1231            true,
1232            None,
1233        );
1234        assert!(resolved.production);
1235
1236        let resolved2 = make_config(false).resolve(
1237            PathBuf::from("/project"),
1238            OutputFormat::Human,
1239            1,
1240            true,
1241            true,
1242            None,
1243        );
1244        assert!(!resolved2.production);
1245    }
1246
1247    #[test]
1248    fn resolve_default_ignores_node_modules() {
1249        let resolved = make_config(false).resolve(
1250            PathBuf::from("/project"),
1251            OutputFormat::Human,
1252            1,
1253            true,
1254            true,
1255            None,
1256        );
1257        assert!(
1258            resolved
1259                .ignore_patterns
1260                .is_match("node_modules/lodash/index.js")
1261        );
1262        assert!(
1263            resolved
1264                .ignore_patterns
1265                .is_match("packages/a/node_modules/react/index.js")
1266        );
1267    }
1268
1269    #[test]
1270    fn resolve_default_ignores_dist() {
1271        let resolved = make_config(false).resolve(
1272            PathBuf::from("/project"),
1273            OutputFormat::Human,
1274            1,
1275            true,
1276            true,
1277            None,
1278        );
1279        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1280        assert!(
1281            resolved
1282                .ignore_patterns
1283                .is_match("packages/ui/dist/index.js")
1284        );
1285    }
1286
1287    #[test]
1288    fn resolve_default_ignores_root_build_only() {
1289        let resolved = make_config(false).resolve(
1290            PathBuf::from("/project"),
1291            OutputFormat::Human,
1292            1,
1293            true,
1294            true,
1295            None,
1296        );
1297        assert!(
1298            resolved.ignore_patterns.is_match("build/output.js"),
1299            "root build/ should be ignored"
1300        );
1301        assert!(
1302            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1303            "nested build/ should NOT be ignored by default"
1304        );
1305    }
1306
1307    #[test]
1308    fn resolve_default_ignores_minified_files() {
1309        let resolved = make_config(false).resolve(
1310            PathBuf::from("/project"),
1311            OutputFormat::Human,
1312            1,
1313            true,
1314            true,
1315            None,
1316        );
1317        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1318        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1319        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1320        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1321        assert!(
1322            resolved
1323                .ignore_patterns
1324                .is_match("src/vendor/app.bundle.js")
1325        );
1326        // Hand-written source with a similar name stays analyzed.
1327        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1328        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1329    }
1330
1331    #[test]
1332    fn resolve_max_file_size_bytes_default_and_unlimited() {
1333        // Unset keeps the built-in default.
1334        assert_eq!(
1335            resolve_max_file_size_bytes(None),
1336            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1337        );
1338        // `0` means no limit.
1339        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1340        // Any other value is that many megabytes in bytes.
1341        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1342        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1343    }
1344
1345    #[test]
1346    fn resolve_sets_default_max_file_size() {
1347        let resolved = make_config(false).resolve(
1348            PathBuf::from("/project"),
1349            OutputFormat::Human,
1350            1,
1351            true,
1352            true,
1353            None,
1354        );
1355        assert_eq!(
1356            resolved.max_file_size_bytes,
1357            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1358        );
1359    }
1360
1361    #[test]
1362    fn resolve_default_ignores_git() {
1363        let resolved = make_config(false).resolve(
1364            PathBuf::from("/project"),
1365            OutputFormat::Human,
1366            1,
1367            true,
1368            true,
1369            None,
1370        );
1371        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1372    }
1373
1374    #[test]
1375    fn resolve_default_ignores_coverage() {
1376        let resolved = make_config(false).resolve(
1377            PathBuf::from("/project"),
1378            OutputFormat::Human,
1379            1,
1380            true,
1381            true,
1382            None,
1383        );
1384        assert!(
1385            resolved
1386                .ignore_patterns
1387                .is_match("coverage/lcov-report/index.js")
1388        );
1389    }
1390
1391    #[test]
1392    fn resolve_source_files_not_ignored_by_default() {
1393        let resolved = make_config(false).resolve(
1394            PathBuf::from("/project"),
1395            OutputFormat::Human,
1396            1,
1397            true,
1398            true,
1399            None,
1400        );
1401        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1402        assert!(
1403            !resolved
1404                .ignore_patterns
1405                .is_match("src/components/Button.tsx")
1406        );
1407        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1408    }
1409
1410    #[test]
1411    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1412        let mut config = make_config(false);
1413        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1414        let resolved = config.resolve(
1415            PathBuf::from("/project"),
1416            OutputFormat::Human,
1417            1,
1418            true,
1419            true,
1420            None,
1421        );
1422        assert!(
1423            resolved
1424                .ignore_patterns
1425                .is_match("src/__generated__/types.ts")
1426        );
1427        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1428    }
1429
1430    #[test]
1431    fn resolve_normalizes_leading_dot_ignore_patterns() {
1432        let mut config = make_config(false);
1433        config.ignore_patterns = vec!["./src/generated/**".to_string()];
1434        let resolved = config.resolve(
1435            PathBuf::from("/project"),
1436            OutputFormat::Human,
1437            1,
1438            true,
1439            true,
1440            None,
1441        );
1442
1443        assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1444        assert!(
1445            !resolved
1446                .ignore_patterns
1447                .is_match("./src/generated/client.ts")
1448        );
1449    }
1450
1451    #[test]
1452    fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1453        let mut config = make_config(false);
1454        config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1455        let resolved = config.resolve(
1456            PathBuf::from("/project"),
1457            OutputFormat::Human,
1458            1,
1459            true,
1460            true,
1461            None,
1462        );
1463
1464        assert!(
1465            resolved
1466                .ignore_unresolved_imports
1467                .iter()
1468                .any(|matcher| matcher.is_match("src/generated/client"))
1469        );
1470        assert!(
1471            !resolved
1472                .ignore_unresolved_imports
1473                .iter()
1474                .any(|matcher| matcher.is_match("./src/generated/client"))
1475        );
1476    }
1477
1478    #[test]
1479    fn resolve_passes_through_entry_patterns() {
1480        let mut config = make_config(false);
1481        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1482        let resolved = config.resolve(
1483            PathBuf::from("/project"),
1484            OutputFormat::Human,
1485            1,
1486            true,
1487            true,
1488            None,
1489        );
1490        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1491    }
1492
1493    #[test]
1494    fn resolve_passes_through_ignore_dependencies() {
1495        let mut config = make_config(false);
1496        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1497        let resolved = config.resolve(
1498            PathBuf::from("/project"),
1499            OutputFormat::Human,
1500            1,
1501            true,
1502            true,
1503            None,
1504        );
1505        assert_eq!(
1506            resolved.ignore_dependencies,
1507            vec!["postcss", "autoprefixer"]
1508        );
1509    }
1510
1511    #[test]
1512    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1513        let mut config = make_config(false);
1514        config.ignore_unresolved_imports = vec![
1515            "@example/icons".to_string(),
1516            "@example/icons/**".to_string(),
1517            "../generated/**".to_string(),
1518        ];
1519        let resolved = config.resolve(
1520            PathBuf::from("/project"),
1521            OutputFormat::Human,
1522            1,
1523            true,
1524            true,
1525            None,
1526        );
1527
1528        assert!(
1529            resolved
1530                .ignore_unresolved_imports
1531                .iter()
1532                .any(|matcher| matcher.is_match("@example/icons"))
1533        );
1534        assert!(
1535            resolved
1536                .ignore_unresolved_imports
1537                .iter()
1538                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1539        );
1540        assert!(
1541            resolved
1542                .ignore_unresolved_imports
1543                .iter()
1544                .any(|matcher| matcher.is_match("../generated/client"))
1545        );
1546    }
1547
1548    #[test]
1549    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1550        let mut config = make_config(false);
1551        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1552        let resolved = config.resolve(
1553            PathBuf::from("/project"),
1554            OutputFormat::Human,
1555            1,
1556            true,
1557            true,
1558            None,
1559        );
1560
1561        assert!(
1562            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1563            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1564        );
1565        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1566    }
1567
1568    #[test]
1569    fn resolve_sets_cache_dir() {
1570        let resolved = make_config(false).resolve(
1571            PathBuf::from("/my/project"),
1572            OutputFormat::Human,
1573            1,
1574            true,
1575            true,
1576            None,
1577        );
1578        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1579    }
1580
1581    #[test]
1582    fn resolve_uses_relative_configured_cache_dir_from_root() {
1583        let config = FallowConfig {
1584            cache: crate::CacheConfig {
1585                dir: Some(PathBuf::from(".cache/fallow")),
1586                ..Default::default()
1587            },
1588            ..make_config(false)
1589        };
1590        let resolved = config.resolve(
1591            PathBuf::from("/my/project"),
1592            OutputFormat::Human,
1593            1,
1594            false,
1595            true,
1596            None,
1597        );
1598        assert_eq!(
1599            resolved.cache_dir,
1600            PathBuf::from("/my/project/.cache/fallow")
1601        );
1602    }
1603
1604    #[test]
1605    fn resolve_keeps_absolute_configured_cache_dir() {
1606        let config = FallowConfig {
1607            cache: crate::CacheConfig {
1608                dir: Some(PathBuf::from("/tmp/fallow-cache")),
1609                ..Default::default()
1610            },
1611            ..make_config(false)
1612        };
1613        let resolved = config.resolve(
1614            PathBuf::from("/my/project"),
1615            OutputFormat::Human,
1616            1,
1617            false,
1618            true,
1619            None,
1620        );
1621        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1622    }
1623
1624    #[test]
1625    fn resolve_passes_through_thread_count() {
1626        let resolved = make_config(false).resolve(
1627            PathBuf::from("/project"),
1628            OutputFormat::Human,
1629            8,
1630            true,
1631            true,
1632            None,
1633        );
1634        assert_eq!(resolved.threads, 8);
1635    }
1636
1637    #[test]
1638    fn resolve_passes_through_quiet_flag() {
1639        let resolved = make_config(false).resolve(
1640            PathBuf::from("/project"),
1641            OutputFormat::Human,
1642            1,
1643            true,
1644            false,
1645            None,
1646        );
1647        assert!(!resolved.quiet);
1648
1649        let resolved2 = make_config(false).resolve(
1650            PathBuf::from("/project"),
1651            OutputFormat::Human,
1652            1,
1653            true,
1654            true,
1655            None,
1656        );
1657        assert!(resolved2.quiet);
1658    }
1659
1660    #[test]
1661    fn resolve_passes_through_no_cache_flag() {
1662        let resolved_no_cache = make_config(false).resolve(
1663            PathBuf::from("/project"),
1664            OutputFormat::Human,
1665            1,
1666            true,
1667            true,
1668            None,
1669        );
1670        assert!(resolved_no_cache.no_cache);
1671
1672        let resolved_with_cache = make_config(false).resolve(
1673            PathBuf::from("/project"),
1674            OutputFormat::Human,
1675            1,
1676            false,
1677            true,
1678            None,
1679        );
1680        assert!(!resolved_with_cache.no_cache);
1681    }
1682
1683    #[test]
1684    #[should_panic(expected = "validated at config load time")]
1685    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1686        let mut config = make_config(false);
1687        config.overrides = vec![ConfigOverride {
1688            files: vec!["[invalid".to_string()],
1689            rules: PartialRulesConfig {
1690                unused_files: Some(Severity::Off),
1691                ..Default::default()
1692            },
1693        }];
1694        let _ = config.resolve(
1695            PathBuf::from("/project"),
1696            OutputFormat::Human,
1697            1,
1698            true,
1699            true,
1700            None,
1701        );
1702    }
1703
1704    #[test]
1705    fn resolve_override_with_empty_files_skipped() {
1706        let mut config = make_config(false);
1707        config.overrides = vec![ConfigOverride {
1708            files: vec![],
1709            rules: PartialRulesConfig {
1710                unused_files: Some(Severity::Off),
1711                ..Default::default()
1712            },
1713        }];
1714        let resolved = config.resolve(
1715            PathBuf::from("/project"),
1716            OutputFormat::Human,
1717            1,
1718            true,
1719            true,
1720            None,
1721        );
1722        assert!(
1723            resolved.overrides.is_empty(),
1724            "override with no file patterns should be skipped"
1725        );
1726    }
1727
1728    #[test]
1729    fn resolve_multiple_valid_overrides() {
1730        let mut config = make_config(false);
1731        config.overrides = vec![
1732            ConfigOverride {
1733                files: vec!["*.test.ts".to_string()],
1734                rules: PartialRulesConfig {
1735                    unused_exports: Some(Severity::Off),
1736                    ..Default::default()
1737                },
1738            },
1739            ConfigOverride {
1740                files: vec!["*.stories.tsx".to_string()],
1741                rules: PartialRulesConfig {
1742                    unused_files: Some(Severity::Off),
1743                    ..Default::default()
1744                },
1745            },
1746        ];
1747        let resolved = config.resolve(
1748            PathBuf::from("/project"),
1749            OutputFormat::Human,
1750            1,
1751            true,
1752            true,
1753            None,
1754        );
1755        assert_eq!(resolved.overrides.len(), 2);
1756    }
1757
1758    #[test]
1759    fn ignore_export_rule_deserialize() {
1760        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1761        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1762        assert_eq!(rule.file, "src/types/*.ts");
1763        assert_eq!(rule.exports, vec!["*"]);
1764    }
1765
1766    #[test]
1767    fn ignore_export_rule_specific_exports() {
1768        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1769        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1770        assert_eq!(rule.exports.len(), 3);
1771        assert!(rule.exports.contains(&"FOO".to_string()));
1772    }
1773
1774    mod proptests {
1775        use super::*;
1776        use proptest::prelude::*;
1777
1778        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1779            make_config(production).resolve(
1780                PathBuf::from("/project"),
1781                OutputFormat::Human,
1782                1,
1783                true,
1784                true,
1785                None,
1786            )
1787        }
1788
1789        proptest! {
1790            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1791            #[test]
1792            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1793                let resolved = arb_resolved_config(production);
1794                prop_assert!(
1795                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1796                    "Default ignore should match node_modules"
1797                );
1798                prop_assert!(
1799                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1800                    "Default ignore should match dist"
1801                );
1802            }
1803
1804            /// Production mode always forces dev and optional deps to Off.
1805            #[test]
1806            fn production_forces_dev_deps_off(_unused in Just(())) {
1807                let resolved = arb_resolved_config(true);
1808                prop_assert_eq!(
1809                    resolved.rules.unused_dev_dependencies,
1810                    Severity::Off,
1811                    "Production should force unused_dev_dependencies off"
1812                );
1813                prop_assert_eq!(
1814                    resolved.rules.unused_optional_dependencies,
1815                    Severity::Off,
1816                    "Production should force unused_optional_dependencies off"
1817                );
1818            }
1819
1820            /// Non-production mode preserves default severity for dev deps.
1821            #[test]
1822            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1823                let resolved = arb_resolved_config(false);
1824                prop_assert_eq!(
1825                    resolved.rules.unused_dev_dependencies,
1826                    Severity::Warn,
1827                    "Non-production should keep default dev dep severity"
1828                );
1829            }
1830
1831            /// Default cache dir is root/.fallow.
1832            #[test]
1833            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1834                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1835                let expected_cache = root.join(".fallow");
1836                let resolved = make_config(false).resolve(
1837                    root,
1838                    OutputFormat::Human,
1839                    1,
1840                    true,
1841                    true,
1842                    None,
1843                );
1844                prop_assert_eq!(
1845                    resolved.cache_dir, expected_cache,
1846                    "Default cache dir should be root/.fallow"
1847                );
1848            }
1849
1850            /// Thread count is always passed through exactly.
1851            #[test]
1852            fn threads_passed_through(threads in 1..64usize) {
1853                let resolved = make_config(false).resolve(
1854                    PathBuf::from("/project"),
1855                    OutputFormat::Human,
1856                    threads,
1857                    true,
1858                    true, None,
1859                );
1860                prop_assert_eq!(
1861                    resolved.threads, threads,
1862                    "Thread count should be passed through"
1863                );
1864            }
1865
1866            /// Custom ignore patterns are merged with defaults, not replacing them.
1867            /// Uses a pattern regex that cannot match node_modules paths, so the
1868            /// assertion proves the default pattern is what provides the match.
1869            #[test]
1870            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1871                let mut config = make_config(false);
1872                config.ignore_patterns = vec![pattern];
1873                let resolved = config.resolve(
1874                    PathBuf::from("/project"),
1875                    OutputFormat::Human,
1876                    1,
1877                    true,
1878                    true, None,
1879                );
1880                prop_assert!(
1881                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1882                    "Default node_modules ignore should still be active"
1883                );
1884            }
1885        }
1886    }
1887
1888    #[test]
1889    fn resolve_expands_boundary_preset() {
1890        use crate::config::boundaries::BoundaryPreset;
1891
1892        let mut config = make_config(false);
1893        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1894        let resolved = config.resolve(
1895            PathBuf::from("/project"),
1896            OutputFormat::Human,
1897            1,
1898            true,
1899            true,
1900            None,
1901        );
1902        assert_eq!(resolved.boundaries.zones.len(), 3);
1903        assert_eq!(resolved.boundaries.rules.len(), 3);
1904        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1905        assert_eq!(
1906            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1907            Some("adapters")
1908        );
1909    }
1910
1911    #[test]
1912    fn resolve_boundary_preset_with_user_override() {
1913        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1914
1915        let mut config = make_config(false);
1916        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1917        config.boundaries.zones = vec![BoundaryZone {
1918            name: "domain".to_string(),
1919            patterns: vec!["src/core/**".to_string()],
1920            auto_discover: vec![],
1921            root: None,
1922        }];
1923        let resolved = config.resolve(
1924            PathBuf::from("/project"),
1925            OutputFormat::Human,
1926            1,
1927            true,
1928            true,
1929            None,
1930        );
1931        assert_eq!(resolved.boundaries.zones.len(), 3);
1932        assert_eq!(
1933            resolved.boundaries.classify_zone("src/core/user.ts"),
1934            Some("domain")
1935        );
1936        assert_eq!(
1937            resolved.boundaries.classify_zone("src/domain/user.ts"),
1938            None
1939        );
1940    }
1941
1942    #[test]
1943    fn resolve_no_preset_unchanged() {
1944        let config = make_config(false);
1945        let resolved = config.resolve(
1946            PathBuf::from("/project"),
1947            OutputFormat::Human,
1948            1,
1949            true,
1950            true,
1951            None,
1952        );
1953        assert!(resolved.boundaries.is_empty());
1954    }
1955}