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    /// How many globs at the FRONT of [`Self::ignore_patterns`] came from the
218    /// user's `ignorePatterns`. The rest, in order, are
219    /// [`DEFAULT_IGNORE_PATTERNS`].
220    ///
221    /// Source discovery needs the split to answer "which pattern removed this
222    /// file": a match index below this count is the project's own explicit
223    /// choice and is reported nowhere, while an index at or above it names a
224    /// built-in the user never asked for (issue #2638).
225    pub user_ignore_pattern_count: usize,
226    /// Post-analysis finding-path matcher built from `ignoreFindings`; hides
227    /// findings without removing files from the module graph.
228    pub ignore_findings: FindingIgnoreMatcher,
229    /// Output format for this run, passed through from the CLI at resolve time.
230    pub output: OutputFormat,
231    /// Cache directory: `cache.dir` resolved against the root, or the default
232    /// `<root>/.fallow`.
233    pub cache_dir: PathBuf,
234    /// Worker-thread count, passed through from the CLI at resolve time.
235    pub threads: usize,
236    /// When true, skip reading and writing the persistent caches for this run.
237    pub no_cache: bool,
238    /// Extraction-cache size ceiling in megabytes (`None` = no ceiling), from
239    /// the CLI override, `FALLOW_CACHE_MAX_SIZE`, or `cache.maxSizeMb`.
240    pub cache_max_size_mb: Option<u32>,
241    /// Hash over extraction-affecting config (the sorted external plugin
242    /// names), mixed into cache keys so plugin changes invalidate cached
243    /// extractions instead of serving stale results.
244    pub cache_config_hash: u64,
245    /// Exact package names excluded from both unused-dependency and
246    /// unlisted-dependency detection.
247    pub ignore_dependencies: Vec<String>,
248    /// Compiled globs matched against raw import specifiers (not filesystem
249    /// paths) whose `unresolved-import` findings are suppressed.
250    pub ignore_unresolved_imports: Vec<GlobMatcher>,
251    /// Raw `ignoreExports` rules as configured, kept alongside the compiled
252    /// form for surfaces that need the original glob text (config editing,
253    /// diagnostics).
254    pub ignore_export_rules: Vec<IgnoreExportRule>,
255    /// `ignoreExports` rules with their file globs pre-compiled for matching.
256    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
257    /// `ignoreCatalogReferences` rules with consumer globs pre-compiled.
258    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
259    /// `ignoreDependencyOverrides` rules ready for matching.
260    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
261    /// Same-file-use suppression setting for `unused-export`.
262    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
263    /// Class-member names, globs, or heritage-scoped rules treated as
264    /// framework-used and exempt from `unused-class-member`.
265    pub used_class_members: Vec<UsedClassMemberRule>,
266    /// Decorator names stripped of the automatic `unused-class-member`
267    /// exemption that decorated members otherwise receive.
268    pub ignore_decorators: Vec<String>,
269    /// Compiled regex matched against each declared component prop's local
270    /// destructure binding name; a matching prop is exempted from
271    /// `unused-component-props`. `None` when `unusedComponentProps.ignorePattern`
272    /// is unset. Compiled from the validated raw pattern in [`Self::resolve`].
273    pub unused_component_props_ignore: Option<regex::Regex>,
274    /// Clone-detection settings, passed through unchanged.
275    pub duplicates: DuplicatesConfig,
276    /// Explicit similar-code candidate settings, passed through unchanged.
277    pub similar_code: SimilarCodeConfig,
278    /// Health and complexity thresholds, passed through unchanged.
279    pub health: HealthConfig,
280    /// TypeScript semantic-analysis opt-in, passed through unchanged.
281    pub type_aware: TypeAwareConfig,
282    /// Per-rule severities with production-mode adjustments applied: when
283    /// [`Self::production`] is set, `unused-dev-dependencies` and
284    /// `unused-optional-dependencies` are forced to `off`.
285    pub rules: RulesConfig,
286    /// Resolved architecture boundaries: preset expanded (honoring the
287    /// tsconfig `rootDir`), auto-discovered zones added, and rules validated.
288    pub boundaries: ResolvedBoundaryConfig,
289    /// Rule packs loaded from the `rulePacks` config key, in config order.
290    /// Validated at config load (`load_rule_packs` is also the validation
291    /// gate in the CLI and programmatic entry points); a pack that fails to
292    /// load here is skipped with a `tracing::error!` as defense in depth.
293    pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
294    /// Source paths from the `rulePacks` config key, index-aligned with
295    /// [`Self::rule_packs`] when every configured pack loaded successfully.
296    pub rule_pack_sources: Vec<PathBuf>,
297    /// Production mode for this analysis pass: test/spec/story/dev files are
298    /// excluded from discovery. Out of [`FallowConfig::resolve`] this is the
299    /// global `production` bool ([`super::ProductionConfig::global`]); the
300    /// per-analysis object form and CLI/env overrides are applied post-resolve.
301    pub production: bool,
302    /// Quiet mode from the CLI: suppress non-essential progress and warning
303    /// output.
304    pub quiet: bool,
305    /// External plugin definitions: inline `framework` entries plus those
306    /// discovered from `plugins` paths, `.fallow/plugins/`, and root
307    /// `fallow-plugin-*` files (first occurrence of a name wins).
308    pub external_plugins: Vec<ExternalPluginDef>,
309    /// Globs for files loaded dynamically at runtime; matching files are
310    /// seeded as entry points so they stay reachable.
311    pub dynamically_loaded: Vec<String>,
312    /// Per-file severity overrides with globs pre-compiled, in config order.
313    pub overrides: Vec<ResolvedOverride>,
314    /// Saved regression baseline for `--fail-on-regression`, when embedded.
315    pub regression: Option<super::RegressionConfig>,
316    /// In-repo `fallow audit` defaults, passed through unchanged.
317    pub audit: super::AuditConfig,
318    /// Configured CODEOWNERS path override; `None` probes the standard
319    /// locations.
320    pub codeowners: Option<String>,
321    /// Workspace package names (or globs over them) whose public API is
322    /// treated as externally consumed, making their export surface a
323    /// reachability root.
324    pub public_packages: Vec<String>,
325    /// Feature-flag detection settings, passed through unchanged.
326    pub flags: FlagsConfig,
327    /// Security catalogue scoping with `requestReceivers` normalized
328    /// (trimmed, lowercased, deduplicated).
329    pub security: SecurityConfig,
330    /// `fallow fix` behavior settings, passed through unchanged.
331    pub fix: super::FixConfig,
332    /// Module-resolver settings (extra `exports` conditions), passed through
333    /// unchanged.
334    pub resolve: ResolveConfig,
335    /// When true, entry-point exports are subject to `unused-export`
336    /// detection instead of being auto-credited as used.
337    pub include_entry_exports: bool,
338    /// When true, drop Nuxt convention entry-pattern fallbacks that
339    /// `nuxt.config` does not explicitly declare; auto-import graph edges are
340    /// synthesized regardless.
341    pub auto_imports: bool,
342    /// When true, a source file that did not parse cleanly fails the run
343    /// through the `parse-error` gate. The CLI flag `--fail-on-parse-error`
344    /// arms the same gate.
345    pub fail_on_parse_error: bool,
346    /// Source files strictly larger than this many bytes are skipped at
347    /// discovery (never read, parsed, or analyzed), guarding against the
348    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
349    /// causes (issue #1086). `None` means no limit. Declaration files
350    /// (`.d.ts`/`.d.mts`/`.d.cts`) are exempt regardless of size because they
351    /// are reachability roots for global types. Defaults to
352    /// [`DEFAULT_MAX_FILE_SIZE_MB`] MB; the CLI overrides it post-resolve from
353    /// `--max-file-size` / `FALLOW_MAX_FILE_SIZE` (`0` = unlimited).
354    pub max_file_size_bytes: Option<u64>,
355    /// Which revision this analysis pass describes. Always
356    /// [`AnalysisSnapshot::Current`] out of [`FallowConfig::resolve`]; the CLI
357    /// sets [`AnalysisSnapshot::Base`] post-resolve for the isolated
358    /// `audit --base` pass so diagnostics can name the base revision.
359    pub analysis_snapshot: AnalysisSnapshot,
360}
361
362/// Default per-file size ceiling (in megabytes) for source discovery. A value
363/// chosen so hand-written source effectively never reaches it while generated
364/// API clients, vendored bundles, and minified blobs do. See issue #1086.
365pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
366
367/// [`DEFAULT_MAX_FILE_SIZE_MB`] expressed in bytes.
368pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
369
370/// Convert a user-supplied megabyte ceiling into the byte limit stored on
371/// [`ResolvedConfig::max_file_size_bytes`]. `Some(0)` means "no limit"
372/// (`None`); any other `Some(n)` is `n` MB in bytes; `None` (unset) keeps the
373/// built-in [`DEFAULT_MAX_FILE_SIZE_BYTES`].
374#[must_use]
375pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
376    match max_file_size_mb {
377        None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
378        Some(0) => None,
379        Some(mb) => Some(u64::from(mb) * 1024 * 1024),
380    }
381}
382
383/// Hash the extraction-affecting configuration a persisted cache is keyed on:
384/// the external plugin names and the user flag patterns, which the parse
385/// applies. Built-in-only flag patterns add nothing, so a config without a
386/// `flags` section keeps the hash it had before flag patterns joined it.
387///
388/// Public because a run is not the only thing that needs it: `fallow doctor`
389/// inspects the cache without running an analysis, and it resolves config with
390/// caching disabled, which zeroes the stored hash. Comparing against that zero
391/// reported every healthy cache as config drift.
392#[must_use]
393pub fn cache_config_hash(external_plugins: &[ExternalPluginDef], flags: &FlagsConfig) -> u64 {
394    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
395    names.sort_unstable();
396    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
397    for name in names {
398        hash_str(&mut hasher, name);
399    }
400    let patterns = flags.patterns();
401    if !patterns.is_builtin_only() {
402        hasher.update(b"\0flags");
403        hasher.update(&(patterns.sdk_patterns.len() as u64).to_le_bytes());
404        for (function, name_arg, provider) in &patterns.sdk_patterns {
405            hash_str(&mut hasher, function);
406            hasher.update(&(*name_arg as u64).to_le_bytes());
407            hash_str(&mut hasher, provider);
408        }
409        hasher.update(&(patterns.env_prefixes.len() as u64).to_le_bytes());
410        for prefix in &patterns.env_prefixes {
411            hash_str(&mut hasher, prefix);
412        }
413        hasher.update(&[u8::from(patterns.config_object_heuristics)]);
414    }
415    hasher.digest()
416}
417
418fn hash_str(hasher: &mut xxhash_rust::xxh3::Xxh3, value: &str) {
419    hasher.update(&(value.len() as u32).to_le_bytes());
420    hasher.update(value.as_bytes());
421}
422
423fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
424    let Some(dir) = configured else {
425        return root.join(".fallow");
426    };
427    if dir.is_absolute() {
428        dir
429    } else {
430        root.join(dir)
431    }
432}
433
434fn normalize_user_glob_pattern(pattern: &str) -> &str {
435    pattern.strip_prefix("./").unwrap_or(pattern)
436}
437
438/// Built-in discovery ignore patterns, unioned into
439/// [`ResolvedConfig::ignore_patterns`] after the user's own `ignorePatterns`.
440///
441/// Public and ordered because the order IS the index layout of that union:
442/// user patterns occupy `0..user_ignore_pattern_count` and these follow, in
443/// this order. Source discovery maps a match index back to the pattern text
444/// through that layout when it reports which built-in removed a candidate
445/// source file (issue #2638), so reordering this list or changing where it is
446/// appended changes an output contract.
447///
448/// The union only ever adds: an `ignorePatterns` entry cannot negate a
449/// built-in, so "write a negation" is never the remedy for a file excluded
450/// here.
451pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
452    "**/node_modules/**",
453    "**/dist/**",
454    "**/build/**",
455    "**/.git/**",
456    "**/coverage/**",
457    "**/*.min.js",
458    "**/*.min.mjs",
459    "**/*.min.cjs",
460    "**/*.bundle.js",
461];
462
463#[expect(
464    clippy::expect_used,
465    reason = "user glob patterns are validated before config resolution"
466)]
467fn compile_ignore_patterns(ignore_patterns: &[String]) -> GlobSet {
468    let mut ignore_builder = GlobSetBuilder::new();
469    for pattern in ignore_patterns {
470        let normalized = normalize_user_glob_pattern(pattern);
471        ignore_builder.add(
472            Glob::new(normalized).expect("ignorePatterns entry was validated at config load time"),
473        );
474    }
475
476    for pattern in DEFAULT_IGNORE_PATTERNS {
477        ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
478    }
479
480    ignore_builder.build().unwrap_or_default()
481}
482
483#[expect(
484    clippy::expect_used,
485    reason = "user glob patterns are validated before config resolution"
486)]
487fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
488    patterns
489        .iter()
490        .map(|pattern| {
491            let normalized = normalize_user_glob_pattern(pattern);
492            Glob::new(normalized)
493                .expect("ignoreUnresolvedImports entry was validated at config load time")
494                .compile_matcher()
495        })
496        .collect()
497}
498
499fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
500    if production {
501        rules.unused_dev_dependencies = Severity::Off;
502        rules.unused_optional_dependencies = Severity::Off;
503    }
504    rules
505}
506
507fn resolve_boundaries(
508    mut boundaries: super::boundaries::BoundaryConfig,
509    root: &Path,
510) -> ResolvedBoundaryConfig {
511    expand_boundary_preset(&mut boundaries, root);
512    let logical_groups = boundaries.expand_auto_discover(root);
513    let mut resolved = boundaries.resolve();
514    resolved.logical_groups = logical_groups;
515    resolved
516}
517
518/// Expand the boundary preset in place, with the tsconfig `rootDir` as the
519/// source root. Does nothing without a preset.
520pub(super) fn expand_boundary_preset(
521    boundaries: &mut super::boundaries::BoundaryConfig,
522    root: &Path,
523) {
524    if boundaries.preset.is_some() {
525        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
526            .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
527            .unwrap_or_else(|| "src".to_owned());
528        if source_root != "src" {
529            tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
530        }
531        boundaries.expand(&source_root);
532    }
533}
534
535/// Inter-file rules that a per-file override cannot change.
536///
537/// `circular-dependency` is not in this list: a cycle takes the highest
538/// severity of its files, and a cycle whose files all resolve to `off` is
539/// dropped, so a per-file override does change the result.
540fn ineffective_inter_file_override_rules(rules: &PartialRulesConfig) -> Vec<&'static str> {
541    let mut names = Vec::new();
542    if rules.duplicate_exports.is_some() {
543        names.push("duplicate-exports");
544    }
545    if rules.re_export_cycle.is_some() {
546        names.push("re-export-cycle");
547    }
548    names
549}
550
551fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
552    let ineffective = ineffective_inter_file_override_rules(rules);
553    if ineffective.contains(&"duplicate-exports")
554        && record_inter_file_warn_seen("duplicate-exports", files)
555    {
556        let files = files.join(", ");
557        tracing::warn!(
558            "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."
559        );
560    }
561    if ineffective.contains(&"re-export-cycle")
562        && record_inter_file_warn_seen("re-export-cycle", files)
563    {
564        let files = files.join(", ");
565        tracing::warn!(
566            "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."
567        );
568    }
569}
570
571#[expect(
572    clippy::expect_used,
573    reason = "override glob patterns are validated before config resolution"
574)]
575fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
576    overrides
577        .into_iter()
578        .filter_map(|override_entry| {
579            warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
580            let matchers: Vec<globset::GlobMatcher> = override_entry
581                .files
582                .iter()
583                .map(|pattern| {
584                    Glob::new(pattern)
585                        .expect("overrides[].files pattern was validated at config load time")
586                        .compile_matcher()
587                })
588                .collect();
589            if matchers.is_empty() {
590                None
591            } else {
592                Some(ResolvedOverride {
593                    matchers,
594                    rules: override_entry.rules,
595                })
596            }
597        })
598        .collect()
599}
600
601/// Compile `ignoreExports` file globs into matchers paired with export names.
602#[expect(
603    clippy::expect_used,
604    reason = "user glob patterns are validated before config resolution"
605)]
606fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
607    rules
608        .iter()
609        .map(|rule| CompiledIgnoreExportRule {
610            matcher: Glob::new(&rule.file)
611                .expect("ignoreExports[].file was validated at config load time")
612                .compile_matcher(),
613            exports: rule.exports.clone(),
614        })
615        .collect()
616}
617
618/// Compile `ignoreCatalogReferences` rules, pre-compiling the consumer glob.
619#[expect(
620    clippy::expect_used,
621    reason = "user glob patterns are validated before config resolution"
622)]
623fn compile_ignore_catalog_reference_rules(
624    rules: &[IgnoreCatalogReferenceRule],
625) -> Vec<CompiledIgnoreCatalogReferenceRule> {
626    rules
627        .iter()
628        .map(|rule| CompiledIgnoreCatalogReferenceRule {
629            package: rule.package.clone(),
630            catalog: rule.catalog.clone(),
631            consumer_matcher: rule.consumer.as_ref().map(|pattern| {
632                Glob::new(pattern)
633                    .expect("ignoreCatalogReferences[].consumer was validated at config load time")
634                    .compile_matcher()
635            }),
636        })
637        .collect()
638}
639
640/// Convert `ignoreDependencyOverrides` rules into their match-ready form.
641fn compile_ignore_dependency_override_rules(
642    rules: &[IgnoreDependencyOverrideRule],
643) -> Vec<CompiledIgnoreDependencyOverrideRule> {
644    rules
645        .iter()
646        .map(|rule| CompiledIgnoreDependencyOverrideRule {
647            package: rule.package.clone(),
648            source: rule.source.clone(),
649        })
650        .collect()
651}
652
653struct CompiledIgnoreSettings {
654    patterns: GlobSet,
655    user_pattern_count: usize,
656    findings: FindingIgnoreMatcher,
657    unresolved_imports: Vec<GlobMatcher>,
658    exports: Vec<CompiledIgnoreExportRule>,
659    catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
660    dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
661}
662
663fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
664    CompiledIgnoreSettings {
665        patterns: compile_ignore_patterns(&config.ignore_patterns),
666        user_pattern_count: config.ignore_patterns.len(),
667        findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
668        unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
669        exports: compile_ignore_export_rules(&config.ignore_exports),
670        catalog_references: compile_ignore_catalog_reference_rules(
671            &config.ignore_catalog_references,
672        ),
673        dependency_overrides: compile_ignore_dependency_override_rules(
674            &config.ignore_dependency_overrides,
675        ),
676    }
677}
678
679struct ResolvedPluginSettings {
680    external_plugins: Vec<ExternalPluginDef>,
681    rule_packs: Vec<crate::rule_pack::RulePackDef>,
682    rule_pack_sources: Vec<PathBuf>,
683}
684
685fn resolve_plugin_settings(
686    root: &Path,
687    configured_plugins: &[String],
688    framework: Vec<ExternalPluginDef>,
689    rule_packs: &[String],
690) -> ResolvedPluginSettings {
691    let mut external_plugins = discover_external_plugins(root, configured_plugins);
692    external_plugins.extend(framework);
693
694    let configured_rule_packs = rule_packs;
695    let rule_packs =
696        crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
697            for error in &errors {
698                tracing::error!("invalid rule pack: {error}");
699            }
700            Vec::new()
701        });
702    let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
703        configured_rule_packs.iter().map(PathBuf::from).collect()
704    } else {
705        Vec::new()
706    };
707
708    ResolvedPluginSettings {
709        external_plugins,
710        rule_packs,
711        rule_pack_sources,
712    }
713}
714
715struct ResolvedCacheSettings {
716    dir: PathBuf,
717    max_size_mb: Option<u32>,
718    config_hash: u64,
719}
720
721struct ResolvedProductionRules {
722    production: bool,
723    rules: RulesConfig,
724}
725
726fn resolve_production_rules(
727    production_config: ProductionConfig,
728    rules: RulesConfig,
729) -> ResolvedProductionRules {
730    let production = production_config.global();
731    ResolvedProductionRules {
732        production,
733        rules: resolve_rules_for_production(rules, production),
734    }
735}
736
737fn resolve_cache_settings(
738    root: &Path,
739    configured_dir: Option<PathBuf>,
740    configured_max_size_mb: Option<u32>,
741    override_max_size_mb: Option<u32>,
742    no_cache: bool,
743    config_hash: impl FnOnce() -> u64,
744) -> ResolvedCacheSettings {
745    ResolvedCacheSettings {
746        dir: resolve_cache_dir(root, configured_dir),
747        max_size_mb: override_max_size_mb.or(configured_max_size_mb),
748        config_hash: if no_cache { 0 } else { config_hash() },
749    }
750}
751
752fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
753    SecurityConfig {
754        request_receivers: security.normalized_request_receivers(),
755        ..security
756    }
757}
758
759struct ResolvedPathPolicySettings {
760    boundaries: ResolvedBoundaryConfig,
761    overrides: Vec<ResolvedOverride>,
762}
763
764fn resolve_path_policy_settings(
765    boundaries: BoundaryConfig,
766    overrides: Vec<ConfigOverride>,
767    root: &Path,
768) -> ResolvedPathPolicySettings {
769    ResolvedPathPolicySettings {
770        boundaries: resolve_boundaries(boundaries, root),
771        overrides: compile_overrides(overrides),
772    }
773}
774
775fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
776    pattern.and_then(|pattern| match regex::Regex::new(pattern) {
777        Ok(re) => Some(re),
778        Err(error) => {
779            tracing::warn!(
780                %error,
781                "ignoring invalid unusedComponentProps.ignorePattern; this config was \
782                 not validated through FallowConfig::load"
783            );
784            None
785        }
786    })
787}
788
789impl FallowConfig {
790    /// Resolve into a fully resolved config with compiled globs.
791    #[expect(
792        clippy::too_many_arguments,
793        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"
794    )]
795    pub fn resolve(
796        self,
797        root: PathBuf,
798        output: OutputFormat,
799        threads: usize,
800        no_cache: bool,
801        quiet: bool,
802        cache_max_size_mb: Option<u32>,
803    ) -> ResolvedConfig {
804        let compiled_ignores = compile_ignore_settings(&self);
805
806        let production_rules = resolve_production_rules(self.production, self.rules);
807
808        let plugins =
809            resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
810
811        let cache = resolve_cache_settings(
812            &root,
813            self.cache.dir,
814            self.cache.max_size_mb,
815            cache_max_size_mb,
816            no_cache,
817            || cache_config_hash(&plugins.external_plugins, &self.flags),
818        );
819
820        let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
821
822        let unused_component_props_ignore = compile_unused_component_props_ignore(
823            self.unused_component_props.ignore_pattern.as_deref(),
824        );
825
826        ResolvedConfig {
827            root,
828            entry_patterns: self.entry,
829            ignore_patterns: compiled_ignores.patterns,
830            user_ignore_pattern_count: compiled_ignores.user_pattern_count,
831            ignore_findings: compiled_ignores.findings,
832            output,
833            cache_dir: cache.dir,
834            threads,
835            no_cache,
836            cache_max_size_mb: cache.max_size_mb,
837            cache_config_hash: cache.config_hash,
838            ignore_dependencies: self.ignore_dependencies,
839            ignore_unresolved_imports: compiled_ignores.unresolved_imports,
840            ignore_export_rules: self.ignore_exports,
841            compiled_ignore_exports: compiled_ignores.exports,
842            compiled_ignore_catalog_references: compiled_ignores.catalog_references,
843            compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
844            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
845            used_class_members: self.used_class_members,
846            ignore_decorators: self.ignore_decorators,
847            unused_component_props_ignore,
848            duplicates: self.duplicates,
849            similar_code: self.similar_code,
850            health: self.health,
851            type_aware: self.type_aware,
852            rules: production_rules.rules,
853            boundaries: path_policy.boundaries,
854            rule_packs: plugins.rule_packs,
855            rule_pack_sources: plugins.rule_pack_sources,
856            production: production_rules.production,
857            quiet,
858            external_plugins: plugins.external_plugins,
859            dynamically_loaded: self.dynamically_loaded,
860            overrides: path_policy.overrides,
861            regression: self.regression,
862            audit: self.audit,
863            codeowners: self.codeowners,
864            public_packages: self.public_packages,
865            flags: self.flags,
866            security: normalize_security_config(self.security),
867            fix: self.fix,
868            resolve: self.resolve,
869            include_entry_exports: self.include_entry_exports,
870            auto_imports: self.auto_imports,
871            fail_on_parse_error: self.fail_on_parse_error,
872            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
873            analysis_snapshot: AnalysisSnapshot::Current,
874        }
875    }
876}
877
878impl ResolvedConfig {
879    /// Resolve the effective rules for a given file path.
880    /// Starts with base rules and applies matching overrides in order.
881    #[must_use]
882    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
883        if self.overrides.is_empty() {
884            return self.rules.clone();
885        }
886
887        let relative = path.strip_prefix(&self.root).unwrap_or(path);
888        let relative_str = relative.to_string_lossy();
889
890        let mut rules = self.rules.clone();
891        for override_entry in &self.overrides {
892            let matches = override_entry
893                .matchers
894                .iter()
895                .any(|m| m.is_match(relative_str.as_ref()));
896            if matches {
897                rules.apply_partial(&override_entry.rules);
898            }
899        }
900        rules
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use crate::CacheConfig;
908    use crate::config::boundaries::BoundaryConfig;
909    use crate::config::health::HealthConfig;
910
911    #[test]
912    fn cache_config_hash_keys_on_user_flag_patterns() {
913        let builtin = cache_config_hash(&[], &FlagsConfig::default());
914        let with_pattern = |function: &str| FlagsConfig {
915            sdk_patterns: vec![super::super::flags::SdkPattern {
916                function: function.to_string(),
917                name_arg: 0,
918                provider: None,
919            }],
920            ..FlagsConfig::default()
921        };
922
923        assert_eq!(
924            builtin,
925            xxhash_rust::xxh3::Xxh3::new().digest(),
926            "a config without flag patterns keeps the plugin-only hash"
927        );
928        assert_ne!(builtin, cache_config_hash(&[], &with_pattern("isOn")));
929        assert_ne!(
930            cache_config_hash(&[], &with_pattern("isOn")),
931            cache_config_hash(&[], &with_pattern("isOff"))
932        );
933        assert_ne!(
934            builtin,
935            cache_config_hash(
936                &[],
937                &FlagsConfig {
938                    config_object_heuristics: true,
939                    ..FlagsConfig::default()
940                }
941            )
942        );
943        assert_ne!(
944            builtin,
945            cache_config_hash(
946                &[],
947                &FlagsConfig {
948                    env_prefixes: vec!["MYAPP_".to_string()],
949                    ..FlagsConfig::default()
950                }
951            )
952        );
953    }
954
955    #[test]
956    fn overrides_deserialize() {
957        let json_str = r#"{
958            "overrides": [{
959                "files": ["*.test.ts"],
960                "rules": {
961                    "unused-exports": "off"
962                }
963            }]
964        }"#;
965        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
966        assert_eq!(config.overrides.len(), 1);
967        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
968        assert_eq!(
969            config.overrides[0].rules.unused_exports,
970            Some(Severity::Off)
971        );
972        assert_eq!(config.overrides[0].rules.unused_files, None);
973    }
974
975    #[test]
976    fn resolve_rules_for_path_no_overrides() {
977        let config = FallowConfig {
978            schema: None,
979            minimum_version: 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            regression: None,
1006            type_aware: crate::TypeAwareConfig::default(),
1007            audit: crate::config::AuditConfig::default(),
1008            codeowners: None,
1009            public_packages: vec![],
1010            flags: FlagsConfig::default(),
1011            security: SecurityConfig::default(),
1012            fix: crate::config::FixConfig::default(),
1013            resolve: ResolveConfig::default(),
1014            sealed: false,
1015            include_entry_exports: false,
1016            auto_imports: false,
1017            fail_on_parse_error: false,
1018            cache: CacheConfig::default(),
1019        };
1020        let resolved = config.resolve(
1021            PathBuf::from("/project"),
1022            OutputFormat::Human,
1023            1,
1024            true,
1025            true,
1026            None,
1027        );
1028        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1029        assert_eq!(rules.unused_files, Severity::Error);
1030    }
1031
1032    #[test]
1033    fn resolve_rules_for_path_with_matching_override() {
1034        let config = FallowConfig {
1035            schema: None,
1036            minimum_version: None,
1037            extends: vec![],
1038            entry: vec![],
1039            ignore_patterns: vec![],
1040            ignore_findings: vec![],
1041            framework: vec![],
1042            workspaces: None,
1043            ignore_dependencies: vec![],
1044            ignore_unresolved_imports: vec![],
1045            ignore_exports: vec![],
1046            ignore_catalog_references: vec![],
1047            ignore_dependency_overrides: vec![],
1048            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1049            used_class_members: vec![],
1050            ignore_decorators: vec![],
1051            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1052            duplicates: DuplicatesConfig::default(),
1053            similar_code: SimilarCodeConfig::default(),
1054            health: HealthConfig::default(),
1055            rules: RulesConfig::default(),
1056            boundaries: BoundaryConfig::default(),
1057            production: false.into(),
1058            plugins: vec![],
1059            rule_packs: vec![],
1060            dynamically_loaded: vec![],
1061            overrides: vec![ConfigOverride {
1062                files: vec!["*.test.ts".to_string()],
1063                rules: PartialRulesConfig {
1064                    unused_exports: Some(Severity::Off),
1065                    ..Default::default()
1066                },
1067            }],
1068            regression: None,
1069            type_aware: crate::TypeAwareConfig::default(),
1070            audit: crate::config::AuditConfig::default(),
1071            codeowners: None,
1072            public_packages: vec![],
1073            flags: FlagsConfig::default(),
1074            security: SecurityConfig::default(),
1075            fix: crate::config::FixConfig::default(),
1076            resolve: ResolveConfig::default(),
1077            sealed: false,
1078            include_entry_exports: false,
1079            auto_imports: false,
1080            fail_on_parse_error: false,
1081            cache: CacheConfig::default(),
1082        };
1083        let resolved = config.resolve(
1084            PathBuf::from("/project"),
1085            OutputFormat::Human,
1086            1,
1087            true,
1088            true,
1089            None,
1090        );
1091
1092        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1093        assert_eq!(test_rules.unused_exports, Severity::Off);
1094        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
1095
1096        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1097        assert_eq!(src_rules.unused_exports, Severity::Error);
1098    }
1099
1100    #[test]
1101    fn resolve_rules_for_path_later_override_wins() {
1102        let config = FallowConfig {
1103            schema: None,
1104            minimum_version: None,
1105            extends: vec![],
1106            entry: vec![],
1107            ignore_patterns: vec![],
1108            ignore_findings: vec![],
1109            framework: vec![],
1110            workspaces: None,
1111            ignore_dependencies: vec![],
1112            ignore_unresolved_imports: vec![],
1113            ignore_exports: vec![],
1114            ignore_catalog_references: vec![],
1115            ignore_dependency_overrides: vec![],
1116            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1117            used_class_members: vec![],
1118            ignore_decorators: vec![],
1119            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1120            duplicates: DuplicatesConfig::default(),
1121            similar_code: SimilarCodeConfig::default(),
1122            health: HealthConfig::default(),
1123            rules: RulesConfig::default(),
1124            boundaries: BoundaryConfig::default(),
1125            production: false.into(),
1126            plugins: vec![],
1127            rule_packs: vec![],
1128            dynamically_loaded: vec![],
1129            overrides: vec![
1130                ConfigOverride {
1131                    files: vec!["*.ts".to_string()],
1132                    rules: PartialRulesConfig {
1133                        unused_files: Some(Severity::Warn),
1134                        ..Default::default()
1135                    },
1136                },
1137                ConfigOverride {
1138                    files: vec!["*.test.ts".to_string()],
1139                    rules: PartialRulesConfig {
1140                        unused_files: Some(Severity::Off),
1141                        ..Default::default()
1142                    },
1143                },
1144            ],
1145            regression: None,
1146            type_aware: crate::TypeAwareConfig::default(),
1147            audit: crate::config::AuditConfig::default(),
1148            codeowners: None,
1149            public_packages: vec![],
1150            flags: FlagsConfig::default(),
1151            security: SecurityConfig::default(),
1152            fix: crate::config::FixConfig::default(),
1153            resolve: ResolveConfig::default(),
1154            sealed: false,
1155            include_entry_exports: false,
1156            auto_imports: false,
1157            fail_on_parse_error: false,
1158            cache: CacheConfig::default(),
1159        };
1160        let resolved = config.resolve(
1161            PathBuf::from("/project"),
1162            OutputFormat::Human,
1163            1,
1164            true,
1165            true,
1166            None,
1167        );
1168
1169        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1170        assert_eq!(rules.unused_files, Severity::Off);
1171
1172        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1173        assert_eq!(rules2.unused_files, Severity::Warn);
1174    }
1175
1176    #[test]
1177    fn resolve_keeps_inter_file_rule_override_after_warning() {
1178        let config = FallowConfig {
1179            schema: None,
1180            minimum_version: None,
1181            extends: vec![],
1182            entry: vec![],
1183            ignore_patterns: vec![],
1184            ignore_findings: vec![],
1185            framework: vec![],
1186            workspaces: None,
1187            ignore_dependencies: vec![],
1188            ignore_unresolved_imports: vec![],
1189            ignore_exports: vec![],
1190            ignore_catalog_references: vec![],
1191            ignore_dependency_overrides: vec![],
1192            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1193            used_class_members: vec![],
1194            ignore_decorators: vec![],
1195            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1196            duplicates: DuplicatesConfig::default(),
1197            similar_code: SimilarCodeConfig::default(),
1198            health: HealthConfig::default(),
1199            rules: RulesConfig::default(),
1200            boundaries: BoundaryConfig::default(),
1201            production: false.into(),
1202            plugins: vec![],
1203            rule_packs: vec![],
1204            dynamically_loaded: vec![],
1205            overrides: vec![ConfigOverride {
1206                files: vec!["**/ui/**".to_string()],
1207                rules: PartialRulesConfig {
1208                    duplicate_exports: Some(Severity::Off),
1209                    unused_files: Some(Severity::Warn),
1210                    ..Default::default()
1211                },
1212            }],
1213            regression: None,
1214            type_aware: crate::TypeAwareConfig::default(),
1215            audit: crate::config::AuditConfig::default(),
1216            codeowners: None,
1217            public_packages: vec![],
1218            flags: FlagsConfig::default(),
1219            security: SecurityConfig::default(),
1220            fix: crate::config::FixConfig::default(),
1221            resolve: ResolveConfig::default(),
1222            sealed: false,
1223            include_entry_exports: false,
1224            auto_imports: false,
1225            fail_on_parse_error: false,
1226            cache: CacheConfig::default(),
1227        };
1228        let resolved = config.resolve(
1229            PathBuf::from("/project"),
1230            OutputFormat::Human,
1231            1,
1232            true,
1233            true,
1234            None,
1235        );
1236        assert_eq!(
1237            resolved.overrides.len(),
1238            1,
1239            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1240        );
1241        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1242        assert_eq!(rules.unused_files, Severity::Warn);
1243    }
1244
1245    #[test]
1246    fn circular_dependency_override_is_not_reported_as_ineffective() {
1247        let rules = PartialRulesConfig {
1248            circular_dependencies: Some(Severity::Off),
1249            duplicate_exports: Some(Severity::Off),
1250            re_export_cycle: Some(Severity::Off),
1251            ..PartialRulesConfig::default()
1252        };
1253        assert_eq!(
1254            ineffective_inter_file_override_rules(&rules),
1255            vec!["duplicate-exports", "re-export-cycle"]
1256        );
1257
1258        let only_circular = PartialRulesConfig {
1259            circular_dependencies: Some(Severity::Off),
1260            ..PartialRulesConfig::default()
1261        };
1262        assert!(ineffective_inter_file_override_rules(&only_circular).is_empty());
1263    }
1264
1265    #[test]
1266    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1267        reset_inter_file_warn_dedup_for_test();
1268        let files_a = vec!["__test_dedup_a/*".to_string()];
1269        let files_b = vec!["__test_dedup_b/*".to_string()];
1270
1271        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1272        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1273        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1274
1275        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1276        assert!(!record_inter_file_warn_seen(
1277            "circular-dependency",
1278            &files_a
1279        ));
1280
1281        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1282
1283        let files_reordered = vec![
1284            "__test_dedup_b/*".to_string(),
1285            "__test_dedup_a/*".to_string(),
1286        ];
1287        let files_natural = vec![
1288            "__test_dedup_a/*".to_string(),
1289            "__test_dedup_b/*".to_string(),
1290        ];
1291        reset_inter_file_warn_dedup_for_test();
1292        assert!(record_inter_file_warn_seen(
1293            "duplicate-exports",
1294            &files_natural
1295        ));
1296        assert!(!record_inter_file_warn_seen(
1297            "duplicate-exports",
1298            &files_reordered
1299        ));
1300    }
1301
1302    #[test]
1303    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1304        reset_inter_file_warn_dedup_for_test();
1305        let files = vec!["__test_resolve_dedup/**".to_string()];
1306        let build_config = || FallowConfig {
1307            schema: None,
1308            minimum_version: None,
1309            extends: vec![],
1310            entry: vec![],
1311            ignore_patterns: vec![],
1312            ignore_findings: vec![],
1313            framework: vec![],
1314            workspaces: None,
1315            ignore_dependencies: vec![],
1316            ignore_unresolved_imports: vec![],
1317            ignore_exports: vec![],
1318            ignore_catalog_references: vec![],
1319            ignore_dependency_overrides: vec![],
1320            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1321            used_class_members: vec![],
1322            ignore_decorators: vec![],
1323            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1324            duplicates: DuplicatesConfig::default(),
1325            similar_code: SimilarCodeConfig::default(),
1326            health: HealthConfig::default(),
1327            rules: RulesConfig::default(),
1328            boundaries: BoundaryConfig::default(),
1329            production: false.into(),
1330            plugins: vec![],
1331            rule_packs: vec![],
1332            dynamically_loaded: vec![],
1333            overrides: vec![ConfigOverride {
1334                files: files.clone(),
1335                rules: PartialRulesConfig {
1336                    duplicate_exports: Some(Severity::Off),
1337                    ..Default::default()
1338                },
1339            }],
1340            regression: None,
1341            type_aware: crate::TypeAwareConfig::default(),
1342            audit: crate::config::AuditConfig::default(),
1343            codeowners: None,
1344            public_packages: vec![],
1345            flags: FlagsConfig::default(),
1346            security: SecurityConfig::default(),
1347            fix: crate::config::FixConfig::default(),
1348            resolve: ResolveConfig::default(),
1349            sealed: false,
1350            include_entry_exports: false,
1351            auto_imports: false,
1352            fail_on_parse_error: false,
1353            cache: CacheConfig::default(),
1354        };
1355        for _ in 0..10 {
1356            let _ = build_config().resolve(
1357                PathBuf::from("/project"),
1358                OutputFormat::Human,
1359                1,
1360                true,
1361                true,
1362                None,
1363            );
1364        }
1365        assert!(
1366            !record_inter_file_warn_seen("duplicate-exports", &files),
1367            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1368        );
1369    }
1370
1371    /// Helper to build a FallowConfig with minimal boilerplate.
1372    fn make_config(production: bool) -> FallowConfig {
1373        FallowConfig {
1374            schema: None,
1375            minimum_version: None,
1376            extends: vec![],
1377            entry: vec![],
1378            ignore_patterns: vec![],
1379            ignore_findings: vec![],
1380            framework: vec![],
1381            workspaces: None,
1382            ignore_dependencies: vec![],
1383            ignore_unresolved_imports: vec![],
1384            ignore_exports: vec![],
1385            ignore_catalog_references: vec![],
1386            ignore_dependency_overrides: vec![],
1387            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1388            used_class_members: vec![],
1389            ignore_decorators: vec![],
1390            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1391            duplicates: DuplicatesConfig::default(),
1392            similar_code: SimilarCodeConfig::default(),
1393            health: HealthConfig::default(),
1394            rules: RulesConfig::default(),
1395            boundaries: BoundaryConfig::default(),
1396            production: production.into(),
1397            plugins: vec![],
1398            rule_packs: vec![],
1399            dynamically_loaded: vec![],
1400            overrides: vec![],
1401            regression: None,
1402            type_aware: crate::TypeAwareConfig::default(),
1403            audit: crate::config::AuditConfig::default(),
1404            codeowners: None,
1405            public_packages: vec![],
1406            flags: FlagsConfig::default(),
1407            security: SecurityConfig::default(),
1408            fix: crate::config::FixConfig::default(),
1409            resolve: ResolveConfig::default(),
1410            sealed: false,
1411            include_entry_exports: false,
1412            auto_imports: false,
1413            fail_on_parse_error: false,
1414            cache: CacheConfig::default(),
1415        }
1416    }
1417
1418    #[test]
1419    fn resolve_tracks_rule_pack_sources_in_config_order() {
1420        let dir = tempfile::tempdir().unwrap();
1421        std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1422        std::fs::write(
1423            dir.path().join("rule-packs/team-policy.jsonc"),
1424            r#"{
1425  "version": 1,
1426  "name": "team-policy",
1427  "rules": [
1428    {
1429      "id": "no-moment",
1430      "kind": "banned-import",
1431      "specifiers": ["moment"]
1432    }
1433  ]
1434}
1435"#,
1436        )
1437        .unwrap();
1438
1439        let mut config = make_config(false);
1440        config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1441
1442        let resolved = config.resolve(
1443            dir.path().to_path_buf(),
1444            OutputFormat::Human,
1445            1,
1446            true,
1447            true,
1448            None,
1449        );
1450
1451        assert_eq!(resolved.rule_packs.len(), 1);
1452        assert_eq!(resolved.rule_packs[0].name, "team-policy");
1453        assert_eq!(
1454            resolved.rule_pack_sources,
1455            vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1456        );
1457    }
1458
1459    #[test]
1460    fn resolve_production_forces_dev_deps_off() {
1461        let resolved = make_config(true).resolve(
1462            PathBuf::from("/project"),
1463            OutputFormat::Human,
1464            1,
1465            true,
1466            true,
1467            None,
1468        );
1469        assert_eq!(
1470            resolved.rules.unused_dev_dependencies,
1471            Severity::Off,
1472            "production mode should force unused_dev_dependencies to off"
1473        );
1474    }
1475
1476    #[test]
1477    fn resolve_production_forces_optional_deps_off() {
1478        let resolved = make_config(true).resolve(
1479            PathBuf::from("/project"),
1480            OutputFormat::Human,
1481            1,
1482            true,
1483            true,
1484            None,
1485        );
1486        assert_eq!(
1487            resolved.rules.unused_optional_dependencies,
1488            Severity::Off,
1489            "production mode should force unused_optional_dependencies to off"
1490        );
1491    }
1492
1493    #[test]
1494    fn resolve_production_preserves_other_rules() {
1495        let resolved = make_config(true).resolve(
1496            PathBuf::from("/project"),
1497            OutputFormat::Human,
1498            1,
1499            true,
1500            true,
1501            None,
1502        );
1503        assert_eq!(resolved.rules.unused_files, Severity::Error);
1504        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1505        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1506    }
1507
1508    #[test]
1509    fn resolve_non_production_keeps_dev_deps_default() {
1510        let resolved = make_config(false).resolve(
1511            PathBuf::from("/project"),
1512            OutputFormat::Human,
1513            1,
1514            true,
1515            true,
1516            None,
1517        );
1518        assert_eq!(
1519            resolved.rules.unused_dev_dependencies,
1520            Severity::Warn,
1521            "non-production should keep default severity"
1522        );
1523        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1524    }
1525
1526    #[test]
1527    fn resolve_production_flag_stored() {
1528        let resolved = make_config(true).resolve(
1529            PathBuf::from("/project"),
1530            OutputFormat::Human,
1531            1,
1532            true,
1533            true,
1534            None,
1535        );
1536        assert!(resolved.production);
1537
1538        let resolved2 = make_config(false).resolve(
1539            PathBuf::from("/project"),
1540            OutputFormat::Human,
1541            1,
1542            true,
1543            true,
1544            None,
1545        );
1546        assert!(!resolved2.production);
1547    }
1548
1549    #[test]
1550    fn resolve_default_ignores_node_modules() {
1551        let resolved = make_config(false).resolve(
1552            PathBuf::from("/project"),
1553            OutputFormat::Human,
1554            1,
1555            true,
1556            true,
1557            None,
1558        );
1559        assert!(
1560            resolved
1561                .ignore_patterns
1562                .is_match("node_modules/lodash/index.js")
1563        );
1564        assert!(
1565            resolved
1566                .ignore_patterns
1567                .is_match("packages/a/node_modules/react/index.js")
1568        );
1569    }
1570
1571    #[test]
1572    fn resolve_default_ignores_dist() {
1573        let resolved = make_config(false).resolve(
1574            PathBuf::from("/project"),
1575            OutputFormat::Human,
1576            1,
1577            true,
1578            true,
1579            None,
1580        );
1581        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1582        assert!(
1583            resolved
1584                .ignore_patterns
1585                .is_match("packages/ui/dist/index.js")
1586        );
1587    }
1588
1589    /// Issue #2638: source discovery maps a `GlobSet` match index back to the
1590    /// pattern text through this layout, so the split point and the order of
1591    /// the built-ins are an output contract, not an implementation detail.
1592    /// A future reordering of `compile_ignore_patterns` fails here first.
1593    #[test]
1594    fn the_compiled_ignore_union_puts_user_patterns_first_and_the_built_ins_in_order() {
1595        let mut config = make_config(false);
1596        config.ignore_patterns = vec!["vendor/**".to_owned(), "legacy/**".to_owned()];
1597        let resolved = config.resolve(
1598            PathBuf::from("/project"),
1599            OutputFormat::Human,
1600            1,
1601            true,
1602            true,
1603            None,
1604        );
1605
1606        assert_eq!(resolved.user_ignore_pattern_count, 2);
1607        assert_eq!(
1608            resolved.ignore_patterns.len(),
1609            2 + DEFAULT_IGNORE_PATTERNS.len(),
1610            "the union only ever adds"
1611        );
1612        assert_eq!(
1613            resolved.ignore_patterns.matches("vendor/a.ts"),
1614            vec![0],
1615            "a user pattern keeps its configured index"
1616        );
1617        for (offset, pattern) in DEFAULT_IGNORE_PATTERNS.iter().enumerate() {
1618            let sample = match *pattern {
1619                "**/node_modules/**" => "node_modules/react/index.js",
1620                "**/dist/**" => "dist/a.ts",
1621                "**/build/**" => "build/a.ts",
1622                "**/.git/**" => ".git/hooks/a.js",
1623                "**/coverage/**" => "coverage/a.ts",
1624                "**/*.min.js" => "a.min.js",
1625                "**/*.min.mjs" => "a.min.mjs",
1626                "**/*.min.cjs" => "a.min.cjs",
1627                "**/*.bundle.js" => "a.bundle.js",
1628                other => panic!("no sample path for built-in ignore {other}"),
1629            };
1630            assert_eq!(
1631                resolved.ignore_patterns.matches(sample),
1632                vec![resolved.user_ignore_pattern_count + offset],
1633                "{pattern} must sit at its DEFAULT_IGNORE_PATTERNS offset"
1634            );
1635        }
1636    }
1637
1638    /// A file both a user pattern and a built-in match reports the user index
1639    /// first, which is how discovery tells an explicit project choice from a
1640    /// built-in the user never asked for (issue #2638).
1641    #[test]
1642    fn a_user_pattern_that_overlaps_a_built_in_matches_at_the_lower_index() {
1643        let mut config = make_config(false);
1644        config.ignore_patterns = vec!["dist/**".to_owned()];
1645        let resolved = config.resolve(
1646            PathBuf::from("/project"),
1647            OutputFormat::Human,
1648            1,
1649            true,
1650            true,
1651            None,
1652        );
1653
1654        let matches = resolved.ignore_patterns.matches("dist/a.ts");
1655        assert_eq!(matches.first(), Some(&0));
1656        assert!(matches.len() > 1, "the built-in still matches too");
1657    }
1658
1659    #[test]
1660    fn resolve_default_ignores_build_at_any_depth() {
1661        let resolved = make_config(false).resolve(
1662            PathBuf::from("/project"),
1663            OutputFormat::Human,
1664            1,
1665            true,
1666            true,
1667            None,
1668        );
1669        assert!(
1670            resolved.ignore_patterns.is_match("build/output.js"),
1671            "root build/ should be ignored"
1672        );
1673        assert!(
1674            resolved.ignore_patterns.is_match("src/build/helper.ts"),
1675            "nested build/ should be ignored, like dist/ and coverage/"
1676        );
1677        assert!(
1678            resolved
1679                .ignore_patterns
1680                .is_match("projects/app/build/index.js"),
1681            "build/ inside a workspace package should be ignored"
1682        );
1683    }
1684
1685    #[test]
1686    fn resolve_default_ignores_match_build_only_as_a_whole_segment() {
1687        let resolved = make_config(false).resolve(
1688            PathBuf::from("/project"),
1689            OutputFormat::Human,
1690            1,
1691            true,
1692            true,
1693            None,
1694        );
1695        assert!(!resolved.ignore_patterns.is_match("src/build.ts"));
1696        assert!(!resolved.ignore_patterns.is_match("src/rebuild/helper.ts"));
1697        assert!(!resolved.ignore_patterns.is_match("src/buildings/a.ts"));
1698        assert!(!resolved.ignore_patterns.is_match("src/prebuild/a.ts"));
1699    }
1700
1701    /// A workspace package directory named `build` keeps its entry in workspace
1702    /// discovery, because a declared member holding a manifest survives
1703    /// `ignorePatterns`, but everything inside it is filtered out: the source
1704    /// files never reach the walker in `crates/core/src/discover/walk.rs`, and
1705    /// the manifest never reaches the dependency filter in
1706    /// `crates/core/src/analyze/unused_deps.rs`. Both consumers read this one
1707    /// globset, so pin both paths here and keep the consequence a deliberate
1708    /// choice rather than a documentation guess.
1709    #[test]
1710    fn resolve_default_ignores_cover_a_workspace_package_named_build() {
1711        let resolved = make_config(false).resolve(
1712            PathBuf::from("/project"),
1713            OutputFormat::Human,
1714            1,
1715            true,
1716            true,
1717            None,
1718        );
1719        assert!(
1720            resolved
1721                .ignore_patterns
1722                .is_match("packages/build/package.json"),
1723            "the manifest stops contributing unused-dependency findings"
1724        );
1725        assert!(
1726            resolved
1727                .ignore_patterns
1728                .is_match("packages/build/src/index.ts"),
1729            "the package's source stops being analyzed entirely"
1730        );
1731        assert!(
1732            resolved
1733                .ignore_patterns
1734                .is_match("packages/build/src/nested/deep.ts"),
1735            "including source below the package's own subdirectories"
1736        );
1737    }
1738
1739    /// A framework config inside a nested `build/` directory is filtered out of
1740    /// discovery with everything else under the segment, so the path aliases it
1741    /// declares are lost and imports through them are reported as unlisted
1742    /// dependencies. Pinned so that consequence is a recorded choice.
1743    #[test]
1744    fn resolve_default_ignores_cover_a_framework_config_inside_build() {
1745        let resolved = make_config(false).resolve(
1746            PathBuf::from("/project"),
1747            OutputFormat::Human,
1748            1,
1749            true,
1750            true,
1751            None,
1752        );
1753        assert!(
1754            resolved
1755                .ignore_patterns
1756                .is_match("app/build/webpack.config.js")
1757        );
1758    }
1759
1760    #[test]
1761    fn resolve_default_ignores_minified_files() {
1762        let resolved = make_config(false).resolve(
1763            PathBuf::from("/project"),
1764            OutputFormat::Human,
1765            1,
1766            true,
1767            true,
1768            None,
1769        );
1770        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1771        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1772        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
1773        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
1774        assert!(
1775            resolved
1776                .ignore_patterns
1777                .is_match("src/vendor/app.bundle.js")
1778        );
1779        // Hand-written source with a similar name stays analyzed.
1780        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
1781        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
1782    }
1783
1784    #[test]
1785    fn resolve_max_file_size_bytes_default_and_unlimited() {
1786        // Unset keeps the built-in default.
1787        assert_eq!(
1788            resolve_max_file_size_bytes(None),
1789            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1790        );
1791        // `0` means no limit.
1792        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
1793        // Any other value is that many megabytes in bytes.
1794        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
1795        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
1796    }
1797
1798    #[test]
1799    fn resolve_sets_default_max_file_size() {
1800        let resolved = make_config(false).resolve(
1801            PathBuf::from("/project"),
1802            OutputFormat::Human,
1803            1,
1804            true,
1805            true,
1806            None,
1807        );
1808        assert_eq!(
1809            resolved.max_file_size_bytes,
1810            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
1811        );
1812    }
1813
1814    #[test]
1815    fn resolve_default_ignores_git() {
1816        let resolved = make_config(false).resolve(
1817            PathBuf::from("/project"),
1818            OutputFormat::Human,
1819            1,
1820            true,
1821            true,
1822            None,
1823        );
1824        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1825    }
1826
1827    #[test]
1828    fn resolve_default_ignores_coverage() {
1829        let resolved = make_config(false).resolve(
1830            PathBuf::from("/project"),
1831            OutputFormat::Human,
1832            1,
1833            true,
1834            true,
1835            None,
1836        );
1837        assert!(
1838            resolved
1839                .ignore_patterns
1840                .is_match("coverage/lcov-report/index.js")
1841        );
1842    }
1843
1844    #[test]
1845    fn resolve_source_files_not_ignored_by_default() {
1846        let resolved = make_config(false).resolve(
1847            PathBuf::from("/project"),
1848            OutputFormat::Human,
1849            1,
1850            true,
1851            true,
1852            None,
1853        );
1854        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1855        assert!(
1856            !resolved
1857                .ignore_patterns
1858                .is_match("src/components/Button.tsx")
1859        );
1860        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1861    }
1862
1863    #[test]
1864    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1865        let mut config = make_config(false);
1866        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1867        let resolved = config.resolve(
1868            PathBuf::from("/project"),
1869            OutputFormat::Human,
1870            1,
1871            true,
1872            true,
1873            None,
1874        );
1875        assert!(
1876            resolved
1877                .ignore_patterns
1878                .is_match("src/__generated__/types.ts")
1879        );
1880        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1881    }
1882
1883    #[test]
1884    fn resolve_normalizes_leading_dot_ignore_patterns() {
1885        let mut config = make_config(false);
1886        config.ignore_patterns = vec!["./src/generated/**".to_string()];
1887        let resolved = config.resolve(
1888            PathBuf::from("/project"),
1889            OutputFormat::Human,
1890            1,
1891            true,
1892            true,
1893            None,
1894        );
1895
1896        assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
1897        assert!(
1898            !resolved
1899                .ignore_patterns
1900                .is_match("./src/generated/client.ts")
1901        );
1902    }
1903
1904    #[test]
1905    fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
1906        let mut config = make_config(false);
1907        config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
1908        let resolved = config.resolve(
1909            PathBuf::from("/project"),
1910            OutputFormat::Human,
1911            1,
1912            true,
1913            true,
1914            None,
1915        );
1916
1917        assert!(
1918            resolved
1919                .ignore_unresolved_imports
1920                .iter()
1921                .any(|matcher| matcher.is_match("src/generated/client"))
1922        );
1923        assert!(
1924            !resolved
1925                .ignore_unresolved_imports
1926                .iter()
1927                .any(|matcher| matcher.is_match("./src/generated/client"))
1928        );
1929    }
1930
1931    #[test]
1932    fn resolve_passes_through_entry_patterns() {
1933        let mut config = make_config(false);
1934        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1935        let resolved = config.resolve(
1936            PathBuf::from("/project"),
1937            OutputFormat::Human,
1938            1,
1939            true,
1940            true,
1941            None,
1942        );
1943        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1944    }
1945
1946    #[test]
1947    fn resolve_passes_through_ignore_dependencies() {
1948        let mut config = make_config(false);
1949        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1950        let resolved = config.resolve(
1951            PathBuf::from("/project"),
1952            OutputFormat::Human,
1953            1,
1954            true,
1955            true,
1956            None,
1957        );
1958        assert_eq!(
1959            resolved.ignore_dependencies,
1960            vec!["postcss", "autoprefixer"]
1961        );
1962    }
1963
1964    #[test]
1965    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1966        let mut config = make_config(false);
1967        config.ignore_unresolved_imports = vec![
1968            "@example/icons".to_string(),
1969            "@example/icons/**".to_string(),
1970            "../generated/**".to_string(),
1971        ];
1972        let resolved = config.resolve(
1973            PathBuf::from("/project"),
1974            OutputFormat::Human,
1975            1,
1976            true,
1977            true,
1978            None,
1979        );
1980
1981        assert!(
1982            resolved
1983                .ignore_unresolved_imports
1984                .iter()
1985                .any(|matcher| matcher.is_match("@example/icons"))
1986        );
1987        assert!(
1988            resolved
1989                .ignore_unresolved_imports
1990                .iter()
1991                .any(|matcher| matcher.is_match("@example/icons/metadata"))
1992        );
1993        assert!(
1994            resolved
1995                .ignore_unresolved_imports
1996                .iter()
1997                .any(|matcher| matcher.is_match("../generated/client"))
1998        );
1999    }
2000
2001    #[test]
2002    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
2003        let mut config = make_config(false);
2004        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
2005        let resolved = config.resolve(
2006            PathBuf::from("/project"),
2007            OutputFormat::Human,
2008            1,
2009            true,
2010            true,
2011            None,
2012        );
2013
2014        assert!(
2015            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
2016            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
2017        );
2018        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
2019    }
2020
2021    #[test]
2022    fn resolve_sets_cache_dir() {
2023        let resolved = make_config(false).resolve(
2024            PathBuf::from("/my/project"),
2025            OutputFormat::Human,
2026            1,
2027            true,
2028            true,
2029            None,
2030        );
2031        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
2032    }
2033
2034    #[test]
2035    fn resolve_uses_relative_configured_cache_dir_from_root() {
2036        let config = FallowConfig {
2037            cache: crate::CacheConfig {
2038                dir: Some(PathBuf::from(".cache/fallow")),
2039                ..Default::default()
2040            },
2041            ..make_config(false)
2042        };
2043        let resolved = config.resolve(
2044            PathBuf::from("/my/project"),
2045            OutputFormat::Human,
2046            1,
2047            false,
2048            true,
2049            None,
2050        );
2051        assert_eq!(
2052            resolved.cache_dir,
2053            PathBuf::from("/my/project/.cache/fallow")
2054        );
2055    }
2056
2057    #[test]
2058    fn resolve_keeps_absolute_configured_cache_dir() {
2059        let config = FallowConfig {
2060            cache: crate::CacheConfig {
2061                dir: Some(PathBuf::from("/tmp/fallow-cache")),
2062                ..Default::default()
2063            },
2064            ..make_config(false)
2065        };
2066        let resolved = config.resolve(
2067            PathBuf::from("/my/project"),
2068            OutputFormat::Human,
2069            1,
2070            false,
2071            true,
2072            None,
2073        );
2074        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
2075    }
2076
2077    #[test]
2078    fn resolve_passes_through_thread_count() {
2079        let resolved = make_config(false).resolve(
2080            PathBuf::from("/project"),
2081            OutputFormat::Human,
2082            8,
2083            true,
2084            true,
2085            None,
2086        );
2087        assert_eq!(resolved.threads, 8);
2088    }
2089
2090    #[test]
2091    fn resolve_passes_through_quiet_flag() {
2092        let resolved = make_config(false).resolve(
2093            PathBuf::from("/project"),
2094            OutputFormat::Human,
2095            1,
2096            true,
2097            false,
2098            None,
2099        );
2100        assert!(!resolved.quiet);
2101
2102        let resolved2 = make_config(false).resolve(
2103            PathBuf::from("/project"),
2104            OutputFormat::Human,
2105            1,
2106            true,
2107            true,
2108            None,
2109        );
2110        assert!(resolved2.quiet);
2111    }
2112
2113    #[test]
2114    fn resolve_passes_through_no_cache_flag() {
2115        let resolved_no_cache = make_config(false).resolve(
2116            PathBuf::from("/project"),
2117            OutputFormat::Human,
2118            1,
2119            true,
2120            true,
2121            None,
2122        );
2123        assert!(resolved_no_cache.no_cache);
2124
2125        let resolved_with_cache = make_config(false).resolve(
2126            PathBuf::from("/project"),
2127            OutputFormat::Human,
2128            1,
2129            false,
2130            true,
2131            None,
2132        );
2133        assert!(!resolved_with_cache.no_cache);
2134    }
2135
2136    #[test]
2137    #[should_panic(expected = "validated at config load time")]
2138    fn resolve_panics_on_unvalidated_invalid_override_glob() {
2139        let mut config = make_config(false);
2140        config.overrides = vec![ConfigOverride {
2141            files: vec!["[invalid".to_string()],
2142            rules: PartialRulesConfig {
2143                unused_files: Some(Severity::Off),
2144                ..Default::default()
2145            },
2146        }];
2147        let _ = config.resolve(
2148            PathBuf::from("/project"),
2149            OutputFormat::Human,
2150            1,
2151            true,
2152            true,
2153            None,
2154        );
2155    }
2156
2157    #[test]
2158    fn resolve_override_with_empty_files_skipped() {
2159        let mut config = make_config(false);
2160        config.overrides = vec![ConfigOverride {
2161            files: vec![],
2162            rules: PartialRulesConfig {
2163                unused_files: Some(Severity::Off),
2164                ..Default::default()
2165            },
2166        }];
2167        let resolved = config.resolve(
2168            PathBuf::from("/project"),
2169            OutputFormat::Human,
2170            1,
2171            true,
2172            true,
2173            None,
2174        );
2175        assert!(
2176            resolved.overrides.is_empty(),
2177            "override with no file patterns should be skipped"
2178        );
2179    }
2180
2181    #[test]
2182    fn resolve_multiple_valid_overrides() {
2183        let mut config = make_config(false);
2184        config.overrides = vec![
2185            ConfigOverride {
2186                files: vec!["*.test.ts".to_string()],
2187                rules: PartialRulesConfig {
2188                    unused_exports: Some(Severity::Off),
2189                    ..Default::default()
2190                },
2191            },
2192            ConfigOverride {
2193                files: vec!["*.stories.tsx".to_string()],
2194                rules: PartialRulesConfig {
2195                    unused_files: Some(Severity::Off),
2196                    ..Default::default()
2197                },
2198            },
2199        ];
2200        let resolved = config.resolve(
2201            PathBuf::from("/project"),
2202            OutputFormat::Human,
2203            1,
2204            true,
2205            true,
2206            None,
2207        );
2208        assert_eq!(resolved.overrides.len(), 2);
2209    }
2210
2211    #[test]
2212    fn ignore_export_rule_deserialize() {
2213        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
2214        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2215        assert_eq!(rule.file, "src/types/*.ts");
2216        assert_eq!(rule.exports, vec!["*"]);
2217    }
2218
2219    #[test]
2220    fn ignore_export_rule_specific_exports() {
2221        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
2222        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2223        assert_eq!(rule.exports.len(), 3);
2224        assert!(rule.exports.contains(&"FOO".to_string()));
2225    }
2226
2227    mod proptests {
2228        use super::*;
2229        use proptest::prelude::*;
2230
2231        fn arb_resolved_config(production: bool) -> ResolvedConfig {
2232            make_config(production).resolve(
2233                PathBuf::from("/project"),
2234                OutputFormat::Human,
2235                1,
2236                true,
2237                true,
2238                None,
2239            )
2240        }
2241
2242        proptest! {
2243            /// Resolved config always has non-empty ignore patterns (defaults are always added).
2244            #[test]
2245            fn resolved_config_has_default_ignores(production in any::<bool>()) {
2246                let resolved = arb_resolved_config(production);
2247                prop_assert!(
2248                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2249                    "Default ignore should match node_modules"
2250                );
2251                prop_assert!(
2252                    resolved.ignore_patterns.is_match("dist/bundle.js"),
2253                    "Default ignore should match dist"
2254                );
2255            }
2256
2257            /// Production mode always forces dev and optional deps to Off.
2258            #[test]
2259            fn production_forces_dev_deps_off(_unused in Just(())) {
2260                let resolved = arb_resolved_config(true);
2261                prop_assert_eq!(
2262                    resolved.rules.unused_dev_dependencies,
2263                    Severity::Off,
2264                    "Production should force unused_dev_dependencies off"
2265                );
2266                prop_assert_eq!(
2267                    resolved.rules.unused_optional_dependencies,
2268                    Severity::Off,
2269                    "Production should force unused_optional_dependencies off"
2270                );
2271            }
2272
2273            /// Non-production mode preserves default severity for dev deps.
2274            #[test]
2275            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
2276                let resolved = arb_resolved_config(false);
2277                prop_assert_eq!(
2278                    resolved.rules.unused_dev_dependencies,
2279                    Severity::Warn,
2280                    "Non-production should keep default dev dep severity"
2281                );
2282            }
2283
2284            /// Default cache dir is root/.fallow.
2285            #[test]
2286            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
2287                let root = PathBuf::from(format!("/project/{dir_suffix}"));
2288                let expected_cache = root.join(".fallow");
2289                let resolved = make_config(false).resolve(
2290                    root,
2291                    OutputFormat::Human,
2292                    1,
2293                    true,
2294                    true,
2295                    None,
2296                );
2297                prop_assert_eq!(
2298                    resolved.cache_dir, expected_cache,
2299                    "Default cache dir should be root/.fallow"
2300                );
2301            }
2302
2303            /// Thread count is always passed through exactly.
2304            #[test]
2305            fn threads_passed_through(threads in 1..64usize) {
2306                let resolved = make_config(false).resolve(
2307                    PathBuf::from("/project"),
2308                    OutputFormat::Human,
2309                    threads,
2310                    true,
2311                    true, None,
2312                );
2313                prop_assert_eq!(
2314                    resolved.threads, threads,
2315                    "Thread count should be passed through"
2316                );
2317            }
2318
2319            /// Custom ignore patterns are merged with defaults, not replacing them.
2320            /// Uses a pattern regex that cannot match node_modules paths, so the
2321            /// assertion proves the default pattern is what provides the match.
2322            #[test]
2323            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2324                let mut config = make_config(false);
2325                config.ignore_patterns = vec![pattern];
2326                let resolved = config.resolve(
2327                    PathBuf::from("/project"),
2328                    OutputFormat::Human,
2329                    1,
2330                    true,
2331                    true, None,
2332                );
2333                prop_assert!(
2334                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2335                    "Default node_modules ignore should still be active"
2336                );
2337            }
2338        }
2339    }
2340
2341    #[test]
2342    fn resolve_expands_boundary_preset() {
2343        use crate::config::boundaries::BoundaryPreset;
2344
2345        let mut config = make_config(false);
2346        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2347        let resolved = config.resolve(
2348            PathBuf::from("/project"),
2349            OutputFormat::Human,
2350            1,
2351            true,
2352            true,
2353            None,
2354        );
2355        assert_eq!(resolved.boundaries.zones.len(), 3);
2356        assert_eq!(resolved.boundaries.rules.len(), 3);
2357        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2358        assert_eq!(
2359            resolved.boundaries.classify_zone("src/adapters/http.ts"),
2360            Some("adapters")
2361        );
2362    }
2363
2364    #[test]
2365    fn resolve_boundary_preset_with_user_override() {
2366        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2367
2368        let mut config = make_config(false);
2369        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2370        config.boundaries.zones = vec![BoundaryZone {
2371            name: "domain".to_string(),
2372            patterns: vec!["src/core/**".to_string()],
2373            auto_discover: vec![],
2374            root: None,
2375        }];
2376        let resolved = config.resolve(
2377            PathBuf::from("/project"),
2378            OutputFormat::Human,
2379            1,
2380            true,
2381            true,
2382            None,
2383        );
2384        assert_eq!(resolved.boundaries.zones.len(), 3);
2385        assert_eq!(
2386            resolved.boundaries.classify_zone("src/core/user.ts"),
2387            Some("domain")
2388        );
2389        assert_eq!(
2390            resolved.boundaries.classify_zone("src/domain/user.ts"),
2391            None
2392        );
2393    }
2394
2395    #[test]
2396    fn resolve_no_preset_unchanged() {
2397        let config = make_config(false);
2398        let resolved = config.resolve(
2399            PathBuf::from("/project"),
2400            OutputFormat::Human,
2401            1,
2402            true,
2403            true,
2404            None,
2405        );
2406        assert!(resolved.boundaries.is_empty());
2407    }
2408}