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::FallowConfig;
22use super::IgnoreExportsUsedInFileConfig;
23
24/// Process-local dedup state for inter-file rule warnings.
25///
26/// Workspace mode calls `FallowConfig::resolve()` once per package, so a single
27/// top-level config with `overrides.rules.{duplicate-exports,circular-dependency}`
28/// would otherwise emit the same warning N times. The set is keyed on a stable
29/// hash of (rule name, sorted glob list) so logically identical override blocks
30/// dedupe across all package resolves.
31///
32/// The state persists across resolves within a single process. That matches the
33/// CLI's "one warning per invocation" expectation. In long-running hosts
34/// (`fallow watch`, the LSP server, NAPI consumers re-using a worker, the MCP
35/// server) the same set survives between re-runs and re-loads, so a user who
36/// edits the config and triggers a re-analysis sees the warning at most once
37/// per process lifetime. That is the documented behavior; restarting the host
38/// re-arms the warning.
39static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
40
41/// Stable hash of `(rule_name, sorted glob list)`.
42///
43/// Sorting deduplicates `["a/*", "b/*"]` against `["b/*", "a/*"]`. The element-
44/// wise hash loop is explicit so the lint sees the sorted Vec as read.
45fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
46    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
47    sorted.sort_unstable();
48    let mut hasher = DefaultHasher::new();
49    rule_name.hash(&mut hasher);
50    for s in &sorted {
51        s.hash(&mut hasher);
52    }
53    hasher.finish()
54}
55
56/// Returns `true` if this `(rule_name, files)` warning has not yet been recorded
57/// in the current process; `false` if it has already fired (or the mutex was
58/// poisoned, in which case we behave as if the warning had not fired yet so the
59/// user still sees one warning).
60fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
61    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
62    let key = inter_file_warn_key(rule_name, files);
63    seen.lock().map_or(true, |mut set| set.insert(key))
64}
65
66#[cfg(test)]
67fn reset_inter_file_warn_dedup_for_test() {
68    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
69        && let Ok(mut set) = seen.lock()
70    {
71        set.clear();
72    }
73}
74
75/// Rule for ignoring specific exports.
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
77pub struct IgnoreExportRule {
78    /// Glob pattern for files.
79    pub file: String,
80    /// Export names to ignore (`*` for all).
81    pub exports: Vec<String>,
82}
83
84/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
85///
86/// Workspace mode runs `find_unused_exports` and `find_duplicate_exports` once
87/// per package, each of which previously re-compiled the same set of globs from
88/// `ignore_export_rules`. Compiling once at `ResolvedConfig` construction and
89/// reading `&[CompiledIgnoreExportRule]` from both detectors removes that work.
90#[derive(Debug)]
91pub struct CompiledIgnoreExportRule {
92    pub matcher: globset::GlobMatcher,
93    pub exports: Vec<String>,
94}
95
96/// Rule for suppressing an `unresolved-catalog-reference` finding.
97///
98/// A finding is suppressed when ALL provided fields match the finding:
99/// - `package` matches the consumed package name exactly (case-sensitive).
100/// - `catalog`, if set, matches the referenced catalog name (`"default"` for
101///   bare `catalog:` references; named catalogs use their declared key). When
102///   omitted, any catalog matches.
103/// - `consumer`, if set, is a glob matched against the consumer `package.json`
104///   path relative to the project root. When omitted, any consumer matches.
105///
106/// Typical use cases:
107/// - Staged migrations: catalog entry is being added in a separate PR
108/// - Library-internal placeholder packages whose target catalog isn't ready yet
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct IgnoreCatalogReferenceRule {
112    /// Package name being referenced via the catalog protocol (exact match).
113    pub package: String,
114    /// Catalog name to scope the suppression to. `None` matches any catalog.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub catalog: Option<String>,
117    /// Glob (root-relative) for the consumer `package.json`. `None` matches any consumer.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub consumer: Option<String>,
120}
121
122/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
123#[derive(Debug)]
124pub struct CompiledIgnoreCatalogReferenceRule {
125    pub package: String,
126    pub catalog: Option<String>,
127    /// `None` means "match any consumer path"; `Some` matches only paths the glob accepts.
128    pub consumer_matcher: Option<globset::GlobMatcher>,
129}
130
131impl CompiledIgnoreCatalogReferenceRule {
132    /// Whether this rule suppresses an `unresolved-catalog-reference` finding
133    /// for the given (package, catalog, consumer-path) triple. The consumer
134    /// path must be project-root-relative.
135    #[must_use]
136    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
137        if self.package != package {
138            return false;
139        }
140        if let Some(catalog_filter) = &self.catalog
141            && catalog_filter != catalog
142        {
143            return false;
144        }
145        if let Some(matcher) = &self.consumer_matcher
146            && !matcher.is_match(consumer_path)
147        {
148            return false;
149        }
150        true
151    }
152}
153
154/// Rule for suppressing an `unused-dependency-override` or
155/// `misconfigured-dependency-override` finding.
156///
157/// A finding is suppressed when ALL provided fields match the finding:
158/// - `package` matches the override's target package name exactly
159///   (case-sensitive). For parent-chain overrides (`react>react-dom`), the
160///   target is the rightmost segment (`react-dom`).
161/// - `source`, if set, scopes the suppression to overrides declared in that
162///   source file. Accepts `"pnpm-workspace.yaml"` or `"package.json"`.
163///   When omitted, both sources match.
164///
165/// Typical use cases:
166/// - Library-internal CI tooling overrides we cannot drop yet
167/// - Overrides targeting purely-transitive packages (CVE-fix pattern)
168#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct IgnoreDependencyOverrideRule {
171    /// Override target package name (exact match; case-sensitive).
172    pub package: String,
173    /// Source file scope: `"pnpm-workspace.yaml"` or `"package.json"`.
174    /// `None` matches both sources.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<String>,
177}
178
179/// `IgnoreDependencyOverrideRule` ready for matching.
180#[derive(Debug)]
181pub struct CompiledIgnoreDependencyOverrideRule {
182    pub package: String,
183    /// `None` matches any source; `Some` matches only the named source.
184    pub source: Option<String>,
185}
186
187impl CompiledIgnoreDependencyOverrideRule {
188    /// Whether this rule suppresses a dependency-override finding for the
189    /// given (target_package, source_label) pair. `source_label` should be
190    /// `"pnpm-workspace.yaml"` or `"package.json"`.
191    #[must_use]
192    pub fn matches(&self, package: &str, source_label: &str) -> bool {
193        if self.package != package {
194            return false;
195        }
196        if let Some(source_filter) = &self.source
197            && source_filter != source_label
198        {
199            return false;
200        }
201        true
202    }
203}
204
205/// Per-file override entry.
206#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct ConfigOverride {
209    /// Glob patterns to match files against (relative to config file location).
210    pub files: Vec<String>,
211    /// Partial rules — only specified fields override the base rules.
212    #[serde(default)]
213    pub rules: PartialRulesConfig,
214}
215
216/// Resolved override with pre-compiled glob matchers.
217#[derive(Debug)]
218pub struct ResolvedOverride {
219    pub matchers: Vec<globset::GlobMatcher>,
220    pub rules: PartialRulesConfig,
221}
222
223/// Fully resolved configuration with all globs pre-compiled.
224#[derive(Debug)]
225pub struct ResolvedConfig {
226    pub root: PathBuf,
227    pub entry_patterns: Vec<String>,
228    pub ignore_patterns: GlobSet,
229    pub output: OutputFormat,
230    pub cache_dir: PathBuf,
231    pub threads: usize,
232    pub no_cache: bool,
233    /// Resolved on-disk cache cap in megabytes. `None` selects the default
234    /// (`fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE`, 256 MB). Computed
235    /// at the CLI layer as `FALLOW_CACHE_MAX_SIZE` env var (if set), else
236    /// `cache.maxSizeMb` in the config file. Stored in MB rather than
237    /// bytes so that the config crate has no dependency on
238    /// `fallow-extract`; the bytes resolution happens at the callsite
239    /// (`fallow_core::lib::analyze_full`).
240    pub cache_max_size_mb: Option<u32>,
241    /// Stable u64 hash of extraction-affecting config fields (currently the
242    /// active external plugin names + inline framework definition names).
243    /// Threaded into `CacheStore::load` and `CacheStore::save` so a config
244    /// change discards the stale cache without requiring a `CACHE_VERSION`
245    /// bump. See ADR-009 for the ingredient list and the contract for
246    /// adding new ingredients in the future. Zero when `no_cache` is set
247    /// (the bookkeeping is skipped to avoid unnecessary work when caching
248    /// is disabled).
249    pub cache_config_hash: u64,
250    pub ignore_dependencies: Vec<String>,
251    /// Pre-compiled raw import specifier patterns for suppressing
252    /// `unresolved-import` findings.
253    pub ignore_unresolved_imports: Vec<GlobMatcher>,
254    pub ignore_export_rules: Vec<IgnoreExportRule>,
255    /// Pre-compiled glob matchers for `ignoreExports`.
256    ///
257    /// Populated alongside `ignore_export_rules` so detectors that need to test
258    /// "does this file match a configured `ignoreExports` glob?" can read the
259    /// compiled matchers without re-running `globset::Glob::new` per call.
260    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
261    /// Pre-compiled rules for suppressing `unresolved-catalog-reference` findings.
262    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
263    /// Pre-compiled rules for suppressing dependency-override findings (both
264    /// `unused-dependency-override` and `misconfigured-dependency-override`).
265    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
266    /// Whether same-file references should suppress unused-export findings.
267    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
268    /// Class member names that should never be flagged as unused-class-members.
269    /// Union of top-level config and active plugin contributions; merged during
270    /// config resolution so analysis code reads a single list.
271    pub used_class_members: Vec<UsedClassMemberRule>,
272    /// Decorator paths the user has opted out of the default skip-all-decorated
273    /// behavior for `unused-class-members`. See `FallowConfig::ignore_decorators`
274    /// for matching semantics. Passed through unchanged from the user config
275    /// (no glob compilation; small set, linear scan at the call site).
276    pub ignore_decorators: Vec<String>,
277    pub duplicates: DuplicatesConfig,
278    pub health: HealthConfig,
279    pub rules: RulesConfig,
280    /// Resolved architecture boundary configuration with pre-compiled glob matchers.
281    pub boundaries: ResolvedBoundaryConfig,
282    /// Whether production mode is active.
283    pub production: bool,
284    /// Suppress progress output and non-essential stderr messages.
285    pub quiet: bool,
286    /// External plugin definitions (from plugin files + inline framework definitions).
287    pub external_plugins: Vec<ExternalPluginDef>,
288    /// Glob patterns for dynamically loaded files (treated as always-used).
289    pub dynamically_loaded: Vec<String>,
290    /// Per-file rule overrides with pre-compiled glob matchers.
291    pub overrides: Vec<ResolvedOverride>,
292    /// Regression config (passed through from user config, not resolved).
293    pub regression: Option<super::RegressionConfig>,
294    /// Audit baseline paths (passed through from user config, not resolved).
295    pub audit: super::AuditConfig,
296    /// Optional CODEOWNERS file path (passed through for `--group-by owner`).
297    pub codeowners: Option<String>,
298    /// Workspace package name patterns that are public libraries.
299    /// Exported API surface from these packages is not flagged as unused.
300    pub public_packages: Vec<String>,
301    /// Feature flag detection configuration.
302    pub flags: FlagsConfig,
303    /// Auto-fix behavior settings.
304    pub fix: super::FixConfig,
305    /// Module resolver configuration (user-supplied import/export conditions).
306    pub resolve: ResolveConfig,
307    /// When true, entry file exports are subject to unused-export detection
308    /// instead of being automatically marked as used. Set via the global CLI flag
309    /// `--include-entry-exports` or via `includeEntryExports: true` in the fallow
310    /// config file; the CLI flag ORs with the config value (CLI wins when set).
311    pub include_entry_exports: bool,
312    /// When true, framework plugins drop the convention entry patterns they can
313    /// resolve via `auto_imports` (today: Nuxt component directories), so
314    /// genuinely-unreferenced convention files are reported as `unused-file`.
315    /// Auto-import edge synthesis runs regardless of this flag; it only gates the
316    /// entry-pattern removal. Set via `autoImports: true` in the config. See
317    /// issue #704.
318    pub auto_imports: bool,
319}
320
321/// Compute the cache-invalidation hash over extraction-affecting config
322/// fields. See ADR-009 for the contract: this hash is stored in the cache
323/// header, and a mismatch on load discards the cache.
324///
325/// Today's ingredients (sorted for determinism across runs):
326/// - Active external plugin names. `discover_external_plugins` finalises the
327///   plugin set after merging inline `framework: [...]` definitions, so we
328///   hash the post-merge list.
329///
330/// **Adding a new ingredient.** Any new `ResolvedConfig` field that affects
331/// what extraction emits (e.g. a future "extract source-map references"
332/// toggle) MUST be folded into this hash, otherwise stale caches keep
333/// serving the old extraction output across the config change. The signal
334/// is "field affects extraction output bytes," not "field affects detection
335/// behavior" (detection-only fields like `entry`/`ignorePatterns` belong on
336/// the analysis layer, not in the cache key).
337fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
338    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
339    names.sort_unstable();
340    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
341    for name in names {
342        // Length-prefix each name so `["ab", "c"]` and `["a", "bc"]` hash
343        // distinctly even though the concatenated bytes are identical.
344        hasher.update(&(name.len() as u32).to_le_bytes());
345        hasher.update(name.as_bytes());
346    }
347    hasher.digest()
348}
349
350impl FallowConfig {
351    /// Resolve into a fully resolved config with compiled globs.
352    ///
353    /// `cache_max_size_mb` is the user's override for the cache cap (env var
354    /// or in-config `cache.maxSizeMb`). When `None`, the cap defaults to
355    /// `fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE` (256 MB). Env-var
356    /// precedence is resolved at the CLI layer, so the resolver itself only
357    /// sees the final value.
358    pub fn resolve(
359        self,
360        root: PathBuf,
361        output: OutputFormat,
362        threads: usize,
363        no_cache: bool,
364        quiet: bool,
365        cache_max_size_mb: Option<u32>,
366    ) -> ResolvedConfig {
367        // User-supplied patterns are validated by `FallowConfig::load`
368        // (issue #463). Configs constructed in-code (tests, defaults) bypass
369        // load and are assumed to use valid patterns; an invalid pattern here
370        // surfaces as a panic, which is correct for a programming error.
371        let mut ignore_builder = GlobSetBuilder::new();
372        for pattern in &self.ignore_patterns {
373            ignore_builder.add(
374                Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
375            );
376        }
377
378        // Default ignores (hardcoded, known-good patterns).
379        // Note: `build/` is only ignored at the project root (not `**/build/**`)
380        // because nested `build/` directories like `test/build/` may contain source files.
381        let default_ignores = [
382            "**/node_modules/**",
383            "**/dist/**",
384            "build/**",
385            "**/.git/**",
386            "**/coverage/**",
387            "**/*.min.js",
388            "**/*.min.mjs",
389        ];
390        for pattern in &default_ignores {
391            ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
392        }
393
394        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
395        let ignore_unresolved_imports: Vec<GlobMatcher> = self
396            .ignore_unresolved_imports
397            .iter()
398            .map(|pattern| {
399                Glob::new(pattern)
400                    .expect("ignoreUnresolvedImports entry was validated at config load time")
401                    .compile_matcher()
402            })
403            .collect();
404        let cache_dir = root.join(".fallow");
405
406        let mut rules = self.rules;
407
408        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
409        let production = self.production.global();
410        if production {
411            rules.unused_dev_dependencies = Severity::Off;
412            rules.unused_optional_dependencies = Severity::Off;
413        }
414
415        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
416        // Merge inline framework definitions into external plugins
417        external_plugins.extend(self.framework);
418
419        // Expand boundary preset (if configured) before validation.
420        // Detect source root from tsconfig.json, falling back to "src".
421        let mut boundaries = self.boundaries;
422        if boundaries.preset.is_some() {
423            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
424                .filter(|r| {
425                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
426                })
427                .unwrap_or_else(|| "src".to_owned());
428            if source_root != "src" {
429                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
430            }
431            boundaries.expand(&source_root);
432        }
433        // MUST run AFTER `expand` and BEFORE `validate_zone_references`. Presets
434        // like Bulletproof emit a rule whose `from` is the logical group name
435        // (`features`) that auto-discovery later replaces with concrete child
436        // zones (`features/auth`, `features/billing`). Moving validation above
437        // expansion makes the preset look like it references undefined zones.
438        //
439        // The returned `logical_groups` records the pre-expansion parent
440        // identity (name, children, the user's verbatim `autoDiscover` paths,
441        // the authored rule, and discovery status). It is stashed onto
442        // `ResolvedBoundaryConfig` further down so `fallow list --boundaries
443        // --format json` can surface the user's grouping intent even after
444        // the parent name is flattened out of `zones[]`. Closes issue #373.
445        let logical_groups = boundaries.expand_auto_discover(&root);
446
447        // Compile architecture boundary config. Validation errors
448        // (`validate_zone_references` + `validate_root_prefixes`) are surfaced
449        // via `FallowConfig::validate_resolved_boundaries` at config load
450        // time (issue #468); by the time `resolve()` runs they have already
451        // exited the process with exit code 2. Test fixtures that bypass the
452        // load path and construct configs in-code are responsible for keeping
453        // their zone references and root prefixes valid.
454        let mut boundaries = boundaries.resolve();
455        // `expand_auto_discover` is the only producer of `logical_groups`;
456        // `resolve()` has no view of the pre-expansion state and leaves the
457        // field empty. Stitch it back together here.
458        boundaries.logical_groups = logical_groups;
459
460        // Pre-compile override glob matchers
461        let overrides = self
462            .overrides
463            .into_iter()
464            .filter_map(|o| {
465                // Inter-file rules group findings across multiple files (a
466                // single duplicate-exports finding spans N files; a single
467                // circular-dependency finding spans M files in a cycle), so a
468                // per-file `overrides.rules` setting cannot meaningfully turn
469                // them off: the override only fires when the path being looked
470                // up matches, but the finding belongs to a group of paths, not
471                // to one. Warn at load time and point users at the working
472                // escape hatch (`ignoreExports` for duplicates, file-level
473                // `// fallow-ignore-file circular-dependency` for cycles).
474                if o.rules.duplicate_exports.is_some()
475                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
476                {
477                    let files = o.files.join(", ");
478                    tracing::warn!(
479                        "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."
480                    );
481                }
482                if o.rules.circular_dependencies.is_some()
483                    && record_inter_file_warn_seen("circular-dependency", &o.files)
484                {
485                    let files = o.files.join(", ");
486                    tracing::warn!(
487                        "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."
488                    );
489                }
490                if o.rules.re_export_cycle.is_some()
491                    && record_inter_file_warn_seen("re-export-cycle", &o.files)
492                {
493                    let files = o.files.join(", ");
494                    tracing::warn!(
495                        "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."
496                    );
497                }
498                let matchers: Vec<globset::GlobMatcher> = o
499                    .files
500                    .iter()
501                    .map(|pattern| {
502                        Glob::new(pattern)
503                            .expect("overrides[].files pattern was validated at config load time")
504                            .compile_matcher()
505                    })
506                    .collect();
507                if matchers.is_empty() {
508                    None
509                } else {
510                    Some(ResolvedOverride {
511                        matchers,
512                        rules: o.rules,
513                    })
514                }
515            })
516            .collect();
517
518        // Compile `ignoreExports` once at resolve time so both `find_unused_exports`
519        // and `find_duplicate_exports` can read pre-built matchers from
520        // `ResolvedConfig`. Patterns were validated at config load time.
521        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
522            .ignore_exports
523            .iter()
524            .map(|rule| CompiledIgnoreExportRule {
525                matcher: Glob::new(&rule.file)
526                    .expect("ignoreExports[].file was validated at config load time")
527                    .compile_matcher(),
528                exports: rule.exports.clone(),
529            })
530            .collect();
531
532        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
533            .ignore_catalog_references
534            .iter()
535            .map(|rule| CompiledIgnoreCatalogReferenceRule {
536                package: rule.package.clone(),
537                catalog: rule.catalog.clone(),
538                consumer_matcher: rule.consumer.as_ref().map(|pattern| {
539                    Glob::new(pattern)
540                        .expect(
541                            "ignoreCatalogReferences[].consumer was validated at config load time",
542                        )
543                        .compile_matcher()
544                }),
545            })
546            .collect();
547
548        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
549            .ignore_dependency_overrides
550            .iter()
551            .map(|rule| CompiledIgnoreDependencyOverrideRule {
552                package: rule.package.clone(),
553                source: rule.source.clone(),
554            })
555            .collect();
556
557        // Resolve the cache cap. Env-var precedence is handled at the CLI
558        // layer (CLI passes either the env-var value or `None`), so here we
559        // just fall back to the in-config `cache.maxSizeMb`. The bytes
560        // conversion happens at the `CacheStore::save` callsite (in
561        // `fallow_core`), keeping `fallow-config` independent of
562        // `fallow-extract`.
563        let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
564
565        // Compute the cache config hash. The hash invalidates the cache on
566        // user-driven config changes that affect extraction (currently:
567        // active external plugin names + inline framework definition
568        // names; see ADR-009 for the contract). Skipped under `no_cache`
569        // so the bookkeeping is zero-cost when caching is disabled.
570        let cache_config_hash = if no_cache {
571            0
572        } else {
573            compute_cache_config_hash(&external_plugins)
574        };
575
576        ResolvedConfig {
577            root,
578            entry_patterns: self.entry,
579            ignore_patterns: compiled_ignore_patterns,
580            output,
581            cache_dir,
582            threads,
583            no_cache,
584            cache_max_size_mb,
585            cache_config_hash,
586            ignore_dependencies: self.ignore_dependencies,
587            ignore_unresolved_imports,
588            ignore_export_rules: self.ignore_exports,
589            compiled_ignore_exports,
590            compiled_ignore_catalog_references,
591            compiled_ignore_dependency_overrides,
592            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
593            used_class_members: self.used_class_members,
594            ignore_decorators: self.ignore_decorators,
595            duplicates: self.duplicates,
596            health: self.health,
597            rules,
598            boundaries,
599            production,
600            quiet,
601            external_plugins,
602            dynamically_loaded: self.dynamically_loaded,
603            overrides,
604            regression: self.regression,
605            audit: self.audit,
606            codeowners: self.codeowners,
607            public_packages: self.public_packages,
608            flags: self.flags,
609            fix: self.fix,
610            resolve: self.resolve,
611            include_entry_exports: self.include_entry_exports,
612            auto_imports: self.auto_imports,
613        }
614    }
615}
616
617impl ResolvedConfig {
618    /// Resolve the effective rules for a given file path.
619    /// Starts with base rules and applies matching overrides in order.
620    #[must_use]
621    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
622        if self.overrides.is_empty() {
623            return self.rules.clone();
624        }
625
626        let relative = path.strip_prefix(&self.root).unwrap_or(path);
627        let relative_str = relative.to_string_lossy();
628
629        let mut rules = self.rules.clone();
630        for override_entry in &self.overrides {
631            let matches = override_entry
632                .matchers
633                .iter()
634                .any(|m| m.is_match(relative_str.as_ref()));
635            if matches {
636                rules.apply_partial(&override_entry.rules);
637            }
638        }
639        rules
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::CacheConfig;
647    use crate::config::boundaries::BoundaryConfig;
648    use crate::config::health::HealthConfig;
649
650    #[test]
651    fn overrides_deserialize() {
652        let json_str = r#"{
653            "overrides": [{
654                "files": ["*.test.ts"],
655                "rules": {
656                    "unused-exports": "off"
657                }
658            }]
659        }"#;
660        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
661        assert_eq!(config.overrides.len(), 1);
662        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
663        assert_eq!(
664            config.overrides[0].rules.unused_exports,
665            Some(Severity::Off)
666        );
667        assert_eq!(config.overrides[0].rules.unused_files, None);
668    }
669
670    #[test]
671    fn resolve_rules_for_path_no_overrides() {
672        let config = FallowConfig {
673            schema: None,
674            extends: vec![],
675            entry: vec![],
676            ignore_patterns: vec![],
677            framework: vec![],
678            workspaces: None,
679            ignore_dependencies: vec![],
680            ignore_unresolved_imports: vec![],
681            ignore_exports: vec![],
682            ignore_catalog_references: vec![],
683            ignore_dependency_overrides: vec![],
684            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
685            used_class_members: vec![],
686            ignore_decorators: vec![],
687            duplicates: DuplicatesConfig::default(),
688            health: HealthConfig::default(),
689            rules: RulesConfig::default(),
690            boundaries: BoundaryConfig::default(),
691            production: false.into(),
692            plugins: vec![],
693            dynamically_loaded: vec![],
694            overrides: vec![],
695            regression: None,
696            audit: crate::config::AuditConfig::default(),
697            codeowners: None,
698            public_packages: vec![],
699            flags: FlagsConfig::default(),
700            fix: crate::config::FixConfig::default(),
701            resolve: ResolveConfig::default(),
702            sealed: false,
703            include_entry_exports: false,
704            auto_imports: false,
705            cache: CacheConfig::default(),
706        };
707        let resolved = config.resolve(
708            PathBuf::from("/project"),
709            OutputFormat::Human,
710            1,
711            true,
712            true,
713            None,
714        );
715        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
716        assert_eq!(rules.unused_files, Severity::Error);
717    }
718
719    #[test]
720    fn resolve_rules_for_path_with_matching_override() {
721        let config = FallowConfig {
722            schema: None,
723            extends: vec![],
724            entry: vec![],
725            ignore_patterns: vec![],
726            framework: vec![],
727            workspaces: None,
728            ignore_dependencies: vec![],
729            ignore_unresolved_imports: vec![],
730            ignore_exports: vec![],
731            ignore_catalog_references: vec![],
732            ignore_dependency_overrides: vec![],
733            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
734            used_class_members: vec![],
735            ignore_decorators: vec![],
736            duplicates: DuplicatesConfig::default(),
737            health: HealthConfig::default(),
738            rules: RulesConfig::default(),
739            boundaries: BoundaryConfig::default(),
740            production: false.into(),
741            plugins: vec![],
742            dynamically_loaded: vec![],
743            overrides: vec![ConfigOverride {
744                files: vec!["*.test.ts".to_string()],
745                rules: PartialRulesConfig {
746                    unused_exports: Some(Severity::Off),
747                    ..Default::default()
748                },
749            }],
750            regression: None,
751            audit: crate::config::AuditConfig::default(),
752            codeowners: None,
753            public_packages: vec![],
754            flags: FlagsConfig::default(),
755            fix: crate::config::FixConfig::default(),
756            resolve: ResolveConfig::default(),
757            sealed: false,
758            include_entry_exports: false,
759            auto_imports: false,
760            cache: CacheConfig::default(),
761        };
762        let resolved = config.resolve(
763            PathBuf::from("/project"),
764            OutputFormat::Human,
765            1,
766            true,
767            true,
768            None,
769        );
770
771        // Test file matches override
772        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
773        assert_eq!(test_rules.unused_exports, Severity::Off);
774        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
775
776        // Non-test file does not match
777        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
778        assert_eq!(src_rules.unused_exports, Severity::Error);
779    }
780
781    #[test]
782    fn resolve_rules_for_path_later_override_wins() {
783        let config = FallowConfig {
784            schema: None,
785            extends: vec![],
786            entry: vec![],
787            ignore_patterns: vec![],
788            framework: vec![],
789            workspaces: None,
790            ignore_dependencies: vec![],
791            ignore_unresolved_imports: vec![],
792            ignore_exports: vec![],
793            ignore_catalog_references: vec![],
794            ignore_dependency_overrides: vec![],
795            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
796            used_class_members: vec![],
797            ignore_decorators: vec![],
798            duplicates: DuplicatesConfig::default(),
799            health: HealthConfig::default(),
800            rules: RulesConfig::default(),
801            boundaries: BoundaryConfig::default(),
802            production: false.into(),
803            plugins: vec![],
804            dynamically_loaded: vec![],
805            overrides: vec![
806                ConfigOverride {
807                    files: vec!["*.ts".to_string()],
808                    rules: PartialRulesConfig {
809                        unused_files: Some(Severity::Warn),
810                        ..Default::default()
811                    },
812                },
813                ConfigOverride {
814                    files: vec!["*.test.ts".to_string()],
815                    rules: PartialRulesConfig {
816                        unused_files: Some(Severity::Off),
817                        ..Default::default()
818                    },
819                },
820            ],
821            regression: None,
822            audit: crate::config::AuditConfig::default(),
823            codeowners: None,
824            public_packages: vec![],
825            flags: FlagsConfig::default(),
826            fix: crate::config::FixConfig::default(),
827            resolve: ResolveConfig::default(),
828            sealed: false,
829            include_entry_exports: false,
830            auto_imports: false,
831            cache: CacheConfig::default(),
832        };
833        let resolved = config.resolve(
834            PathBuf::from("/project"),
835            OutputFormat::Human,
836            1,
837            true,
838            true,
839            None,
840        );
841
842        // First override matches *.ts, second matches *.test.ts; second wins
843        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
844        assert_eq!(rules.unused_files, Severity::Off);
845
846        // Non-test .ts file only matches first override
847        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
848        assert_eq!(rules2.unused_files, Severity::Warn);
849    }
850
851    #[test]
852    fn resolve_keeps_inter_file_rule_override_after_warning() {
853        // Setting `overrides.rules.duplicate-exports` for a file glob is a no-op
854        // at finding-time (duplicate-exports groups span multiple files), but the
855        // override must still resolve cleanly so other co-located rule settings
856        // on the same override are honored. The resolver emits a tracing warning;
857        // here we assert the override is still installed for non-inter-file rules.
858        let config = FallowConfig {
859            schema: None,
860            extends: vec![],
861            entry: vec![],
862            ignore_patterns: vec![],
863            framework: vec![],
864            workspaces: None,
865            ignore_dependencies: vec![],
866            ignore_unresolved_imports: vec![],
867            ignore_exports: vec![],
868            ignore_catalog_references: vec![],
869            ignore_dependency_overrides: vec![],
870            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
871            used_class_members: vec![],
872            ignore_decorators: vec![],
873            duplicates: DuplicatesConfig::default(),
874            health: HealthConfig::default(),
875            rules: RulesConfig::default(),
876            boundaries: BoundaryConfig::default(),
877            production: false.into(),
878            plugins: vec![],
879            dynamically_loaded: vec![],
880            overrides: vec![ConfigOverride {
881                files: vec!["**/ui/**".to_string()],
882                rules: PartialRulesConfig {
883                    duplicate_exports: Some(Severity::Off),
884                    unused_files: Some(Severity::Warn),
885                    ..Default::default()
886                },
887            }],
888            regression: None,
889            audit: crate::config::AuditConfig::default(),
890            codeowners: None,
891            public_packages: vec![],
892            flags: FlagsConfig::default(),
893            fix: crate::config::FixConfig::default(),
894            resolve: ResolveConfig::default(),
895            sealed: false,
896            include_entry_exports: false,
897            auto_imports: false,
898            cache: CacheConfig::default(),
899        };
900        let resolved = config.resolve(
901            PathBuf::from("/project"),
902            OutputFormat::Human,
903            1,
904            true,
905            true,
906            None,
907        );
908        assert_eq!(
909            resolved.overrides.len(),
910            1,
911            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
912        );
913        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
914        assert_eq!(rules.unused_files, Severity::Warn);
915    }
916
917    #[test]
918    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
919        // Reset shared state so test ordering does not affect the assertions
920        // below. Uses unique glob strings (`__test_dedup_*`) so other tests in
921        // this module that exercise the warn path do not collide.
922        reset_inter_file_warn_dedup_for_test();
923        let files_a = vec!["__test_dedup_a/*".to_string()];
924        let files_b = vec!["__test_dedup_b/*".to_string()];
925
926        // First call fires; subsequent identical calls do not.
927        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
928        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
929        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
930
931        // Different rule name is a distinct key.
932        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
933        assert!(!record_inter_file_warn_seen(
934            "circular-dependency",
935            &files_a
936        ));
937
938        // Different glob list is a distinct key.
939        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
940
941        // Order-insensitive glob list collapses to the same key.
942        let files_reordered = vec![
943            "__test_dedup_b/*".to_string(),
944            "__test_dedup_a/*".to_string(),
945        ];
946        let files_natural = vec![
947            "__test_dedup_a/*".to_string(),
948            "__test_dedup_b/*".to_string(),
949        ];
950        reset_inter_file_warn_dedup_for_test();
951        assert!(record_inter_file_warn_seen(
952            "duplicate-exports",
953            &files_natural
954        ));
955        assert!(!record_inter_file_warn_seen(
956            "duplicate-exports",
957            &files_reordered
958        ));
959    }
960
961    #[test]
962    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
963        // Drive `FallowConfig::resolve()` ten times with identical
964        // `overrides.rules.duplicate-exports` to mirror workspace mode (one
965        // resolve per package). The dedup must surface the warn key as
966        // already-seen on every call after the first.
967        reset_inter_file_warn_dedup_for_test();
968        let files = vec!["__test_resolve_dedup/**".to_string()];
969        let build_config = || FallowConfig {
970            schema: None,
971            extends: vec![],
972            entry: vec![],
973            ignore_patterns: vec![],
974            framework: vec![],
975            workspaces: None,
976            ignore_dependencies: vec![],
977            ignore_unresolved_imports: vec![],
978            ignore_exports: vec![],
979            ignore_catalog_references: vec![],
980            ignore_dependency_overrides: vec![],
981            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
982            used_class_members: vec![],
983            ignore_decorators: vec![],
984            duplicates: DuplicatesConfig::default(),
985            health: HealthConfig::default(),
986            rules: RulesConfig::default(),
987            boundaries: BoundaryConfig::default(),
988            production: false.into(),
989            plugins: vec![],
990            dynamically_loaded: vec![],
991            overrides: vec![ConfigOverride {
992                files: files.clone(),
993                rules: PartialRulesConfig {
994                    duplicate_exports: Some(Severity::Off),
995                    ..Default::default()
996                },
997            }],
998            regression: None,
999            audit: crate::config::AuditConfig::default(),
1000            codeowners: None,
1001            public_packages: vec![],
1002            flags: FlagsConfig::default(),
1003            fix: crate::config::FixConfig::default(),
1004            resolve: ResolveConfig::default(),
1005            sealed: false,
1006            include_entry_exports: false,
1007            auto_imports: false,
1008            cache: CacheConfig::default(),
1009        };
1010        for _ in 0..10 {
1011            let _ = build_config().resolve(
1012                PathBuf::from("/project"),
1013                OutputFormat::Human,
1014                1,
1015                true,
1016                true,
1017                None,
1018            );
1019        }
1020        // After 10 resolves the dedup state holds the warn key. Asking the
1021        // dedup helper for the SAME key returns false (already seen) instead
1022        // of true (would fire).
1023        assert!(
1024            !record_inter_file_warn_seen("duplicate-exports", &files),
1025            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1026        );
1027    }
1028
1029    /// Helper to build a FallowConfig with minimal boilerplate.
1030    fn make_config(production: bool) -> FallowConfig {
1031        FallowConfig {
1032            schema: None,
1033            extends: vec![],
1034            entry: vec![],
1035            ignore_patterns: vec![],
1036            framework: vec![],
1037            workspaces: None,
1038            ignore_dependencies: vec![],
1039            ignore_unresolved_imports: vec![],
1040            ignore_exports: vec![],
1041            ignore_catalog_references: vec![],
1042            ignore_dependency_overrides: vec![],
1043            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1044            used_class_members: vec![],
1045            ignore_decorators: vec![],
1046            duplicates: DuplicatesConfig::default(),
1047            health: HealthConfig::default(),
1048            rules: RulesConfig::default(),
1049            boundaries: BoundaryConfig::default(),
1050            production: production.into(),
1051            plugins: vec![],
1052            dynamically_loaded: vec![],
1053            overrides: vec![],
1054            regression: None,
1055            audit: crate::config::AuditConfig::default(),
1056            codeowners: None,
1057            public_packages: vec![],
1058            flags: FlagsConfig::default(),
1059            fix: crate::config::FixConfig::default(),
1060            resolve: ResolveConfig::default(),
1061            sealed: false,
1062            include_entry_exports: false,
1063            auto_imports: false,
1064            cache: CacheConfig::default(),
1065        }
1066    }
1067
1068    // ── Production mode ─────────────────────────────────────────────
1069
1070    #[test]
1071    fn resolve_production_forces_dev_deps_off() {
1072        let resolved = make_config(true).resolve(
1073            PathBuf::from("/project"),
1074            OutputFormat::Human,
1075            1,
1076            true,
1077            true,
1078            None,
1079        );
1080        assert_eq!(
1081            resolved.rules.unused_dev_dependencies,
1082            Severity::Off,
1083            "production mode should force unused_dev_dependencies to off"
1084        );
1085    }
1086
1087    #[test]
1088    fn resolve_production_forces_optional_deps_off() {
1089        let resolved = make_config(true).resolve(
1090            PathBuf::from("/project"),
1091            OutputFormat::Human,
1092            1,
1093            true,
1094            true,
1095            None,
1096        );
1097        assert_eq!(
1098            resolved.rules.unused_optional_dependencies,
1099            Severity::Off,
1100            "production mode should force unused_optional_dependencies to off"
1101        );
1102    }
1103
1104    #[test]
1105    fn resolve_production_preserves_other_rules() {
1106        let resolved = make_config(true).resolve(
1107            PathBuf::from("/project"),
1108            OutputFormat::Human,
1109            1,
1110            true,
1111            true,
1112            None,
1113        );
1114        // Other rules should remain at their defaults
1115        assert_eq!(resolved.rules.unused_files, Severity::Error);
1116        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1117        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1118    }
1119
1120    #[test]
1121    fn resolve_non_production_keeps_dev_deps_default() {
1122        let resolved = make_config(false).resolve(
1123            PathBuf::from("/project"),
1124            OutputFormat::Human,
1125            1,
1126            true,
1127            true,
1128            None,
1129        );
1130        assert_eq!(
1131            resolved.rules.unused_dev_dependencies,
1132            Severity::Warn,
1133            "non-production should keep default severity"
1134        );
1135        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1136    }
1137
1138    #[test]
1139    fn resolve_production_flag_stored() {
1140        let resolved = make_config(true).resolve(
1141            PathBuf::from("/project"),
1142            OutputFormat::Human,
1143            1,
1144            true,
1145            true,
1146            None,
1147        );
1148        assert!(resolved.production);
1149
1150        let resolved2 = make_config(false).resolve(
1151            PathBuf::from("/project"),
1152            OutputFormat::Human,
1153            1,
1154            true,
1155            true,
1156            None,
1157        );
1158        assert!(!resolved2.production);
1159    }
1160
1161    // ── Default ignore patterns ─────────────────────────────────────
1162
1163    #[test]
1164    fn resolve_default_ignores_node_modules() {
1165        let resolved = make_config(false).resolve(
1166            PathBuf::from("/project"),
1167            OutputFormat::Human,
1168            1,
1169            true,
1170            true,
1171            None,
1172        );
1173        assert!(
1174            resolved
1175                .ignore_patterns
1176                .is_match("node_modules/lodash/index.js")
1177        );
1178        assert!(
1179            resolved
1180                .ignore_patterns
1181                .is_match("packages/a/node_modules/react/index.js")
1182        );
1183    }
1184
1185    #[test]
1186    fn resolve_default_ignores_dist() {
1187        let resolved = make_config(false).resolve(
1188            PathBuf::from("/project"),
1189            OutputFormat::Human,
1190            1,
1191            true,
1192            true,
1193            None,
1194        );
1195        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1196        assert!(
1197            resolved
1198                .ignore_patterns
1199                .is_match("packages/ui/dist/index.js")
1200        );
1201    }
1202
1203    #[test]
1204    fn resolve_default_ignores_root_build_only() {
1205        let resolved = make_config(false).resolve(
1206            PathBuf::from("/project"),
1207            OutputFormat::Human,
1208            1,
1209            true,
1210            true,
1211            None,
1212        );
1213        assert!(
1214            resolved.ignore_patterns.is_match("build/output.js"),
1215            "root build/ should be ignored"
1216        );
1217        // The pattern is `build/**` (root-only), not `**/build/**`
1218        assert!(
1219            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1220            "nested build/ should NOT be ignored by default"
1221        );
1222    }
1223
1224    #[test]
1225    fn resolve_default_ignores_minified_files() {
1226        let resolved = make_config(false).resolve(
1227            PathBuf::from("/project"),
1228            OutputFormat::Human,
1229            1,
1230            true,
1231            true,
1232            None,
1233        );
1234        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1235        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1236    }
1237
1238    #[test]
1239    fn resolve_default_ignores_git() {
1240        let resolved = make_config(false).resolve(
1241            PathBuf::from("/project"),
1242            OutputFormat::Human,
1243            1,
1244            true,
1245            true,
1246            None,
1247        );
1248        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1249    }
1250
1251    #[test]
1252    fn resolve_default_ignores_coverage() {
1253        let resolved = make_config(false).resolve(
1254            PathBuf::from("/project"),
1255            OutputFormat::Human,
1256            1,
1257            true,
1258            true,
1259            None,
1260        );
1261        assert!(
1262            resolved
1263                .ignore_patterns
1264                .is_match("coverage/lcov-report/index.js")
1265        );
1266    }
1267
1268    #[test]
1269    fn resolve_source_files_not_ignored_by_default() {
1270        let resolved = make_config(false).resolve(
1271            PathBuf::from("/project"),
1272            OutputFormat::Human,
1273            1,
1274            true,
1275            true,
1276            None,
1277        );
1278        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1279        assert!(
1280            !resolved
1281                .ignore_patterns
1282                .is_match("src/components/Button.tsx")
1283        );
1284        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1285    }
1286
1287    // ── Custom ignore patterns ──────────────────────────────────────
1288
1289    #[test]
1290    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1291        let mut config = make_config(false);
1292        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1293        let resolved = config.resolve(
1294            PathBuf::from("/project"),
1295            OutputFormat::Human,
1296            1,
1297            true,
1298            true,
1299            None,
1300        );
1301        // Custom pattern works
1302        assert!(
1303            resolved
1304                .ignore_patterns
1305                .is_match("src/__generated__/types.ts")
1306        );
1307        // Default patterns still work
1308        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1309    }
1310
1311    // ── Config fields passthrough ───────────────────────────────────
1312
1313    #[test]
1314    fn resolve_passes_through_entry_patterns() {
1315        let mut config = make_config(false);
1316        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1317        let resolved = config.resolve(
1318            PathBuf::from("/project"),
1319            OutputFormat::Human,
1320            1,
1321            true,
1322            true,
1323            None,
1324        );
1325        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1326    }
1327
1328    #[test]
1329    fn resolve_passes_through_ignore_dependencies() {
1330        let mut config = make_config(false);
1331        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1332        let resolved = config.resolve(
1333            PathBuf::from("/project"),
1334            OutputFormat::Human,
1335            1,
1336            true,
1337            true,
1338            None,
1339        );
1340        assert_eq!(
1341            resolved.ignore_dependencies,
1342            vec!["postcss", "autoprefixer"]
1343        );
1344    }
1345
1346    #[test]
1347    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1348        let mut config = make_config(false);
1349        config.ignore_unresolved_imports = vec![
1350            "@example/icons".to_string(),
1351            "@example/icons/**".to_string(),
1352            "../generated/**".to_string(),
1353        ];
1354        let resolved = config.resolve(
1355            PathBuf::from("/project"),
1356            OutputFormat::Human,
1357            1,
1358            true,
1359            true,
1360            None,
1361        );
1362
1363        assert!(
1364            resolved
1365                .ignore_unresolved_imports
1366                .iter()
1367                .any(|matcher| matcher.is_match("@example/icons"))
1368        );
1369        assert!(
1370            resolved
1371                .ignore_unresolved_imports
1372                .iter()
1373                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1374        );
1375        assert!(
1376            resolved
1377                .ignore_unresolved_imports
1378                .iter()
1379                .any(|matcher| matcher.is_match("../generated/client"))
1380        );
1381    }
1382
1383    #[test]
1384    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1385        let mut config = make_config(false);
1386        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1387        let resolved = config.resolve(
1388            PathBuf::from("/project"),
1389            OutputFormat::Human,
1390            1,
1391            true,
1392            true,
1393            None,
1394        );
1395
1396        assert!(
1397            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1398            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1399        );
1400        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1401    }
1402
1403    #[test]
1404    fn resolve_sets_cache_dir() {
1405        let resolved = make_config(false).resolve(
1406            PathBuf::from("/my/project"),
1407            OutputFormat::Human,
1408            1,
1409            true,
1410            true,
1411            None,
1412        );
1413        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1414    }
1415
1416    #[test]
1417    fn resolve_passes_through_thread_count() {
1418        let resolved = make_config(false).resolve(
1419            PathBuf::from("/project"),
1420            OutputFormat::Human,
1421            8,
1422            true,
1423            true,
1424            None,
1425        );
1426        assert_eq!(resolved.threads, 8);
1427    }
1428
1429    #[test]
1430    fn resolve_passes_through_quiet_flag() {
1431        let resolved = make_config(false).resolve(
1432            PathBuf::from("/project"),
1433            OutputFormat::Human,
1434            1,
1435            true,
1436            false,
1437            None,
1438        );
1439        assert!(!resolved.quiet);
1440
1441        let resolved2 = make_config(false).resolve(
1442            PathBuf::from("/project"),
1443            OutputFormat::Human,
1444            1,
1445            true,
1446            true,
1447            None,
1448        );
1449        assert!(resolved2.quiet);
1450    }
1451
1452    #[test]
1453    fn resolve_passes_through_no_cache_flag() {
1454        let resolved_no_cache = make_config(false).resolve(
1455            PathBuf::from("/project"),
1456            OutputFormat::Human,
1457            1,
1458            true,
1459            true,
1460            None,
1461        );
1462        assert!(resolved_no_cache.no_cache);
1463
1464        let resolved_with_cache = make_config(false).resolve(
1465            PathBuf::from("/project"),
1466            OutputFormat::Human,
1467            1,
1468            false,
1469            true,
1470            None,
1471        );
1472        assert!(!resolved_with_cache.no_cache);
1473    }
1474
1475    // ── Override resolution edge cases ───────────────────────────────
1476
1477    #[test]
1478    #[should_panic(expected = "validated at config load time")]
1479    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1480        // Per issue #463, overrides[].files are validated by
1481        // FallowConfig::load before reaching resolve(). A program that
1482        // constructs a config in-code with an invalid pattern has skipped
1483        // that validation; resolve() asserts the invariant by panicking.
1484        let mut config = make_config(false);
1485        config.overrides = vec![ConfigOverride {
1486            files: vec!["[invalid".to_string()],
1487            rules: PartialRulesConfig {
1488                unused_files: Some(Severity::Off),
1489                ..Default::default()
1490            },
1491        }];
1492        let _ = config.resolve(
1493            PathBuf::from("/project"),
1494            OutputFormat::Human,
1495            1,
1496            true,
1497            true,
1498            None,
1499        );
1500    }
1501
1502    #[test]
1503    fn resolve_override_with_empty_files_skipped() {
1504        let mut config = make_config(false);
1505        config.overrides = vec![ConfigOverride {
1506            files: vec![],
1507            rules: PartialRulesConfig {
1508                unused_files: Some(Severity::Off),
1509                ..Default::default()
1510            },
1511        }];
1512        let resolved = config.resolve(
1513            PathBuf::from("/project"),
1514            OutputFormat::Human,
1515            1,
1516            true,
1517            true,
1518            None,
1519        );
1520        assert!(
1521            resolved.overrides.is_empty(),
1522            "override with no file patterns should be skipped"
1523        );
1524    }
1525
1526    #[test]
1527    fn resolve_multiple_valid_overrides() {
1528        let mut config = make_config(false);
1529        config.overrides = vec![
1530            ConfigOverride {
1531                files: vec!["*.test.ts".to_string()],
1532                rules: PartialRulesConfig {
1533                    unused_exports: Some(Severity::Off),
1534                    ..Default::default()
1535                },
1536            },
1537            ConfigOverride {
1538                files: vec!["*.stories.tsx".to_string()],
1539                rules: PartialRulesConfig {
1540                    unused_files: Some(Severity::Off),
1541                    ..Default::default()
1542                },
1543            },
1544        ];
1545        let resolved = config.resolve(
1546            PathBuf::from("/project"),
1547            OutputFormat::Human,
1548            1,
1549            true,
1550            true,
1551            None,
1552        );
1553        assert_eq!(resolved.overrides.len(), 2);
1554    }
1555
1556    // ── IgnoreExportRule ────────────────────────────────────────────
1557
1558    #[test]
1559    fn ignore_export_rule_deserialize() {
1560        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1561        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1562        assert_eq!(rule.file, "src/types/*.ts");
1563        assert_eq!(rule.exports, vec!["*"]);
1564    }
1565
1566    #[test]
1567    fn ignore_export_rule_specific_exports() {
1568        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1569        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1570        assert_eq!(rule.exports.len(), 3);
1571        assert!(rule.exports.contains(&"FOO".to_string()));
1572    }
1573
1574    mod proptests {
1575        use super::*;
1576        use proptest::prelude::*;
1577
1578        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1579            make_config(production).resolve(
1580                PathBuf::from("/project"),
1581                OutputFormat::Human,
1582                1,
1583                true,
1584                true,
1585                None,
1586            )
1587        }
1588
1589        proptest! {
1590            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1591            #[test]
1592            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1593                let resolved = arb_resolved_config(production);
1594                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
1595                prop_assert!(
1596                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1597                    "Default ignore should match node_modules"
1598                );
1599                prop_assert!(
1600                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1601                    "Default ignore should match dist"
1602                );
1603            }
1604
1605            /// Production mode always forces dev and optional deps to Off.
1606            #[test]
1607            fn production_forces_dev_deps_off(_unused in Just(())) {
1608                let resolved = arb_resolved_config(true);
1609                prop_assert_eq!(
1610                    resolved.rules.unused_dev_dependencies,
1611                    Severity::Off,
1612                    "Production should force unused_dev_dependencies off"
1613                );
1614                prop_assert_eq!(
1615                    resolved.rules.unused_optional_dependencies,
1616                    Severity::Off,
1617                    "Production should force unused_optional_dependencies off"
1618                );
1619            }
1620
1621            /// Non-production mode preserves default severity for dev deps.
1622            #[test]
1623            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1624                let resolved = arb_resolved_config(false);
1625                prop_assert_eq!(
1626                    resolved.rules.unused_dev_dependencies,
1627                    Severity::Warn,
1628                    "Non-production should keep default dev dep severity"
1629                );
1630            }
1631
1632            /// Cache dir is always root/.fallow.
1633            #[test]
1634            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1635                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1636                let expected_cache = root.join(".fallow");
1637                let resolved = make_config(false).resolve(
1638                    root,
1639                    OutputFormat::Human,
1640                    1,
1641                    true,
1642                    true,
1643                    None,
1644                );
1645                prop_assert_eq!(
1646                    resolved.cache_dir, expected_cache,
1647                    "Cache dir should be root/.fallow"
1648                );
1649            }
1650
1651            /// Thread count is always passed through exactly.
1652            #[test]
1653            fn threads_passed_through(threads in 1..64usize) {
1654                let resolved = make_config(false).resolve(
1655                    PathBuf::from("/project"),
1656                    OutputFormat::Human,
1657                    threads,
1658                    true,
1659                    true, None,
1660                );
1661                prop_assert_eq!(
1662                    resolved.threads, threads,
1663                    "Thread count should be passed through"
1664                );
1665            }
1666
1667            /// Custom ignore patterns are merged with defaults, not replacing them.
1668            /// Uses a pattern regex that cannot match node_modules paths, so the
1669            /// assertion proves the default pattern is what provides the match.
1670            #[test]
1671            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1672                let mut config = make_config(false);
1673                config.ignore_patterns = vec![pattern];
1674                let resolved = config.resolve(
1675                    PathBuf::from("/project"),
1676                    OutputFormat::Human,
1677                    1,
1678                    true,
1679                    true, None,
1680                );
1681                // Defaults should still be present (the custom pattern cannot
1682                // match this path, so only the default **/node_modules/** can)
1683                prop_assert!(
1684                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1685                    "Default node_modules ignore should still be active"
1686                );
1687            }
1688        }
1689    }
1690
1691    // ── Boundary preset expansion ──────────────────────────────────
1692
1693    #[test]
1694    fn resolve_expands_boundary_preset() {
1695        use crate::config::boundaries::BoundaryPreset;
1696
1697        let mut config = make_config(false);
1698        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1699        let resolved = config.resolve(
1700            PathBuf::from("/project"),
1701            OutputFormat::Human,
1702            1,
1703            true,
1704            true,
1705            None,
1706        );
1707        // Preset should have been expanded into zones (no tsconfig → fallback to "src")
1708        assert_eq!(resolved.boundaries.zones.len(), 3);
1709        assert_eq!(resolved.boundaries.rules.len(), 3);
1710        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1711        assert_eq!(
1712            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1713            Some("adapters")
1714        );
1715    }
1716
1717    #[test]
1718    fn resolve_boundary_preset_with_user_override() {
1719        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1720
1721        let mut config = make_config(false);
1722        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1723        config.boundaries.zones = vec![BoundaryZone {
1724            name: "domain".to_string(),
1725            patterns: vec!["src/core/**".to_string()],
1726            auto_discover: vec![],
1727            root: None,
1728        }];
1729        let resolved = config.resolve(
1730            PathBuf::from("/project"),
1731            OutputFormat::Human,
1732            1,
1733            true,
1734            true,
1735            None,
1736        );
1737        // User zone "domain" replaced preset zone "domain"
1738        assert_eq!(resolved.boundaries.zones.len(), 3);
1739        // The user's pattern should be used for domain zone
1740        assert_eq!(
1741            resolved.boundaries.classify_zone("src/core/user.ts"),
1742            Some("domain")
1743        );
1744        // Original preset pattern should NOT match
1745        assert_eq!(
1746            resolved.boundaries.classify_zone("src/domain/user.ts"),
1747            None
1748        );
1749    }
1750
1751    #[test]
1752    fn resolve_no_preset_unchanged() {
1753        let config = make_config(false);
1754        let resolved = config.resolve(
1755            PathBuf::from("/project"),
1756            OutputFormat::Human,
1757            1,
1758            true,
1759            true,
1760            None,
1761        );
1762        assert!(resolved.boundaries.is_empty());
1763    }
1764}