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            minimum_version: None,
860            extends: vec![],
861            entry: vec![],
862            ignore_patterns: vec![],
863            ignore_findings: vec![],
864            framework: vec![],
865            workspaces: None,
866            ignore_dependencies: vec![],
867            ignore_unresolved_imports: vec![],
868            ignore_exports: vec![],
869            ignore_catalog_references: vec![],
870            ignore_dependency_overrides: vec![],
871            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
872            used_class_members: vec![],
873            ignore_decorators: vec![],
874            unused_component_props: crate::UnusedComponentPropsConfig::default(),
875            duplicates: DuplicatesConfig::default(),
876            similar_code: SimilarCodeConfig::default(),
877            health: HealthConfig::default(),
878            rules: RulesConfig::default(),
879            boundaries: BoundaryConfig::default(),
880            production: false.into(),
881            plugins: vec![],
882            rule_packs: vec![],
883            dynamically_loaded: vec![],
884            overrides: vec![],
885            regression: None,
886            type_aware: crate::TypeAwareConfig::default(),
887            audit: crate::config::AuditConfig::default(),
888            codeowners: None,
889            public_packages: vec![],
890            flags: FlagsConfig::default(),
891            security: SecurityConfig::default(),
892            fix: crate::config::FixConfig::default(),
893            resolve: ResolveConfig::default(),
894            sealed: false,
895            include_entry_exports: false,
896            auto_imports: false,
897            cache: CacheConfig::default(),
898        };
899        let resolved = config.resolve(
900            PathBuf::from("/project"),
901            OutputFormat::Human,
902            1,
903            true,
904            true,
905            None,
906        );
907        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
908        assert_eq!(rules.unused_files, Severity::Error);
909    }
910
911    #[test]
912    fn resolve_rules_for_path_with_matching_override() {
913        let config = FallowConfig {
914            schema: None,
915            minimum_version: None,
916            extends: vec![],
917            entry: vec![],
918            ignore_patterns: vec![],
919            ignore_findings: vec![],
920            framework: vec![],
921            workspaces: None,
922            ignore_dependencies: vec![],
923            ignore_unresolved_imports: vec![],
924            ignore_exports: vec![],
925            ignore_catalog_references: vec![],
926            ignore_dependency_overrides: vec![],
927            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
928            used_class_members: vec![],
929            ignore_decorators: vec![],
930            unused_component_props: crate::UnusedComponentPropsConfig::default(),
931            duplicates: DuplicatesConfig::default(),
932            similar_code: SimilarCodeConfig::default(),
933            health: HealthConfig::default(),
934            rules: RulesConfig::default(),
935            boundaries: BoundaryConfig::default(),
936            production: false.into(),
937            plugins: vec![],
938            rule_packs: vec![],
939            dynamically_loaded: vec![],
940            overrides: vec![ConfigOverride {
941                files: vec!["*.test.ts".to_string()],
942                rules: PartialRulesConfig {
943                    unused_exports: Some(Severity::Off),
944                    ..Default::default()
945                },
946            }],
947            regression: None,
948            type_aware: crate::TypeAwareConfig::default(),
949            audit: crate::config::AuditConfig::default(),
950            codeowners: None,
951            public_packages: vec![],
952            flags: FlagsConfig::default(),
953            security: SecurityConfig::default(),
954            fix: crate::config::FixConfig::default(),
955            resolve: ResolveConfig::default(),
956            sealed: false,
957            include_entry_exports: false,
958            auto_imports: false,
959            cache: CacheConfig::default(),
960        };
961        let resolved = config.resolve(
962            PathBuf::from("/project"),
963            OutputFormat::Human,
964            1,
965            true,
966            true,
967            None,
968        );
969
970        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
971        assert_eq!(test_rules.unused_exports, Severity::Off);
972        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
973
974        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
975        assert_eq!(src_rules.unused_exports, Severity::Error);
976    }
977
978    #[test]
979    fn resolve_rules_for_path_later_override_wins() {
980        let config = FallowConfig {
981            schema: None,
982            minimum_version: None,
983            extends: vec![],
984            entry: vec![],
985            ignore_patterns: vec![],
986            ignore_findings: vec![],
987            framework: vec![],
988            workspaces: None,
989            ignore_dependencies: vec![],
990            ignore_unresolved_imports: vec![],
991            ignore_exports: vec![],
992            ignore_catalog_references: vec![],
993            ignore_dependency_overrides: vec![],
994            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
995            used_class_members: vec![],
996            ignore_decorators: vec![],
997            unused_component_props: crate::UnusedComponentPropsConfig::default(),
998            duplicates: DuplicatesConfig::default(),
999            similar_code: SimilarCodeConfig::default(),
1000            health: HealthConfig::default(),
1001            rules: RulesConfig::default(),
1002            boundaries: BoundaryConfig::default(),
1003            production: false.into(),
1004            plugins: vec![],
1005            rule_packs: vec![],
1006            dynamically_loaded: vec![],
1007            overrides: vec![
1008                ConfigOverride {
1009                    files: vec!["*.ts".to_string()],
1010                    rules: PartialRulesConfig {
1011                        unused_files: Some(Severity::Warn),
1012                        ..Default::default()
1013                    },
1014                },
1015                ConfigOverride {
1016                    files: vec!["*.test.ts".to_string()],
1017                    rules: PartialRulesConfig {
1018                        unused_files: Some(Severity::Off),
1019                        ..Default::default()
1020                    },
1021                },
1022            ],
1023            regression: None,
1024            type_aware: crate::TypeAwareConfig::default(),
1025            audit: crate::config::AuditConfig::default(),
1026            codeowners: None,
1027            public_packages: vec![],
1028            flags: FlagsConfig::default(),
1029            security: SecurityConfig::default(),
1030            fix: crate::config::FixConfig::default(),
1031            resolve: ResolveConfig::default(),
1032            sealed: false,
1033            include_entry_exports: false,
1034            auto_imports: false,
1035            cache: CacheConfig::default(),
1036        };
1037        let resolved = config.resolve(
1038            PathBuf::from("/project"),
1039            OutputFormat::Human,
1040            1,
1041            true,
1042            true,
1043            None,
1044        );
1045
1046        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1047        assert_eq!(rules.unused_files, Severity::Off);
1048
1049        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1050        assert_eq!(rules2.unused_files, Severity::Warn);
1051    }
1052
1053    #[test]
1054    fn resolve_keeps_inter_file_rule_override_after_warning() {
1055        let config = FallowConfig {
1056            schema: None,
1057            minimum_version: None,
1058            extends: vec![],
1059            entry: vec![],
1060            ignore_patterns: vec![],
1061            ignore_findings: vec![],
1062            framework: vec![],
1063            workspaces: None,
1064            ignore_dependencies: vec![],
1065            ignore_unresolved_imports: vec![],
1066            ignore_exports: vec![],
1067            ignore_catalog_references: vec![],
1068            ignore_dependency_overrides: vec![],
1069            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1070            used_class_members: vec![],
1071            ignore_decorators: vec![],
1072            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1073            duplicates: DuplicatesConfig::default(),
1074            similar_code: SimilarCodeConfig::default(),
1075            health: HealthConfig::default(),
1076            rules: RulesConfig::default(),
1077            boundaries: BoundaryConfig::default(),
1078            production: false.into(),
1079            plugins: vec![],
1080            rule_packs: vec![],
1081            dynamically_loaded: vec![],
1082            overrides: vec![ConfigOverride {
1083                files: vec!["**/ui/**".to_string()],
1084                rules: PartialRulesConfig {
1085                    duplicate_exports: Some(Severity::Off),
1086                    unused_files: Some(Severity::Warn),
1087                    ..Default::default()
1088                },
1089            }],
1090            regression: None,
1091            type_aware: crate::TypeAwareConfig::default(),
1092            audit: crate::config::AuditConfig::default(),
1093            codeowners: None,
1094            public_packages: vec![],
1095            flags: FlagsConfig::default(),
1096            security: SecurityConfig::default(),
1097            fix: crate::config::FixConfig::default(),
1098            resolve: ResolveConfig::default(),
1099            sealed: false,
1100            include_entry_exports: false,
1101            auto_imports: false,
1102            cache: CacheConfig::default(),
1103        };
1104        let resolved = config.resolve(
1105            PathBuf::from("/project"),
1106            OutputFormat::Human,
1107            1,
1108            true,
1109            true,
1110            None,
1111        );
1112        assert_eq!(
1113            resolved.overrides.len(),
1114            1,
1115            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1116        );
1117        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1118        assert_eq!(rules.unused_files, Severity::Warn);
1119    }
1120
1121    #[test]
1122    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1123        reset_inter_file_warn_dedup_for_test();
1124        let files_a = vec!["__test_dedup_a/*".to_string()];
1125        let files_b = vec!["__test_dedup_b/*".to_string()];
1126
1127        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1128        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1129        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1130
1131        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1132        assert!(!record_inter_file_warn_seen(
1133            "circular-dependency",
1134            &files_a
1135        ));
1136
1137        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1138
1139        let files_reordered = vec![
1140            "__test_dedup_b/*".to_string(),
1141            "__test_dedup_a/*".to_string(),
1142        ];
1143        let files_natural = vec![
1144            "__test_dedup_a/*".to_string(),
1145            "__test_dedup_b/*".to_string(),
1146        ];
1147        reset_inter_file_warn_dedup_for_test();
1148        assert!(record_inter_file_warn_seen(
1149            "duplicate-exports",
1150            &files_natural
1151        ));
1152        assert!(!record_inter_file_warn_seen(
1153            "duplicate-exports",
1154            &files_reordered
1155        ));
1156    }
1157
1158    #[test]
1159    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1160        reset_inter_file_warn_dedup_for_test();
1161        let files = vec!["__test_resolve_dedup/**".to_string()];
1162        let build_config = || FallowConfig {
1163            schema: None,
1164            minimum_version: None,
1165            extends: vec![],
1166            entry: vec![],
1167            ignore_patterns: vec![],
1168            ignore_findings: vec![],
1169            framework: vec![],
1170            workspaces: None,
1171            ignore_dependencies: vec![],
1172            ignore_unresolved_imports: vec![],
1173            ignore_exports: vec![],
1174            ignore_catalog_references: vec![],
1175            ignore_dependency_overrides: vec![],
1176            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1177            used_class_members: vec![],
1178            ignore_decorators: vec![],
1179            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1180            duplicates: DuplicatesConfig::default(),
1181            similar_code: SimilarCodeConfig::default(),
1182            health: HealthConfig::default(),
1183            rules: RulesConfig::default(),
1184            boundaries: BoundaryConfig::default(),
1185            production: false.into(),
1186            plugins: vec![],
1187            rule_packs: vec![],
1188            dynamically_loaded: vec![],
1189            overrides: vec![ConfigOverride {
1190                files: files.clone(),
1191                rules: PartialRulesConfig {
1192                    duplicate_exports: Some(Severity::Off),
1193                    ..Default::default()
1194                },
1195            }],
1196            regression: None,
1197            type_aware: crate::TypeAwareConfig::default(),
1198            audit: crate::config::AuditConfig::default(),
1199            codeowners: None,
1200            public_packages: vec![],
1201            flags: FlagsConfig::default(),
1202            security: SecurityConfig::default(),
1203            fix: crate::config::FixConfig::default(),
1204            resolve: ResolveConfig::default(),
1205            sealed: false,
1206            include_entry_exports: false,
1207            auto_imports: false,
1208            cache: CacheConfig::default(),
1209        };
1210        for _ in 0..10 {
1211            let _ = build_config().resolve(
1212                PathBuf::from("/project"),
1213                OutputFormat::Human,
1214                1,
1215                true,
1216                true,
1217                None,
1218            );
1219        }
1220        assert!(
1221            !record_inter_file_warn_seen("duplicate-exports", &files),
1222            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1223        );
1224    }
1225
1226    /// Helper to build a FallowConfig with minimal boilerplate.
1227    fn make_config(production: bool) -> FallowConfig {
1228        FallowConfig {
1229            schema: None,
1230            minimum_version: None,
1231            extends: vec![],
1232            entry: vec![],
1233            ignore_patterns: vec![],
1234            ignore_findings: vec![],
1235            framework: vec![],
1236            workspaces: None,
1237            ignore_dependencies: vec![],
1238            ignore_unresolved_imports: vec![],
1239            ignore_exports: vec![],
1240            ignore_catalog_references: vec![],
1241            ignore_dependency_overrides: vec![],
1242            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1243            used_class_members: vec![],
1244            ignore_decorators: vec![],
1245            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1246            duplicates: DuplicatesConfig::default(),
1247            similar_code: SimilarCodeConfig::default(),
1248            health: HealthConfig::default(),
1249            rules: RulesConfig::default(),
1250            boundaries: BoundaryConfig::default(),
1251            production: production.into(),
1252            plugins: vec![],
1253            rule_packs: vec![],
1254            dynamically_loaded: vec![],
1255            overrides: vec![],
1256            regression: None,
1257            type_aware: crate::TypeAwareConfig::default(),
1258            audit: crate::config::AuditConfig::default(),
1259            codeowners: None,
1260            public_packages: vec![],
1261            flags: FlagsConfig::default(),
1262            security: SecurityConfig::default(),
1263            fix: crate::config::FixConfig::default(),
1264            resolve: ResolveConfig::default(),
1265            sealed: false,
1266            include_entry_exports: false,
1267            auto_imports: false,
1268            cache: CacheConfig::default(),
1269        }
1270    }
1271
1272    #[test]
1273    fn resolve_tracks_rule_pack_sources_in_config_order() {
1274        let dir = tempfile::tempdir().unwrap();
1275        std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1276        std::fs::write(
1277            dir.path().join("rule-packs/team-policy.jsonc"),
1278            r#"{
1279  "version": 1,
1280  "name": "team-policy",
1281  "rules": [
1282    {
1283      "id": "no-moment",
1284      "kind": "banned-import",
1285      "specifiers": ["moment"]
1286    }
1287  ]
1288}
1289"#,
1290        )
1291        .unwrap();
1292
1293        let mut config = make_config(false);
1294        config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1295
1296        let resolved = config.resolve(
1297            dir.path().to_path_buf(),
1298            OutputFormat::Human,
1299            1,
1300            true,
1301            true,
1302            None,
1303        );
1304
1305        assert_eq!(resolved.rule_packs.len(), 1);
1306        assert_eq!(resolved.rule_packs[0].name, "team-policy");
1307        assert_eq!(
1308            resolved.rule_pack_sources,
1309            vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1310        );
1311    }
1312
1313    #[test]
1314    fn resolve_production_forces_dev_deps_off() {
1315        let resolved = make_config(true).resolve(
1316            PathBuf::from("/project"),
1317            OutputFormat::Human,
1318            1,
1319            true,
1320            true,
1321            None,
1322        );
1323        assert_eq!(
1324            resolved.rules.unused_dev_dependencies,
1325            Severity::Off,
1326            "production mode should force unused_dev_dependencies to off"
1327        );
1328    }
1329
1330    #[test]
1331    fn resolve_production_forces_optional_deps_off() {
1332        let resolved = make_config(true).resolve(
1333            PathBuf::from("/project"),
1334            OutputFormat::Human,
1335            1,
1336            true,
1337            true,
1338            None,
1339        );
1340        assert_eq!(
1341            resolved.rules.unused_optional_dependencies,
1342            Severity::Off,
1343            "production mode should force unused_optional_dependencies to off"
1344        );
1345    }
1346
1347    #[test]
1348    fn resolve_production_preserves_other_rules() {
1349        let resolved = make_config(true).resolve(
1350            PathBuf::from("/project"),
1351            OutputFormat::Human,
1352            1,
1353            true,
1354            true,
1355            None,
1356        );
1357        assert_eq!(resolved.rules.unused_files, Severity::Error);
1358        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1359        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1360    }
1361
1362    #[test]
1363    fn resolve_non_production_keeps_dev_deps_default() {
1364        let resolved = make_config(false).resolve(
1365            PathBuf::from("/project"),
1366            OutputFormat::Human,
1367            1,
1368            true,
1369            true,
1370            None,
1371        );
1372        assert_eq!(
1373            resolved.rules.unused_dev_dependencies,
1374            Severity::Warn,
1375            "non-production should keep default severity"
1376        );
1377        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1378    }
1379
1380    #[test]
1381    fn resolve_production_flag_stored() {
1382        let resolved = make_config(true).resolve(
1383            PathBuf::from("/project"),
1384            OutputFormat::Human,
1385            1,
1386            true,
1387            true,
1388            None,
1389        );
1390        assert!(resolved.production);
1391
1392        let resolved2 = make_config(false).resolve(
1393            PathBuf::from("/project"),
1394            OutputFormat::Human,
1395            1,
1396            true,
1397            true,
1398            None,
1399        );
1400        assert!(!resolved2.production);
1401    }
1402
1403    #[test]
1404    fn resolve_default_ignores_node_modules() {
1405        let resolved = make_config(false).resolve(
1406            PathBuf::from("/project"),
1407            OutputFormat::Human,
1408            1,
1409            true,
1410            true,
1411            None,
1412        );
1413        assert!(
1414            resolved
1415                .ignore_patterns
1416                .is_match("node_modules/lodash/index.js")
1417        );
1418        assert!(
1419            resolved
1420                .ignore_patterns
1421                .is_match("packages/a/node_modules/react/index.js")
1422        );
1423    }
1424
1425    #[test]
1426    fn resolve_default_ignores_dist() {
1427        let resolved = make_config(false).resolve(
1428            PathBuf::from("/project"),
1429            OutputFormat::Human,
1430            1,
1431            true,
1432            true,
1433            None,
1434        );
1435        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1436        assert!(
1437            resolved
1438                .ignore_patterns
1439                .is_match("packages/ui/dist/index.js")
1440        );
1441    }
1442
1443    #[test]
1444    fn resolve_default_ignores_root_build_only() {
1445        let resolved = make_config(false).resolve(
1446            PathBuf::from("/project"),
1447            OutputFormat::Human,
1448            1,
1449            true,
1450            true,
1451            None,
1452        );
1453        assert!(
1454            resolved.ignore_patterns.is_match("build/output.js"),
1455            "root build/ should be ignored"
1456        );
1457        assert!(
1458            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1459            "nested build/ should NOT be ignored by default"
1460        );
1461    }
1462
1463    #[test]
1464    fn resolve_default_ignores_minified_files() {
1465        let resolved = make_config(false).resolve(
1466            PathBuf::from("/project"),
1467            OutputFormat::Human,
1468            1,
1469            true,
1470            true,
1471            None,
1472        );
1473        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1474        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1475        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1476        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1477        assert!(
1478            resolved
1479                .ignore_patterns
1480                .is_match("src/vendor/app.bundle.js")
1481        );
1482        // Hand-written source with a similar name stays analyzed.
1483        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1484        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1485    }
1486
1487    #[test]
1488    fn resolve_max_file_size_bytes_default_and_unlimited() {
1489        // Unset keeps the built-in default.
1490        assert_eq!(
1491            resolve_max_file_size_bytes(None),
1492            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1493        );
1494        // `0` means no limit.
1495        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1496        // Any other value is that many megabytes in bytes.
1497        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1498        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1499    }
1500
1501    #[test]
1502    fn resolve_sets_default_max_file_size() {
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_eq!(
1512            resolved.max_file_size_bytes,
1513            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1514        );
1515    }
1516
1517    #[test]
1518    fn resolve_default_ignores_git() {
1519        let resolved = make_config(false).resolve(
1520            PathBuf::from("/project"),
1521            OutputFormat::Human,
1522            1,
1523            true,
1524            true,
1525            None,
1526        );
1527        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1528    }
1529
1530    #[test]
1531    fn resolve_default_ignores_coverage() {
1532        let resolved = make_config(false).resolve(
1533            PathBuf::from("/project"),
1534            OutputFormat::Human,
1535            1,
1536            true,
1537            true,
1538            None,
1539        );
1540        assert!(
1541            resolved
1542                .ignore_patterns
1543                .is_match("coverage/lcov-report/index.js")
1544        );
1545    }
1546
1547    #[test]
1548    fn resolve_source_files_not_ignored_by_default() {
1549        let resolved = make_config(false).resolve(
1550            PathBuf::from("/project"),
1551            OutputFormat::Human,
1552            1,
1553            true,
1554            true,
1555            None,
1556        );
1557        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1558        assert!(
1559            !resolved
1560                .ignore_patterns
1561                .is_match("src/components/Button.tsx")
1562        );
1563        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1564    }
1565
1566    #[test]
1567    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1568        let mut config = make_config(false);
1569        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1570        let resolved = config.resolve(
1571            PathBuf::from("/project"),
1572            OutputFormat::Human,
1573            1,
1574            true,
1575            true,
1576            None,
1577        );
1578        assert!(
1579            resolved
1580                .ignore_patterns
1581                .is_match("src/__generated__/types.ts")
1582        );
1583        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1584    }
1585
1586    #[test]
1587    fn resolve_normalizes_leading_dot_ignore_patterns() {
1588        let mut config = make_config(false);
1589        config.ignore_patterns = vec!["./src/generated/**".to_string()];
1590        let resolved = config.resolve(
1591            PathBuf::from("/project"),
1592            OutputFormat::Human,
1593            1,
1594            true,
1595            true,
1596            None,
1597        );
1598
1599        assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1600        assert!(
1601            !resolved
1602                .ignore_patterns
1603                .is_match("./src/generated/client.ts")
1604        );
1605    }
1606
1607    #[test]
1608    fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1609        let mut config = make_config(false);
1610        config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1611        let resolved = config.resolve(
1612            PathBuf::from("/project"),
1613            OutputFormat::Human,
1614            1,
1615            true,
1616            true,
1617            None,
1618        );
1619
1620        assert!(
1621            resolved
1622                .ignore_unresolved_imports
1623                .iter()
1624                .any(|matcher| matcher.is_match("src/generated/client"))
1625        );
1626        assert!(
1627            !resolved
1628                .ignore_unresolved_imports
1629                .iter()
1630                .any(|matcher| matcher.is_match("./src/generated/client"))
1631        );
1632    }
1633
1634    #[test]
1635    fn resolve_passes_through_entry_patterns() {
1636        let mut config = make_config(false);
1637        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1638        let resolved = config.resolve(
1639            PathBuf::from("/project"),
1640            OutputFormat::Human,
1641            1,
1642            true,
1643            true,
1644            None,
1645        );
1646        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1647    }
1648
1649    #[test]
1650    fn resolve_passes_through_ignore_dependencies() {
1651        let mut config = make_config(false);
1652        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1653        let resolved = config.resolve(
1654            PathBuf::from("/project"),
1655            OutputFormat::Human,
1656            1,
1657            true,
1658            true,
1659            None,
1660        );
1661        assert_eq!(
1662            resolved.ignore_dependencies,
1663            vec!["postcss", "autoprefixer"]
1664        );
1665    }
1666
1667    #[test]
1668    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1669        let mut config = make_config(false);
1670        config.ignore_unresolved_imports = vec![
1671            "@example/icons".to_string(),
1672            "@example/icons/**".to_string(),
1673            "../generated/**".to_string(),
1674        ];
1675        let resolved = config.resolve(
1676            PathBuf::from("/project"),
1677            OutputFormat::Human,
1678            1,
1679            true,
1680            true,
1681            None,
1682        );
1683
1684        assert!(
1685            resolved
1686                .ignore_unresolved_imports
1687                .iter()
1688                .any(|matcher| matcher.is_match("@example/icons"))
1689        );
1690        assert!(
1691            resolved
1692                .ignore_unresolved_imports
1693                .iter()
1694                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1695        );
1696        assert!(
1697            resolved
1698                .ignore_unresolved_imports
1699                .iter()
1700                .any(|matcher| matcher.is_match("../generated/client"))
1701        );
1702    }
1703
1704    #[test]
1705    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1706        let mut config = make_config(false);
1707        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1708        let resolved = config.resolve(
1709            PathBuf::from("/project"),
1710            OutputFormat::Human,
1711            1,
1712            true,
1713            true,
1714            None,
1715        );
1716
1717        assert!(
1718            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1719            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1720        );
1721        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1722    }
1723
1724    #[test]
1725    fn resolve_sets_cache_dir() {
1726        let resolved = make_config(false).resolve(
1727            PathBuf::from("/my/project"),
1728            OutputFormat::Human,
1729            1,
1730            true,
1731            true,
1732            None,
1733        );
1734        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1735    }
1736
1737    #[test]
1738    fn resolve_uses_relative_configured_cache_dir_from_root() {
1739        let config = FallowConfig {
1740            cache: crate::CacheConfig {
1741                dir: Some(PathBuf::from(".cache/fallow")),
1742                ..Default::default()
1743            },
1744            ..make_config(false)
1745        };
1746        let resolved = config.resolve(
1747            PathBuf::from("/my/project"),
1748            OutputFormat::Human,
1749            1,
1750            false,
1751            true,
1752            None,
1753        );
1754        assert_eq!(
1755            resolved.cache_dir,
1756            PathBuf::from("/my/project/.cache/fallow")
1757        );
1758    }
1759
1760    #[test]
1761    fn resolve_keeps_absolute_configured_cache_dir() {
1762        let config = FallowConfig {
1763            cache: crate::CacheConfig {
1764                dir: Some(PathBuf::from("/tmp/fallow-cache")),
1765                ..Default::default()
1766            },
1767            ..make_config(false)
1768        };
1769        let resolved = config.resolve(
1770            PathBuf::from("/my/project"),
1771            OutputFormat::Human,
1772            1,
1773            false,
1774            true,
1775            None,
1776        );
1777        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
1778    }
1779
1780    #[test]
1781    fn resolve_passes_through_thread_count() {
1782        let resolved = make_config(false).resolve(
1783            PathBuf::from("/project"),
1784            OutputFormat::Human,
1785            8,
1786            true,
1787            true,
1788            None,
1789        );
1790        assert_eq!(resolved.threads, 8);
1791    }
1792
1793    #[test]
1794    fn resolve_passes_through_quiet_flag() {
1795        let resolved = make_config(false).resolve(
1796            PathBuf::from("/project"),
1797            OutputFormat::Human,
1798            1,
1799            true,
1800            false,
1801            None,
1802        );
1803        assert!(!resolved.quiet);
1804
1805        let resolved2 = make_config(false).resolve(
1806            PathBuf::from("/project"),
1807            OutputFormat::Human,
1808            1,
1809            true,
1810            true,
1811            None,
1812        );
1813        assert!(resolved2.quiet);
1814    }
1815
1816    #[test]
1817    fn resolve_passes_through_no_cache_flag() {
1818        let resolved_no_cache = make_config(false).resolve(
1819            PathBuf::from("/project"),
1820            OutputFormat::Human,
1821            1,
1822            true,
1823            true,
1824            None,
1825        );
1826        assert!(resolved_no_cache.no_cache);
1827
1828        let resolved_with_cache = make_config(false).resolve(
1829            PathBuf::from("/project"),
1830            OutputFormat::Human,
1831            1,
1832            false,
1833            true,
1834            None,
1835        );
1836        assert!(!resolved_with_cache.no_cache);
1837    }
1838
1839    #[test]
1840    #[should_panic(expected = "validated at config load time")]
1841    fn resolve_panics_on_unvalidated_invalid_override_glob() {
1842        let mut config = make_config(false);
1843        config.overrides = vec![ConfigOverride {
1844            files: vec!["[invalid".to_string()],
1845            rules: PartialRulesConfig {
1846                unused_files: Some(Severity::Off),
1847                ..Default::default()
1848            },
1849        }];
1850        let _ = config.resolve(
1851            PathBuf::from("/project"),
1852            OutputFormat::Human,
1853            1,
1854            true,
1855            true,
1856            None,
1857        );
1858    }
1859
1860    #[test]
1861    fn resolve_override_with_empty_files_skipped() {
1862        let mut config = make_config(false);
1863        config.overrides = vec![ConfigOverride {
1864            files: vec![],
1865            rules: PartialRulesConfig {
1866                unused_files: Some(Severity::Off),
1867                ..Default::default()
1868            },
1869        }];
1870        let resolved = config.resolve(
1871            PathBuf::from("/project"),
1872            OutputFormat::Human,
1873            1,
1874            true,
1875            true,
1876            None,
1877        );
1878        assert!(
1879            resolved.overrides.is_empty(),
1880            "override with no file patterns should be skipped"
1881        );
1882    }
1883
1884    #[test]
1885    fn resolve_multiple_valid_overrides() {
1886        let mut config = make_config(false);
1887        config.overrides = vec![
1888            ConfigOverride {
1889                files: vec!["*.test.ts".to_string()],
1890                rules: PartialRulesConfig {
1891                    unused_exports: Some(Severity::Off),
1892                    ..Default::default()
1893                },
1894            },
1895            ConfigOverride {
1896                files: vec!["*.stories.tsx".to_string()],
1897                rules: PartialRulesConfig {
1898                    unused_files: Some(Severity::Off),
1899                    ..Default::default()
1900                },
1901            },
1902        ];
1903        let resolved = config.resolve(
1904            PathBuf::from("/project"),
1905            OutputFormat::Human,
1906            1,
1907            true,
1908            true,
1909            None,
1910        );
1911        assert_eq!(resolved.overrides.len(), 2);
1912    }
1913
1914    #[test]
1915    fn ignore_export_rule_deserialize() {
1916        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1917        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1918        assert_eq!(rule.file, "src/types/*.ts");
1919        assert_eq!(rule.exports, vec!["*"]);
1920    }
1921
1922    #[test]
1923    fn ignore_export_rule_specific_exports() {
1924        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1925        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1926        assert_eq!(rule.exports.len(), 3);
1927        assert!(rule.exports.contains(&"FOO".to_string()));
1928    }
1929
1930    mod proptests {
1931        use super::*;
1932        use proptest::prelude::*;
1933
1934        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1935            make_config(production).resolve(
1936                PathBuf::from("/project"),
1937                OutputFormat::Human,
1938                1,
1939                true,
1940                true,
1941                None,
1942            )
1943        }
1944
1945        proptest! {
1946            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1947            #[test]
1948            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1949                let resolved = arb_resolved_config(production);
1950                prop_assert!(
1951                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1952                    "Default ignore should match node_modules"
1953                );
1954                prop_assert!(
1955                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1956                    "Default ignore should match dist"
1957                );
1958            }
1959
1960            /// Production mode always forces dev and optional deps to Off.
1961            #[test]
1962            fn production_forces_dev_deps_off(_unused in Just(())) {
1963                let resolved = arb_resolved_config(true);
1964                prop_assert_eq!(
1965                    resolved.rules.unused_dev_dependencies,
1966                    Severity::Off,
1967                    "Production should force unused_dev_dependencies off"
1968                );
1969                prop_assert_eq!(
1970                    resolved.rules.unused_optional_dependencies,
1971                    Severity::Off,
1972                    "Production should force unused_optional_dependencies off"
1973                );
1974            }
1975
1976            /// Non-production mode preserves default severity for dev deps.
1977            #[test]
1978            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1979                let resolved = arb_resolved_config(false);
1980                prop_assert_eq!(
1981                    resolved.rules.unused_dev_dependencies,
1982                    Severity::Warn,
1983                    "Non-production should keep default dev dep severity"
1984                );
1985            }
1986
1987            /// Default cache dir is root/.fallow.
1988            #[test]
1989            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1990                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1991                let expected_cache = root.join(".fallow");
1992                let resolved = make_config(false).resolve(
1993                    root,
1994                    OutputFormat::Human,
1995                    1,
1996                    true,
1997                    true,
1998                    None,
1999                );
2000                prop_assert_eq!(
2001                    resolved.cache_dir, expected_cache,
2002                    "Default cache dir should be root/.fallow"
2003                );
2004            }
2005
2006            /// Thread count is always passed through exactly.
2007            #[test]
2008            fn threads_passed_through(threads in 1..64usize) {
2009                let resolved = make_config(false).resolve(
2010                    PathBuf::from("/project"),
2011                    OutputFormat::Human,
2012                    threads,
2013                    true,
2014                    true, None,
2015                );
2016                prop_assert_eq!(
2017                    resolved.threads, threads,
2018                    "Thread count should be passed through"
2019                );
2020            }
2021
2022            /// Custom ignore patterns are merged with defaults, not replacing them.
2023            /// Uses a pattern regex that cannot match node_modules paths, so the
2024            /// assertion proves the default pattern is what provides the match.
2025            #[test]
2026            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2027                let mut config = make_config(false);
2028                config.ignore_patterns = vec![pattern];
2029                let resolved = config.resolve(
2030                    PathBuf::from("/project"),
2031                    OutputFormat::Human,
2032                    1,
2033                    true,
2034                    true, None,
2035                );
2036                prop_assert!(
2037                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2038                    "Default node_modules ignore should still be active"
2039                );
2040            }
2041        }
2042    }
2043
2044    #[test]
2045    fn resolve_expands_boundary_preset() {
2046        use crate::config::boundaries::BoundaryPreset;
2047
2048        let mut config = make_config(false);
2049        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2050        let resolved = config.resolve(
2051            PathBuf::from("/project"),
2052            OutputFormat::Human,
2053            1,
2054            true,
2055            true,
2056            None,
2057        );
2058        assert_eq!(resolved.boundaries.zones.len(), 3);
2059        assert_eq!(resolved.boundaries.rules.len(), 3);
2060        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2061        assert_eq!(
2062            resolved.boundaries.classify_zone("src/adapters/http.ts"),
2063            Some("adapters")
2064        );
2065    }
2066
2067    #[test]
2068    fn resolve_boundary_preset_with_user_override() {
2069        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2070
2071        let mut config = make_config(false);
2072        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2073        config.boundaries.zones = vec![BoundaryZone {
2074            name: "domain".to_string(),
2075            patterns: vec!["src/core/**".to_string()],
2076            auto_discover: vec![],
2077            root: None,
2078        }];
2079        let resolved = config.resolve(
2080            PathBuf::from("/project"),
2081            OutputFormat::Human,
2082            1,
2083            true,
2084            true,
2085            None,
2086        );
2087        assert_eq!(resolved.boundaries.zones.len(), 3);
2088        assert_eq!(
2089            resolved.boundaries.classify_zone("src/core/user.ts"),
2090            Some("domain")
2091        );
2092        assert_eq!(
2093            resolved.boundaries.classify_zone("src/domain/user.ts"),
2094            None
2095        );
2096    }
2097
2098    #[test]
2099    fn resolve_no_preset_unchanged() {
2100        let config = make_config(false);
2101        let resolved = config.resolve(
2102            PathBuf::from("/project"),
2103            OutputFormat::Human,
2104            1,
2105            true,
2106            true,
2107            None,
2108        );
2109        assert!(resolved.boundaries.is_empty());
2110    }
2111}