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