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