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