Skip to main content

fallow_config/config/
resolution.rs

1use std::collections::{BTreeMap, hash_map::DefaultHasher};
2use std::ffi::{OsStr, OsString};
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use globset::{Glob, GlobMatcher};
8use rustc_hash::FxHashSet;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use super::boundaries::ResolvedBoundaryConfig;
13use super::duplicates_config::DuplicatesConfig;
14use super::entry_effect::EntryEffectRecord;
15use super::flags::FlagsConfig;
16use super::format::OutputFormat;
17use super::glob_validation::compile_path_glob;
18use super::health::HealthConfig;
19use super::ignore_patterns::compile_ignore_patterns;
20use super::mutation::MutationConfig;
21use super::resolve::ResolveConfig;
22use super::rules::{PartialRulesConfig, RulesConfig, Severity};
23use super::similar_code::SimilarCodeConfig;
24use super::used_class_members::UsedClassMemberRule;
25use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
26
27use super::{
28    BoundaryConfig, FallowConfig, FindingIgnoreMatcher, IgnoreDependencyMatcher,
29    IgnoreExportsUsedInFileConfig, IgnorePatternSet, ProductionConfig, SecurityConfig,
30    TypeAwareConfig,
31};
32
33/// Process-local dedup state for inter-file rule warnings.
34static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
35
36/// Stable hash of `(rule_name, sorted glob list)`.
37fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
38    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
39    sorted.sort_unstable();
40    let mut hasher = DefaultHasher::new();
41    rule_name.hash(&mut hasher);
42    for s in &sorted {
43        s.hash(&mut hasher);
44    }
45    hasher.finish()
46}
47
48/// Returns `true` if this warning has not yet fired in the current process.
49fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
50    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
51    let key = inter_file_warn_key(rule_name, files);
52    seen.lock().map_or(true, |mut set| set.insert(key))
53}
54
55#[cfg(test)]
56fn reset_inter_file_warn_dedup_for_test() {
57    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
58        && let Ok(mut set) = seen.lock()
59    {
60        set.clear();
61    }
62}
63
64/// Rule for ignoring specific exports.
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
66#[serde(deny_unknown_fields)]
67pub struct IgnoreExportRule {
68    /// Glob pattern for files.
69    /// A single `*` stays within one path segment; use `**` to match across directories.
70    pub file: String,
71    /// Export names to ignore (`*` for all).
72    pub exports: Vec<String>,
73}
74
75/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
76#[derive(Debug, Clone)]
77pub struct CompiledIgnoreExportRule {
78    /// Pre-compiled matcher for the rule's `file` glob.
79    pub matcher: globset::GlobMatcher,
80    /// Export names to ignore (`*` for all), copied from the raw rule.
81    pub exports: Vec<String>,
82}
83
84/// Rule for suppressing an `unresolved-catalog-reference` finding.
85#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
86#[serde(deny_unknown_fields)]
87pub struct IgnoreCatalogReferenceRule {
88    /// 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).
89    pub package: String,
90    /// 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.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub catalog: Option<String>,
93    /// 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. A single `*` stays within one path segment; use `**` to match across directories.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub consumer: Option<String>,
96}
97
98/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
99#[derive(Debug, Clone)]
100pub struct CompiledIgnoreCatalogReferenceRule {
101    /// Exact package name the rule suppresses, compared by string equality.
102    pub package: String,
103    /// Optional exact catalog-name filter; `None` matches any catalog.
104    pub catalog: Option<String>,
105    /// Optional pre-compiled glob over the consuming workspace `package.json`
106    /// path; `None` matches any consumer.
107    pub consumer_matcher: Option<globset::GlobMatcher>,
108}
109
110impl CompiledIgnoreCatalogReferenceRule {
111    /// Whether this rule suppresses an `unresolved-catalog-reference` finding.
112    #[must_use]
113    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
114        if self.package != package {
115            return false;
116        }
117        if let Some(catalog_filter) = &self.catalog
118            && catalog_filter != catalog
119        {
120            return false;
121        }
122        if let Some(matcher) = &self.consumer_matcher
123            && !matcher.is_match(consumer_path)
124        {
125            return false;
126        }
127        true
128    }
129}
130
131/// Rule for suppressing dependency-override findings.
132#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
133#[serde(deny_unknown_fields)]
134pub struct IgnoreDependencyOverrideRule {
135    /// 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).
136    pub package: String,
137    /// 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.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub source: Option<String>,
140}
141
142/// `IgnoreDependencyOverrideRule` ready for matching.
143#[derive(Debug, Clone)]
144pub struct CompiledIgnoreDependencyOverrideRule {
145    /// Exact target package name the rule suppresses.
146    pub package: String,
147    /// Optional declaring-file filter (`"pnpm-workspace.yaml"` or
148    /// `"package.json"`); `None` matches either source.
149    pub source: Option<String>,
150}
151
152impl CompiledIgnoreDependencyOverrideRule {
153    /// Whether this rule suppresses a dependency-override finding.
154    #[must_use]
155    pub fn matches(&self, package: &str, source_label: &str) -> bool {
156        if self.package != package {
157            return false;
158        }
159        if let Some(source_filter) = &self.source
160            && source_filter != source_label
161        {
162            return false;
163        }
164        true
165    }
166}
167
168/// Per-file override entry.
169#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct ConfigOverride {
172    /// 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). A single `*` stays within one path segment; use `**` to match across directories.
173    pub files: Vec<String>,
174    /// 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).
175    #[serde(default)]
176    pub rules: PartialRulesConfig,
177}
178
179/// Resolved override with pre-compiled glob matchers.
180#[derive(Debug, Clone)]
181pub struct ResolvedOverride {
182    /// Pre-compiled matchers for the entry's `files` globs; the override
183    /// applies to a file when any matcher matches.
184    pub matchers: Vec<globset::GlobMatcher>,
185    /// Partial severity map applied to matching files.
186    pub rules: PartialRulesConfig,
187}
188
189/// Which revision an analysis pass describes.
190///
191/// `fallow audit --base <ref>` analyzes the base revision in an isolated
192/// worktree in addition to the working tree. Diagnostics raised while the base
193/// pass runs must say which revision they came from, otherwise a base-only
194/// condition reads as a defect in the current configuration (issue #2013).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum AnalysisSnapshot {
197    /// The working tree, which is what every command analyzes by default.
198    #[default]
199    Current,
200    /// The base revision analyzed by `audit --base`.
201    Base,
202}
203
204impl AnalysisSnapshot {
205    /// True when this pass analyzes the `audit --base` revision.
206    #[must_use]
207    pub const fn is_base(self) -> bool {
208        matches!(self, Self::Base)
209    }
210}
211
212/// Fully resolved configuration with all globs pre-compiled.
213#[derive(Debug, Clone)]
214pub struct ResolvedConfig {
215    /// Project root every analysis path resolves against.
216    pub root: PathBuf,
217    /// Manual entry-point globs from the `entry` config key, matched against
218    /// discovered files on top of plugin- and manifest-derived entries.
219    pub entry_patterns: Vec<String>,
220    /// Compiled union of user `ignorePatterns` and the built-in default
221    /// ignores (`node_modules`, `dist`, minified bundles, ...); matching files
222    /// are excluded from discovery entirely, unless a `!` entry in
223    /// `ignorePatterns` lifts them (issue #2940).
224    pub ignore_patterns: IgnorePatternSet,
225    /// How many globs at the FRONT of [`Self::ignore_patterns`] came from the
226    /// user's `ignorePatterns` (the `!` exceptions are not globs of this
227    /// union and do not count). The rest, in order, are
228    /// [`DEFAULT_IGNORE_PATTERNS`].
229    ///
230    /// Source discovery needs the split to answer "which pattern removed this
231    /// file": a match index below this count is the project's own explicit
232    /// choice and is reported nowhere, while an index at or above it names a
233    /// built-in the user never asked for (issue #2638).
234    pub user_ignore_pattern_count: usize,
235    /// Which [`Self::entry_patterns`] matched a file that no other runtime
236    /// entry source declares, in the latest entry point discovery (issue
237    /// #3824).
238    pub entry_pattern_effect: EntryEffectRecord,
239    /// Post-analysis finding-path matcher built from `ignoreFindings`; hides
240    /// findings without removing files from the module graph.
241    pub ignore_findings: FindingIgnoreMatcher,
242    /// Output format for this run, passed through from the CLI at resolve time.
243    pub output: OutputFormat,
244    /// Cache directory: `cache.dir` resolved against the root, or the default
245    /// `<root>/.fallow`.
246    pub cache_dir: PathBuf,
247    /// Worker-thread count, passed through from the CLI at resolve time.
248    pub threads: usize,
249    /// When true, skip reading and writing the persistent caches for this run.
250    pub no_cache: bool,
251    /// Extraction-cache size ceiling in megabytes (`None` = no ceiling), from
252    /// the CLI override, `FALLOW_CACHE_MAX_SIZE`, or `cache.maxSizeMb`.
253    pub cache_max_size_mb: Option<u32>,
254    /// Hash over extraction-affecting config (the sorted external plugin
255    /// names), mixed into cache keys so plugin changes invalidate cached
256    /// extractions instead of serving stale results.
257    pub cache_config_hash: u64,
258    /// Package names and package-name globs excluded from both
259    /// unused-dependency and unlisted-dependency detection.
260    pub ignore_dependencies: IgnoreDependencyMatcher,
261    /// Command names whose file arguments do not become entry points; `*`
262    /// matches every command.
263    pub ignore_command_entries: Vec<String>,
264    /// Compiled globs matched against raw import specifiers (not filesystem
265    /// paths) whose `unresolved-import` findings are suppressed.
266    pub ignore_unresolved_imports: Vec<GlobMatcher>,
267    /// Raw `ignoreExports` rules as configured, kept alongside the compiled
268    /// form for surfaces that need the original glob text (config editing,
269    /// diagnostics).
270    pub ignore_export_rules: Vec<IgnoreExportRule>,
271    /// `ignoreExports` rules with their file globs pre-compiled for matching.
272    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
273    /// `ignoreCatalogReferences` rules with consumer globs pre-compiled.
274    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
275    /// `ignoreDependencyOverrides` rules ready for matching.
276    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
277    /// Same-file-use suppression setting for `unused-export`.
278    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
279    /// Class-member names, globs, or heritage-scoped rules treated as
280    /// framework-used and exempt from `unused-class-member`.
281    pub used_class_members: Vec<UsedClassMemberRule>,
282    /// Decorator names stripped of the automatic `unused-class-member`
283    /// exemption that decorated members otherwise receive.
284    pub ignore_decorators: Vec<String>,
285    /// Compiled regex matched against each declared component prop's local
286    /// destructure binding name; a matching prop is exempted from
287    /// `unused-component-props`. `None` when `unusedComponentProps.ignorePattern`
288    /// is unset. Compiled from the validated raw pattern in [`Self::resolve`].
289    pub unused_component_props_ignore: Option<regex::Regex>,
290    /// Options for the `circular-dependencies` rule, passed through unchanged.
291    pub circular_dependencies: super::CircularDependenciesConfig,
292    /// Clone-detection settings, passed through unchanged.
293    pub duplicates: DuplicatesConfig,
294    /// Explicit similar-code candidate settings, passed through unchanged.
295    pub similar_code: SimilarCodeConfig,
296    /// Explicit mutation-testing settings, passed through unchanged.
297    pub mutation: MutationConfig,
298    /// Health and complexity thresholds, passed through unchanged.
299    pub health: HealthConfig,
300    /// TypeScript semantic-analysis opt-in, passed through unchanged.
301    pub type_aware: TypeAwareConfig,
302    /// Per-rule severities with production-mode adjustments applied: when
303    /// [`Self::production`] is set, `unused-dev-dependencies` and
304    /// `unused-optional-dependencies` are forced to `off`.
305    pub rules: RulesConfig,
306    /// Resolved architecture boundaries: preset expanded (honoring the
307    /// tsconfig `rootDir`), auto-discovered zones added, and rules validated.
308    pub boundaries: ResolvedBoundaryConfig,
309    /// Rule packs loaded from the `rulePacks` config key, in config order.
310    /// Validated at config load (`load_rule_packs` is also the validation
311    /// gate in the CLI and programmatic entry points); a pack that fails to
312    /// load here is skipped with a `tracing::error!` as defense in depth.
313    pub rule_packs: Vec<crate::rule_pack::RulePackDef>,
314    /// Source paths from the `rulePacks` config key, index-aligned with
315    /// [`Self::rule_packs`] when every configured pack loaded successfully.
316    pub rule_pack_sources: Vec<PathBuf>,
317    /// Production mode for this analysis pass: test/spec/story/dev files are
318    /// excluded from discovery. Out of [`FallowConfig::resolve`] this is the
319    /// global `production` bool ([`super::ProductionConfig::global`]); the
320    /// per-analysis object form and CLI/env overrides are applied post-resolve.
321    pub production: bool,
322    /// Quiet mode from the CLI: suppress non-essential progress and warning
323    /// output.
324    pub quiet: bool,
325    /// External plugin definitions: inline `framework` entries plus those
326    /// discovered from `plugins` paths, `.fallow/plugins/`, and root
327    /// `fallow-plugin-*` files (first occurrence of a name wins).
328    pub external_plugins: Vec<ExternalPluginDef>,
329    /// Globs for files loaded dynamically at runtime; matching files are
330    /// seeded as entry points so they stay reachable.
331    pub dynamically_loaded: Vec<String>,
332    /// Template extensions from `markupExtensions`, in addition to
333    /// [`super::DEFAULT_MARKUP_EXTENSIONS`]: normalized, sorted, and
334    /// deduplicated. Their files count as CSS class references only.
335    pub markup_extensions: Vec<String>,
336    /// Per-file severity overrides with globs pre-compiled, in config order.
337    pub overrides: Vec<ResolvedOverride>,
338    /// Authored Git baseline refs keyed by workspace root; validated against
339    /// discovered packages when an analysis requests package-scoped changes.
340    pub workspace_changed_since: BTreeMap<String, String>,
341    /// Workspace package root globs from `workspaces.patterns`, added to the
342    /// patterns that the manifests declare.
343    pub workspace_patterns: Vec<String>,
344    /// Saved regression baseline for `--fail-on-regression`, when embedded.
345    pub regression: Option<super::RegressionConfig>,
346    /// In-repo `fallow audit` defaults, passed through unchanged.
347    pub audit: super::AuditConfig,
348    /// Configured CODEOWNERS path override; `None` probes the standard
349    /// locations.
350    pub codeowners: Option<String>,
351    /// Workspace package names (or globs over them) whose public API is
352    /// treated as externally consumed, making their export surface a
353    /// reachability root.
354    pub public_packages: Vec<String>,
355    /// Feature-flag detection settings, passed through unchanged.
356    pub flags: FlagsConfig,
357    /// Security catalogue scoping with `requestReceivers` normalized
358    /// (trimmed, lowercased, deduplicated).
359    pub security: SecurityConfig,
360    /// `fallow fix` behavior settings, passed through unchanged.
361    pub fix: super::FixConfig,
362    /// Module-resolver settings (extra `exports` conditions), passed through
363    /// unchanged.
364    pub resolve: ResolveConfig,
365    /// When true, entry-point exports are subject to `unused-export`
366    /// detection instead of being auto-credited as used.
367    pub include_entry_exports: bool,
368    /// When true, the report keeps the export and member findings of an
369    /// unused file. When false, the rule pass hides them.
370    pub show_cascade: bool,
371    /// When true, drop Nuxt convention entry-pattern fallbacks that
372    /// `nuxt.config` does not explicitly declare; auto-import graph edges are
373    /// synthesized regardless.
374    pub auto_imports: bool,
375    /// When true, a source file that did not parse cleanly fails the run
376    /// through the `parse-error` gate. The CLI flag `--fail-on-parse-error`
377    /// arms the same gate.
378    pub fail_on_parse_error: bool,
379    /// Source files strictly larger than this many bytes are skipped at
380    /// discovery (never read, parsed, or analyzed), guarding against the
381    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
382    /// causes (issue #1086). `None` means no limit. Declaration files
383    /// (`.d.ts`/`.d.mts`/`.d.cts`) are exempt regardless of size because they
384    /// are reachability roots for global types. Defaults to
385    /// [`DEFAULT_MAX_FILE_SIZE_MB`] MB; the CLI overrides it post-resolve from
386    /// `--max-file-size` / `FALLOW_MAX_FILE_SIZE` (`0` = unlimited).
387    pub max_file_size_bytes: Option<u64>,
388    /// Which revision this analysis pass describes. Always
389    /// [`AnalysisSnapshot::Current`] out of [`FallowConfig::resolve`]; the CLI
390    /// sets [`AnalysisSnapshot::Base`] post-resolve for the isolated
391    /// `audit --base` pass so diagnostics can name the base revision.
392    pub analysis_snapshot: AnalysisSnapshot,
393    /// A stable hash of the config that decides which dead-code findings a
394    /// run reports: the merged user config after `extends`, without the keys
395    /// that only shape other commands or output, plus the loaded external
396    /// plugin and rule pack definitions. It holds no path of this machine,
397    /// so two checkouts of one commit give the same value. Settings that a
398    /// surface changes after resolution (for example `--include-entry-exports`)
399    /// are not in it; the analysis fingerprint adds them.
400    pub detection_config_digest: String,
401    /// User config path glob lists with a single `*` or `?` that matched `/`
402    /// under the old glob rule. Source discovery compares the old and the new
403    /// rule on these lists and warns for each pattern that lost matches.
404    pub legacy_glob_groups: Vec<super::LegacyGlobGroup>,
405}
406
407/// Top-level config keys that do not change which dead-code findings a run
408/// reports. The detection digest leaves them out, so a health threshold or a
409/// cache setting does not change the analysis fingerprint.
410const NON_DETECTION_CONFIG_KEYS: &[&str] = &[
411    "minimumVersion",
412    "duplicates",
413    "similarCode",
414    "mutation",
415    "health",
416    "markupExtensions",
417    "security",
418    "fix",
419    "codeowners",
420    "regression",
421    "audit",
422    "failOnParseError",
423    "cache",
424];
425
426/// Serialize `value` with every object key sorted, so a map field gives the
427/// same bytes whatever its insertion order.
428fn canonical_json(value: serde_json::Value) -> serde_json::Value {
429    match value {
430        serde_json::Value::Object(map) => {
431            let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
432            entries.sort_by(|a, b| a.0.cmp(&b.0));
433            serde_json::Value::Object(
434                entries
435                    .into_iter()
436                    .map(|(key, value)| (key, canonical_json(value)))
437                    .collect(),
438            )
439        }
440        serde_json::Value::Array(items) => {
441            serde_json::Value::Array(items.into_iter().map(canonical_json).collect())
442        }
443        other => other,
444    }
445}
446
447fn canonical_json_string<T: Serialize>(value: &T) -> String {
448    serde_json::to_value(value)
449        .map(canonical_json)
450        .map(|value| value.to_string())
451        .unwrap_or_default()
452}
453
454/// The canonical JSON of the detection-affecting user config keys.
455fn detection_config_json(config: &FallowConfig) -> String {
456    let mut value = serde_json::to_value(config).unwrap_or_default();
457    if let serde_json::Value::Object(map) = &mut value {
458        for key in NON_DETECTION_CONFIG_KEYS {
459            map.shift_remove(*key);
460        }
461    }
462    canonical_json(value).to_string()
463}
464
465/// Default per-file size ceiling (in megabytes) for source discovery. A value
466/// chosen so hand-written source effectively never reaches it while generated
467/// API clients, vendored bundles, and minified blobs do. See issue #1086.
468pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
469
470/// [`DEFAULT_MAX_FILE_SIZE_MB`] expressed in bytes.
471pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
472
473/// Convert a user-supplied megabyte ceiling into the byte limit stored on
474/// [`ResolvedConfig::max_file_size_bytes`]. `Some(0)` means "no limit"
475/// (`None`); any other `Some(n)` is `n` MB in bytes; `None` (unset) keeps the
476/// built-in [`DEFAULT_MAX_FILE_SIZE_BYTES`].
477#[must_use]
478pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
479    match max_file_size_mb {
480        None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
481        Some(0) => None,
482        Some(mb) => Some(u64::from(mb) * 1024 * 1024),
483    }
484}
485
486/// Hash the extraction-affecting configuration a persisted cache is keyed on:
487/// the external plugin names and the user flag patterns, which the parse
488/// applies. Built-in-only flag patterns add nothing, so a config without a
489/// `flags` section keeps the hash it had before flag patterns joined it.
490///
491/// Public because a run is not the only thing that needs it: `fallow doctor`
492/// inspects the cache without running an analysis, and it resolves config with
493/// caching disabled, which zeroes the stored hash. Comparing against that zero
494/// reported every healthy cache as config drift.
495#[must_use]
496pub fn cache_config_hash(external_plugins: &[ExternalPluginDef], flags: &FlagsConfig) -> u64 {
497    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
498    names.sort_unstable();
499    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
500    for name in names {
501        hash_str(&mut hasher, name);
502    }
503    let patterns = flags.patterns();
504    if !patterns.is_builtin_only() {
505        hasher.update(b"\0flags");
506        hasher.update(&(patterns.sdk_patterns.len() as u64).to_le_bytes());
507        for (function, name_arg, provider) in &patterns.sdk_patterns {
508            hash_str(&mut hasher, function);
509            hasher.update(&(*name_arg as u64).to_le_bytes());
510            hash_str(&mut hasher, provider);
511        }
512        hasher.update(&(patterns.env_prefixes.len() as u64).to_le_bytes());
513        for prefix in &patterns.env_prefixes {
514            hash_str(&mut hasher, prefix);
515        }
516        hasher.update(&[u8::from(patterns.config_object_heuristics)]);
517    }
518    hasher.digest()
519}
520
521fn hash_str(hasher: &mut xxhash_rust::xxh3::Xxh3, value: &str) {
522    hasher.update(&(value.len() as u32).to_le_bytes());
523    hasher.update(value.as_bytes());
524}
525
526/// Environment variable that moves the persistent analysis cache.
527pub const CACHE_DIR_ENV: &str = "FALLOW_CACHE_DIR";
528
529/// Read the non-empty `FALLOW_CACHE_DIR` value from the process environment.
530///
531/// Every host that loads a project config (CLI, LSP, MCP, Node bindings)
532/// applies this value with [`ResolvedConfig::override_cache_dir`], so the
533/// variable has one meaning on every surface. It wins over `cache.dir`.
534#[must_use]
535pub fn cache_dir_env_override() -> Option<PathBuf> {
536    std::env::var_os(CACHE_DIR_ENV).and_then(cache_dir_from_env_value)
537}
538
539/// Parse a raw `FALLOW_CACHE_DIR` value. An empty value is no override.
540#[must_use]
541pub fn cache_dir_from_env_value(raw: OsString) -> Option<PathBuf> {
542    Some(PathBuf::from(raw)).filter(|path| !path.as_os_str().is_empty())
543}
544
545/// Environment variable that caps the extraction cache size in megabytes.
546/// It is not a CLI flag because the cap is a platform or CI concern, not an
547/// analysis input (ADR-009).
548pub const CACHE_MAX_SIZE_ENV: &str = "FALLOW_CACHE_MAX_SIZE";
549
550/// Read `FALLOW_CACHE_MAX_SIZE` from the process environment. It wins over
551/// `cache.maxSizeMb` on every host, like [`cache_dir_env_override`].
552#[must_use]
553pub fn cache_max_size_env_override() -> Option<u32> {
554    std::env::var_os(CACHE_MAX_SIZE_ENV)
555        .as_deref()
556        .and_then(cache_max_size_from_env_value)
557}
558
559/// Parse a raw `FALLOW_CACHE_MAX_SIZE` value. Only a positive whole number of
560/// megabytes is an override.
561#[must_use]
562pub fn cache_max_size_from_env_value(raw: &OsStr) -> Option<u32> {
563    raw.to_str()?
564        .trim()
565        .parse::<u32>()
566        .ok()
567        .filter(|mb| *mb > 0)
568}
569
570/// Environment variable that turns `workspaces.changedSince` off for every
571/// run of a process, like `--no-package-baselines` does for one run. A CI job
572/// that saves or gates a whole-project baseline sets it once.
573pub const PACKAGE_BASELINES_ENV: &str = "FALLOW_PACKAGE_BASELINES";
574
575/// Whether a raw `FALLOW_PACKAGE_BASELINES` value turns the package map off.
576/// `false`, `0`, `no` and `off` do, in any case. Every other value keeps the
577/// map, so a typo never widens a run.
578#[must_use]
579pub fn package_baselines_disabled_by_env_value(raw: &OsStr) -> bool {
580    raw.to_str().is_some_and(|value| {
581        matches!(
582            value.trim().to_ascii_lowercase().as_str(),
583            "false" | "0" | "no" | "off"
584        )
585    })
586}
587
588fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
589    let Some(dir) = configured else {
590        return root.join(".fallow");
591    };
592    if dir.is_absolute() {
593        dir
594    } else {
595        root.join(dir)
596    }
597}
598
599fn cache_dir_is_inside_root(root: &Path, cache_dir: &Path) -> bool {
600    if cache_dir.starts_with(root) {
601        return true;
602    }
603    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
604    let canonical_cache =
605        dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
606    canonical_cache.starts_with(&canonical_root)
607}
608
609/// Name of the subdirectory that one project root owns inside a shared cache
610/// directory: the root's folder name for a reader, then a hash of the full
611/// root path, so two roots with the same folder name stay apart.
612fn per_root_cache_subdir(root: &Path) -> String {
613    let path = root.to_string_lossy().replace('\\', "/");
614    let hash = xxhash_rust::xxh3::xxh3_64(path.as_bytes());
615    let name: String = root
616        .file_name()
617        .map(|name| name.to_string_lossy())
618        .unwrap_or_default()
619        .chars()
620        .map(|c| {
621            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
622                c
623            } else {
624                '_'
625            }
626        })
627        .collect();
628    if name.is_empty() {
629        format!("{hash:016x}")
630    } else {
631        format!("{name}-{hash:016x}")
632    }
633}
634
635pub(super) fn normalize_user_glob_pattern(pattern: &str) -> &str {
636    pattern.strip_prefix("./").unwrap_or(pattern)
637}
638
639/// Built-in discovery ignore patterns, unioned into
640/// [`ResolvedConfig::ignore_patterns`] after the user's own `ignorePatterns`.
641///
642/// Public and ordered because the order IS the index layout of that union:
643/// user patterns occupy `0..user_ignore_pattern_count` and these follow, in
644/// this order. Source discovery maps a match index back to the pattern text
645/// through that layout when it reports which built-in removed a candidate
646/// source file (issue #2638), so reordering this list or changing where it is
647/// appended changes an output contract.
648///
649/// A `!` entry in `ignorePatterns` is not part of this union. It is an
650/// exception that the ignore set applies after the union, so it can lift a
651/// built-in for its own subtree (issue #2940). The `node_modules` and `.git`
652/// patterns can never be lifted.
653pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
654    "**/node_modules/**",
655    "**/dist/**",
656    "**/build/**",
657    "**/.git/**",
658    "**/coverage/**",
659    "**/*.min.js",
660    "**/*.min.mjs",
661    "**/*.min.cjs",
662    "**/*.bundle.js",
663];
664
665#[expect(
666    clippy::expect_used,
667    reason = "user glob patterns are validated before config resolution"
668)]
669fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
670    patterns
671        .iter()
672        .map(|pattern| {
673            let normalized = normalize_user_glob_pattern(pattern);
674            Glob::new(normalized)
675                .expect("ignoreUnresolvedImports entry was validated at config load time")
676                .compile_matcher()
677        })
678        .collect()
679}
680
681fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
682    if production {
683        rules.unused_dev_dependencies = Severity::Off;
684        rules.unused_optional_dependencies = Severity::Off;
685    }
686    rules
687}
688
689fn resolve_boundaries(
690    mut boundaries: super::boundaries::BoundaryConfig,
691    root: &Path,
692) -> ResolvedBoundaryConfig {
693    expand_boundary_preset(&mut boundaries, root);
694    let logical_groups = boundaries.expand_auto_discover(root);
695    let mut resolved = boundaries.resolve();
696    resolved.logical_groups = logical_groups;
697    resolved
698}
699
700/// Expand the boundary preset in place, with the tsconfig `rootDir` as the
701/// source root. Does nothing without a preset.
702pub(super) fn expand_boundary_preset(
703    boundaries: &mut super::boundaries::BoundaryConfig,
704    root: &Path,
705) {
706    if boundaries.preset.is_some() {
707        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
708            .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
709            .unwrap_or_else(|| "src".to_owned());
710        if source_root != "src" {
711            tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
712        }
713        boundaries.expand(&source_root);
714    }
715}
716
717/// Inter-file rules that a per-file override cannot change.
718///
719/// `circular-dependency` is not in this list: a cycle takes the highest
720/// severity of its files, and a cycle whose files all resolve to `off` is
721/// dropped, so a per-file override does change the result.
722fn ineffective_inter_file_override_rules(rules: &PartialRulesConfig) -> Vec<&'static str> {
723    let mut names = Vec::new();
724    if rules.duplicate_exports.is_some() {
725        names.push("duplicate-exports");
726    }
727    if rules.re_export_cycle.is_some() {
728        names.push("re-export-cycle");
729    }
730    names
731}
732
733fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
734    let ineffective = ineffective_inter_file_override_rules(rules);
735    if ineffective.contains(&"duplicate-exports")
736        && record_inter_file_warn_seen("duplicate-exports", files)
737    {
738        let files = files.join(", ");
739        tracing::warn!(
740            "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."
741        );
742    }
743    if ineffective.contains(&"re-export-cycle")
744        && record_inter_file_warn_seen("re-export-cycle", files)
745    {
746        let files = files.join(", ");
747        tracing::warn!(
748            "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."
749        );
750    }
751}
752
753#[expect(
754    clippy::expect_used,
755    reason = "override glob patterns are validated before config resolution"
756)]
757fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
758    overrides
759        .into_iter()
760        .filter_map(|override_entry| {
761            warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
762            let matchers: Vec<globset::GlobMatcher> = override_entry
763                .files
764                .iter()
765                .map(|pattern| {
766                    compile_path_glob(pattern)
767                        .expect("overrides[].files pattern was validated at config load time")
768                        .compile_matcher()
769                })
770                .collect();
771            if matchers.is_empty() {
772                None
773            } else {
774                Some(ResolvedOverride {
775                    matchers,
776                    rules: override_entry.rules,
777                })
778            }
779        })
780        .collect()
781}
782
783/// Compile `ignoreExports` file globs into matchers paired with export names.
784#[expect(
785    clippy::expect_used,
786    reason = "user glob patterns are validated before config resolution"
787)]
788fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
789    rules
790        .iter()
791        .map(|rule| CompiledIgnoreExportRule {
792            matcher: compile_path_glob(&rule.file)
793                .expect("ignoreExports[].file was validated at config load time")
794                .compile_matcher(),
795            exports: rule.exports.clone(),
796        })
797        .collect()
798}
799
800/// Compile `ignoreCatalogReferences` rules, pre-compiling the consumer glob.
801#[expect(
802    clippy::expect_used,
803    reason = "user glob patterns are validated before config resolution"
804)]
805fn compile_ignore_catalog_reference_rules(
806    rules: &[IgnoreCatalogReferenceRule],
807) -> Vec<CompiledIgnoreCatalogReferenceRule> {
808    rules
809        .iter()
810        .map(|rule| CompiledIgnoreCatalogReferenceRule {
811            package: rule.package.clone(),
812            catalog: rule.catalog.clone(),
813            consumer_matcher: rule.consumer.as_ref().map(|pattern| {
814                compile_path_glob(pattern)
815                    .expect("ignoreCatalogReferences[].consumer was validated at config load time")
816                    .compile_matcher()
817            }),
818        })
819        .collect()
820}
821
822/// Convert `ignoreDependencyOverrides` rules into their match-ready form.
823fn compile_ignore_dependency_override_rules(
824    rules: &[IgnoreDependencyOverrideRule],
825) -> Vec<CompiledIgnoreDependencyOverrideRule> {
826    rules
827        .iter()
828        .map(|rule| CompiledIgnoreDependencyOverrideRule {
829            package: rule.package.clone(),
830            source: rule.source.clone(),
831        })
832        .collect()
833}
834
835struct CompiledIgnoreSettings {
836    patterns: IgnorePatternSet,
837    user_pattern_count: usize,
838    findings: FindingIgnoreMatcher,
839    unresolved_imports: Vec<GlobMatcher>,
840    exports: Vec<CompiledIgnoreExportRule>,
841    catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
842    dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
843}
844
845fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
846    let (patterns, user_pattern_count) = compile_ignore_patterns(&config.ignore_patterns);
847    CompiledIgnoreSettings {
848        patterns,
849        user_pattern_count,
850        findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
851        unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
852        exports: compile_ignore_export_rules(&config.ignore_exports),
853        catalog_references: compile_ignore_catalog_reference_rules(
854            &config.ignore_catalog_references,
855        ),
856        dependency_overrides: compile_ignore_dependency_override_rules(
857            &config.ignore_dependency_overrides,
858        ),
859    }
860}
861
862struct ResolvedPluginSettings {
863    external_plugins: Vec<ExternalPluginDef>,
864    rule_packs: Vec<crate::rule_pack::RulePackDef>,
865    rule_pack_sources: Vec<PathBuf>,
866}
867
868fn resolve_plugin_settings(
869    root: &Path,
870    configured_plugins: &[String],
871    framework: Vec<ExternalPluginDef>,
872    rule_packs: &[String],
873) -> ResolvedPluginSettings {
874    let mut external_plugins = discover_external_plugins(root, configured_plugins);
875    external_plugins.extend(framework);
876
877    let configured_rule_packs = rule_packs;
878    let rule_packs =
879        crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
880            for error in &errors {
881                tracing::error!("invalid rule pack: {error}");
882            }
883            Vec::new()
884        });
885    let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
886        configured_rule_packs.iter().map(PathBuf::from).collect()
887    } else {
888        Vec::new()
889    };
890
891    ResolvedPluginSettings {
892        external_plugins,
893        rule_packs,
894        rule_pack_sources,
895    }
896}
897
898struct ResolvedCacheSettings {
899    dir: PathBuf,
900    max_size_mb: Option<u32>,
901    config_hash: u64,
902}
903
904struct ResolvedProductionRules {
905    production: bool,
906    rules: RulesConfig,
907}
908
909fn resolve_production_rules(
910    production_config: ProductionConfig,
911    rules: RulesConfig,
912) -> ResolvedProductionRules {
913    let production = production_config.global();
914    ResolvedProductionRules {
915        production,
916        rules: resolve_rules_for_production(rules, production),
917    }
918}
919
920fn resolve_cache_settings(
921    root: &Path,
922    configured_dir: Option<PathBuf>,
923    configured_max_size_mb: Option<u32>,
924    override_max_size_mb: Option<u32>,
925    no_cache: bool,
926    config_hash: impl FnOnce() -> u64,
927) -> ResolvedCacheSettings {
928    ResolvedCacheSettings {
929        dir: resolve_cache_dir(root, configured_dir),
930        max_size_mb: override_max_size_mb.or(configured_max_size_mb),
931        config_hash: if no_cache { 0 } else { config_hash() },
932    }
933}
934
935fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
936    SecurityConfig {
937        request_receivers: security.normalized_request_receivers(),
938        ..security
939    }
940}
941
942struct ResolvedPathPolicySettings {
943    boundaries: ResolvedBoundaryConfig,
944    overrides: Vec<ResolvedOverride>,
945}
946
947fn resolve_path_policy_settings(
948    boundaries: BoundaryConfig,
949    overrides: Vec<ConfigOverride>,
950    root: &Path,
951) -> ResolvedPathPolicySettings {
952    ResolvedPathPolicySettings {
953        boundaries: resolve_boundaries(boundaries, root),
954        overrides: compile_overrides(overrides),
955    }
956}
957
958fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
959    pattern.and_then(|pattern| match regex::Regex::new(pattern) {
960        Ok(re) => Some(re),
961        Err(error) => {
962            tracing::warn!(
963                %error,
964                "ignoring invalid unusedComponentProps.ignorePattern; this config was \
965                 not validated through FallowConfig::load"
966            );
967            None
968        }
969    })
970}
971
972impl FallowConfig {
973    /// Resolve into a fully resolved config with compiled globs.
974    #[expect(
975        clippy::too_many_arguments,
976        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"
977    )]
978    pub fn resolve(
979        self,
980        root: PathBuf,
981        output: OutputFormat,
982        threads: usize,
983        no_cache: bool,
984        quiet: bool,
985        cache_max_size_mb: Option<u32>,
986    ) -> ResolvedConfig {
987        let mut legacy_glob_groups = super::legacy_glob::collect_config_legacy_glob_groups(&self);
988        let compiled_ignores = compile_ignore_settings(&self);
989        let config_json = detection_config_json(&self);
990
991        let production_rules = resolve_production_rules(self.production, self.rules);
992
993        let plugins =
994            resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
995
996        let cache = resolve_cache_settings(
997            &root,
998            self.cache.dir,
999            self.cache.max_size_mb,
1000            cache_max_size_mb,
1001            no_cache,
1002            || cache_config_hash(&plugins.external_plugins, &self.flags),
1003        );
1004
1005        let detection_config_digest = fallow_types::identity::fnv1a64_parts(&[
1006            "config",
1007            &config_json,
1008            &canonical_json_string(&{
1009                let mut sorted: Vec<&ExternalPluginDef> = plugins.external_plugins.iter().collect();
1010                sorted.sort_by(|a, b| a.name.cmp(&b.name));
1011                sorted
1012            }),
1013            &canonical_json_string(&plugins.rule_packs),
1014        ]);
1015
1016        super::legacy_glob::extend_legacy_glob_groups(
1017            &mut legacy_glob_groups,
1018            &plugins.external_plugins,
1019            &plugins.rule_packs,
1020        );
1021
1022        let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
1023        let (workspace_patterns, workspace_changed_since) = self
1024            .workspaces
1025            .map(|workspaces| (workspaces.patterns, workspaces.changed_since))
1026            .unwrap_or_default();
1027
1028        let unused_component_props_ignore = compile_unused_component_props_ignore(
1029            self.unused_component_props.ignore_pattern.as_deref(),
1030        );
1031
1032        ResolvedConfig {
1033            root,
1034            entry_patterns: self.entry,
1035            ignore_patterns: compiled_ignores.patterns,
1036            user_ignore_pattern_count: compiled_ignores.user_pattern_count,
1037            entry_pattern_effect: EntryEffectRecord::default(),
1038            ignore_findings: compiled_ignores.findings,
1039            output,
1040            cache_dir: cache.dir,
1041            threads,
1042            no_cache,
1043            cache_max_size_mb: cache.max_size_mb,
1044            cache_config_hash: cache.config_hash,
1045            ignore_dependencies: IgnoreDependencyMatcher::compile(&self.ignore_dependencies),
1046            ignore_command_entries: self.ignore_command_entries,
1047            ignore_unresolved_imports: compiled_ignores.unresolved_imports,
1048            ignore_export_rules: self.ignore_exports,
1049            compiled_ignore_exports: compiled_ignores.exports,
1050            compiled_ignore_catalog_references: compiled_ignores.catalog_references,
1051            compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
1052            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
1053            used_class_members: self.used_class_members,
1054            ignore_decorators: self.ignore_decorators,
1055            unused_component_props_ignore,
1056            circular_dependencies: self.circular_dependencies,
1057            duplicates: self.duplicates,
1058            similar_code: self.similar_code,
1059            mutation: self.mutation,
1060            health: self.health,
1061            type_aware: self.type_aware,
1062            rules: production_rules.rules,
1063            boundaries: path_policy.boundaries,
1064            rule_packs: plugins.rule_packs,
1065            rule_pack_sources: plugins.rule_pack_sources,
1066            production: production_rules.production,
1067            quiet,
1068            external_plugins: plugins.external_plugins,
1069            dynamically_loaded: self.dynamically_loaded,
1070            markup_extensions: super::resolve_markup_extensions(&self.markup_extensions),
1071            overrides: path_policy.overrides,
1072            workspace_changed_since,
1073            workspace_patterns,
1074            regression: self.regression,
1075            audit: self.audit,
1076            codeowners: self.codeowners,
1077            public_packages: self.public_packages,
1078            flags: self.flags,
1079            security: normalize_security_config(self.security),
1080            fix: self.fix,
1081            resolve: self.resolve,
1082            include_entry_exports: self.include_entry_exports,
1083            show_cascade: self.show_cascade,
1084            auto_imports: self.auto_imports,
1085            fail_on_parse_error: self.fail_on_parse_error,
1086            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
1087            analysis_snapshot: AnalysisSnapshot::Current,
1088            detection_config_digest,
1089            legacy_glob_groups,
1090        }
1091    }
1092}
1093
1094impl ResolvedConfig {
1095    /// Apply a `FALLOW_PACKAGE_BASELINES` value: a false value empties
1096    /// `workspaces.changedSince`, so no run of the process reads the map.
1097    pub fn apply_package_baselines_env(&mut self, raw: Option<&OsStr>) {
1098        if raw.is_some_and(package_baselines_disabled_by_env_value) {
1099            self.workspace_changed_since.clear();
1100        }
1101    }
1102
1103    /// Replace the resolved cache directory with a host override, such as
1104    /// `FALLOW_CACHE_DIR`. A relative path resolves from the project root,
1105    /// the same base as `cache.dir`.
1106    pub fn override_cache_dir(&mut self, dir: PathBuf) {
1107        self.cache_dir = resolve_cache_dir(&self.root, Some(dir));
1108    }
1109
1110    /// Give this project root its own subdirectory when the cache directory
1111    /// is outside the root.
1112    ///
1113    /// A host that analyzes more than one root in one process, such as the
1114    /// language server with a multi-root workspace, calls this. Without it,
1115    /// the roots write the same `cache.bin` and `graph-cache.bin`, and each
1116    /// save replaces the cache of the other root. The CLI does not call it,
1117    /// so a shared CI cache stays valid when the checkout path changes. A
1118    /// cache directory inside the root already belongs to that root and
1119    /// stays unchanged.
1120    pub fn scope_shared_cache_dir_to_root(&mut self) {
1121        if cache_dir_is_inside_root(&self.root, &self.cache_dir) {
1122            return;
1123        }
1124        self.cache_dir = self.cache_dir.join(per_root_cache_subdir(&self.root));
1125    }
1126
1127    /// Resolve the effective rules for a given file path.
1128    /// Starts with base rules and applies matching overrides in order.
1129    #[must_use]
1130    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
1131        if self.overrides.is_empty() {
1132            return self.rules.clone();
1133        }
1134
1135        let relative = path.strip_prefix(&self.root).unwrap_or(path);
1136        let relative_str = relative.to_string_lossy();
1137
1138        let mut rules = self.rules.clone();
1139        for override_entry in &self.overrides {
1140            let matches = override_entry
1141                .matchers
1142                .iter()
1143                .any(|m| m.is_match(relative_str.as_ref()));
1144            if matches {
1145                rules.apply_partial(&override_entry.rules);
1146            }
1147        }
1148        rules
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use crate::CacheConfig;
1156    use crate::config::boundaries::BoundaryConfig;
1157    use crate::config::health::HealthConfig;
1158
1159    #[test]
1160    fn mutation_section_reaches_resolved_config_and_leaves_the_detection_digest() {
1161        let resolve = |json: &str| {
1162            let authored: FallowConfig = serde_json::from_str(json).expect("config deserializes");
1163            authored.resolve(
1164                PathBuf::from("/project"),
1165                OutputFormat::Json,
1166                1,
1167                true,
1168                true,
1169                None,
1170            )
1171        };
1172        let plain = resolve("{}");
1173        let with_mutation = resolve(r#"{"mutation":{"minScore":75,"operators":["removed-call"]}}"#);
1174        assert_eq!(with_mutation.mutation.min_score, Some(75.0));
1175        assert_eq!(
1176            with_mutation.mutation.operators,
1177            Some(vec![crate::MutationOperatorName::RemovedCall])
1178        );
1179        assert_eq!(
1180            plain.detection_config_digest,
1181            with_mutation.detection_config_digest
1182        );
1183        let detecting = resolve(r#"{"ignorePatterns":["gen/**"]}"#);
1184        assert_ne!(
1185            plain.detection_config_digest,
1186            detecting.detection_config_digest
1187        );
1188    }
1189
1190    /// A single `*` in a config path glob stays within one path segment.
1191    /// Only `**` crosses directories.
1192    #[test]
1193    fn single_star_stays_within_one_segment_in_config_path_globs() {
1194        let authored: FallowConfig = serde_json::from_str(
1195            r#"{
1196                "ignorePatterns": ["gen/*.ts"],
1197                "ignoreFindings": ["legacy/*.ts"],
1198                "overrides": [{ "files": ["old/*.ts"], "rules": { "unused-files": "off" } }],
1199                "ignoreExports": [{ "file": "api/*.ts", "exports": ["*"] }],
1200                "ignoreCatalogReferences": [{ "package": "react", "consumer": "apps/*/package.json" }],
1201                "boundaries": {
1202                    "zones": [{ "name": "ui", "patterns": ["ui/*.ts"] }],
1203                    "coverage": { "allowUnmatched": ["scripts/*.ts"] }
1204                }
1205            }"#,
1206        )
1207        .expect("config deserializes");
1208        let resolved = authored.resolve(
1209            PathBuf::from("/project"),
1210            OutputFormat::Json,
1211            1,
1212            true,
1213            true,
1214            None,
1215        );
1216
1217        assert!(resolved.ignore_patterns.is_match("gen/a.ts"));
1218        assert!(!resolved.ignore_patterns.is_match("gen/deep/a.ts"));
1219
1220        assert!(resolved.ignore_findings.is_ignored("legacy/a.ts"));
1221        assert!(!resolved.ignore_findings.is_ignored("legacy/deep/a.ts"));
1222
1223        let off = |path: &str| {
1224            resolved
1225                .resolve_rules_for_path(Path::new(path))
1226                .unused_files
1227                == Severity::Off
1228        };
1229        assert!(off("/project/old/a.ts"));
1230        assert!(!off("/project/old/deep/a.ts"));
1231
1232        let export_rule = &resolved.compiled_ignore_exports[0].matcher;
1233        assert!(export_rule.is_match("api/a.ts"));
1234        assert!(!export_rule.is_match("api/deep/a.ts"));
1235
1236        let catalog_rule = &resolved.compiled_ignore_catalog_references[0];
1237        assert!(catalog_rule.matches("react", "default", "apps/web/package.json"));
1238        assert!(!catalog_rule.matches("react", "default", "apps/web/nested/package.json"));
1239
1240        assert_eq!(resolved.boundaries.classify_zone("ui/a.ts"), Some("ui"));
1241        assert_eq!(resolved.boundaries.classify_zone("ui/deep/a.ts"), None);
1242
1243        let allow_unmatched = &resolved.boundaries.coverage.allow_unmatched[0];
1244        assert!(allow_unmatched.is_match("scripts/a.ts"));
1245        assert!(!allow_unmatched.is_match("scripts/deep/a.ts"));
1246    }
1247
1248    #[test]
1249    fn workspace_patterns_reach_resolved_config() {
1250        let authored: FallowConfig =
1251            serde_json::from_str(r#"{"workspaces":{"patterns":["apps/*/*"]}}"#)
1252                .expect("workspace patterns deserialize");
1253        let resolved = authored.resolve(
1254            PathBuf::from("/project"),
1255            OutputFormat::Json,
1256            1,
1257            true,
1258            true,
1259            None,
1260        );
1261
1262        assert_eq!(resolved.workspace_patterns, vec!["apps/*/*".to_string()]);
1263    }
1264
1265    #[test]
1266    fn workspace_changed_since_reaches_resolved_config() {
1267        let authored: FallowConfig = serde_json::from_str(
1268            r#"{"workspaces":{"changedSince":{"packages/web":"main","packages/legacy":"release/2024.10"}}}"#,
1269        )
1270        .expect("workspace baselines deserialize");
1271        let resolved = authored.resolve(
1272            PathBuf::from("/project"),
1273            OutputFormat::Json,
1274            1,
1275            true,
1276            true,
1277            None,
1278        );
1279
1280        assert_eq!(resolved.workspace_changed_since.len(), 2);
1281        assert_eq!(resolved.workspace_changed_since["packages/web"], "main");
1282        assert_eq!(
1283            resolved.workspace_changed_since["packages/legacy"],
1284            "release/2024.10"
1285        );
1286    }
1287
1288    #[test]
1289    fn cache_config_hash_keys_on_user_flag_patterns() {
1290        let builtin = cache_config_hash(&[], &FlagsConfig::default());
1291        let with_pattern = |function: &str| FlagsConfig {
1292            sdk_patterns: vec![super::super::flags::SdkPattern {
1293                function: function.to_string(),
1294                name_arg: 0,
1295                provider: None,
1296            }],
1297            ..FlagsConfig::default()
1298        };
1299
1300        assert_eq!(
1301            builtin,
1302            xxhash_rust::xxh3::Xxh3::new().digest(),
1303            "a config without flag patterns keeps the plugin-only hash"
1304        );
1305        assert_ne!(builtin, cache_config_hash(&[], &with_pattern("isOn")));
1306        assert_ne!(
1307            cache_config_hash(&[], &with_pattern("isOn")),
1308            cache_config_hash(&[], &with_pattern("isOff"))
1309        );
1310        assert_ne!(
1311            builtin,
1312            cache_config_hash(
1313                &[],
1314                &FlagsConfig {
1315                    config_object_heuristics: true,
1316                    ..FlagsConfig::default()
1317                }
1318            )
1319        );
1320        assert_ne!(
1321            builtin,
1322            cache_config_hash(
1323                &[],
1324                &FlagsConfig {
1325                    env_prefixes: vec!["MYAPP_".to_string()],
1326                    ..FlagsConfig::default()
1327                }
1328            )
1329        );
1330    }
1331
1332    #[test]
1333    fn overrides_deserialize() {
1334        let json_str = r#"{
1335            "overrides": [{
1336                "files": ["*.test.ts"],
1337                "rules": {
1338                    "unused-exports": "off"
1339                }
1340            }]
1341        }"#;
1342        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1343        assert_eq!(config.overrides.len(), 1);
1344        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1345        assert_eq!(
1346            config.overrides[0].rules.unused_exports,
1347            Some(Severity::Off)
1348        );
1349        assert_eq!(config.overrides[0].rules.unused_files, None);
1350    }
1351
1352    #[test]
1353    fn resolve_rules_for_path_no_overrides() {
1354        let config = FallowConfig {
1355            schema: None,
1356            minimum_version: None,
1357            extends: vec![],
1358            entry: vec![],
1359            ignore_patterns: vec![],
1360            ignore_findings: vec![],
1361            framework: vec![],
1362            workspaces: None,
1363            ignore_dependencies: vec![],
1364            ignore_command_entries: vec![],
1365            ignore_unresolved_imports: vec![],
1366            ignore_exports: vec![],
1367            ignore_catalog_references: vec![],
1368            ignore_dependency_overrides: vec![],
1369            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1370            used_class_members: vec![],
1371            ignore_decorators: vec![],
1372            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1373            circular_dependencies: crate::CircularDependenciesConfig::default(),
1374            duplicates: DuplicatesConfig::default(),
1375            similar_code: SimilarCodeConfig::default(),
1376            mutation: MutationConfig::default(),
1377            health: HealthConfig::default(),
1378            rules: RulesConfig::default(),
1379            boundaries: BoundaryConfig::default(),
1380            production: false.into(),
1381            plugins: vec![],
1382            overrides: vec![],
1383            regression: None,
1384            type_aware: crate::TypeAwareConfig::default(),
1385            audit: crate::config::AuditConfig::default(),
1386            codeowners: None,
1387            public_packages: vec![],
1388            flags: FlagsConfig::default(),
1389            security: SecurityConfig::default(),
1390            fix: crate::config::FixConfig::default(),
1391            resolve: ResolveConfig::default(),
1392            sealed: false,
1393            include_entry_exports: false,
1394            show_cascade: false,
1395            auto_imports: false,
1396            fail_on_parse_error: false,
1397            cache: CacheConfig::default(),
1398            ..Default::default()
1399        };
1400        let resolved = config.resolve(
1401            PathBuf::from("/project"),
1402            OutputFormat::Human,
1403            1,
1404            true,
1405            true,
1406            None,
1407        );
1408        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1409        assert_eq!(rules.unused_files, Severity::Error);
1410    }
1411
1412    #[test]
1413    fn resolve_rules_for_path_with_matching_override() {
1414        let config = FallowConfig {
1415            schema: None,
1416            minimum_version: None,
1417            extends: vec![],
1418            entry: vec![],
1419            ignore_patterns: vec![],
1420            ignore_findings: vec![],
1421            framework: vec![],
1422            workspaces: None,
1423            ignore_dependencies: vec![],
1424            ignore_command_entries: vec![],
1425            ignore_unresolved_imports: vec![],
1426            ignore_exports: vec![],
1427            ignore_catalog_references: vec![],
1428            ignore_dependency_overrides: vec![],
1429            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1430            used_class_members: vec![],
1431            ignore_decorators: vec![],
1432            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1433            circular_dependencies: crate::CircularDependenciesConfig::default(),
1434            duplicates: DuplicatesConfig::default(),
1435            similar_code: SimilarCodeConfig::default(),
1436            mutation: MutationConfig::default(),
1437            health: HealthConfig::default(),
1438            rules: RulesConfig::default(),
1439            boundaries: BoundaryConfig::default(),
1440            production: false.into(),
1441            plugins: vec![],
1442            overrides: vec![ConfigOverride {
1443                files: vec!["**/*.test.ts".to_string()],
1444                rules: PartialRulesConfig {
1445                    unused_exports: Some(Severity::Off),
1446                    ..Default::default()
1447                },
1448            }],
1449            regression: None,
1450            type_aware: crate::TypeAwareConfig::default(),
1451            audit: crate::config::AuditConfig::default(),
1452            codeowners: None,
1453            public_packages: vec![],
1454            flags: FlagsConfig::default(),
1455            security: SecurityConfig::default(),
1456            fix: crate::config::FixConfig::default(),
1457            resolve: ResolveConfig::default(),
1458            sealed: false,
1459            include_entry_exports: false,
1460            show_cascade: false,
1461            auto_imports: false,
1462            fail_on_parse_error: false,
1463            cache: CacheConfig::default(),
1464            ..Default::default()
1465        };
1466        let resolved = config.resolve(
1467            PathBuf::from("/project"),
1468            OutputFormat::Human,
1469            1,
1470            true,
1471            true,
1472            None,
1473        );
1474
1475        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1476        assert_eq!(test_rules.unused_exports, Severity::Off);
1477        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
1478
1479        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1480        assert_eq!(src_rules.unused_exports, Severity::Error);
1481    }
1482
1483    #[test]
1484    fn resolve_rules_for_path_later_override_wins() {
1485        let config = FallowConfig {
1486            schema: None,
1487            minimum_version: None,
1488            extends: vec![],
1489            entry: vec![],
1490            ignore_patterns: vec![],
1491            ignore_findings: vec![],
1492            framework: vec![],
1493            workspaces: None,
1494            ignore_dependencies: vec![],
1495            ignore_command_entries: vec![],
1496            ignore_unresolved_imports: vec![],
1497            ignore_exports: vec![],
1498            ignore_catalog_references: vec![],
1499            ignore_dependency_overrides: vec![],
1500            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1501            used_class_members: vec![],
1502            ignore_decorators: vec![],
1503            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1504            circular_dependencies: crate::CircularDependenciesConfig::default(),
1505            duplicates: DuplicatesConfig::default(),
1506            similar_code: SimilarCodeConfig::default(),
1507            mutation: MutationConfig::default(),
1508            health: HealthConfig::default(),
1509            rules: RulesConfig::default(),
1510            boundaries: BoundaryConfig::default(),
1511            production: false.into(),
1512            plugins: vec![],
1513            overrides: vec![
1514                ConfigOverride {
1515                    files: vec!["*.ts".to_string()],
1516                    rules: PartialRulesConfig {
1517                        unused_files: Some(Severity::Warn),
1518                        ..Default::default()
1519                    },
1520                },
1521                ConfigOverride {
1522                    files: vec!["*.test.ts".to_string()],
1523                    rules: PartialRulesConfig {
1524                        unused_files: Some(Severity::Off),
1525                        ..Default::default()
1526                    },
1527                },
1528            ],
1529            regression: None,
1530            type_aware: crate::TypeAwareConfig::default(),
1531            audit: crate::config::AuditConfig::default(),
1532            codeowners: None,
1533            public_packages: vec![],
1534            flags: FlagsConfig::default(),
1535            security: SecurityConfig::default(),
1536            fix: crate::config::FixConfig::default(),
1537            resolve: ResolveConfig::default(),
1538            sealed: false,
1539            include_entry_exports: false,
1540            show_cascade: false,
1541            auto_imports: false,
1542            fail_on_parse_error: false,
1543            cache: CacheConfig::default(),
1544            ..Default::default()
1545        };
1546        let resolved = config.resolve(
1547            PathBuf::from("/project"),
1548            OutputFormat::Human,
1549            1,
1550            true,
1551            true,
1552            None,
1553        );
1554
1555        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1556        assert_eq!(rules.unused_files, Severity::Off);
1557
1558        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1559        assert_eq!(rules2.unused_files, Severity::Warn);
1560    }
1561
1562    #[test]
1563    fn resolve_keeps_inter_file_rule_override_after_warning() {
1564        let config = FallowConfig {
1565            schema: None,
1566            minimum_version: None,
1567            extends: vec![],
1568            entry: vec![],
1569            ignore_patterns: vec![],
1570            ignore_findings: vec![],
1571            framework: vec![],
1572            workspaces: None,
1573            ignore_dependencies: vec![],
1574            ignore_command_entries: vec![],
1575            ignore_unresolved_imports: vec![],
1576            ignore_exports: vec![],
1577            ignore_catalog_references: vec![],
1578            ignore_dependency_overrides: vec![],
1579            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1580            used_class_members: vec![],
1581            ignore_decorators: vec![],
1582            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1583            circular_dependencies: crate::CircularDependenciesConfig::default(),
1584            duplicates: DuplicatesConfig::default(),
1585            similar_code: SimilarCodeConfig::default(),
1586            mutation: MutationConfig::default(),
1587            health: HealthConfig::default(),
1588            rules: RulesConfig::default(),
1589            boundaries: BoundaryConfig::default(),
1590            production: false.into(),
1591            plugins: vec![],
1592            overrides: vec![ConfigOverride {
1593                files: vec!["**/ui/**".to_string()],
1594                rules: PartialRulesConfig {
1595                    duplicate_exports: Some(Severity::Off),
1596                    unused_files: Some(Severity::Warn),
1597                    ..Default::default()
1598                },
1599            }],
1600            regression: None,
1601            type_aware: crate::TypeAwareConfig::default(),
1602            audit: crate::config::AuditConfig::default(),
1603            codeowners: None,
1604            public_packages: vec![],
1605            flags: FlagsConfig::default(),
1606            security: SecurityConfig::default(),
1607            fix: crate::config::FixConfig::default(),
1608            resolve: ResolveConfig::default(),
1609            sealed: false,
1610            include_entry_exports: false,
1611            show_cascade: false,
1612            auto_imports: false,
1613            fail_on_parse_error: false,
1614            cache: CacheConfig::default(),
1615            ..Default::default()
1616        };
1617        let resolved = config.resolve(
1618            PathBuf::from("/project"),
1619            OutputFormat::Human,
1620            1,
1621            true,
1622            true,
1623            None,
1624        );
1625        assert_eq!(
1626            resolved.overrides.len(),
1627            1,
1628            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1629        );
1630        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1631        assert_eq!(rules.unused_files, Severity::Warn);
1632    }
1633
1634    #[test]
1635    fn circular_dependency_override_is_not_reported_as_ineffective() {
1636        let rules = PartialRulesConfig {
1637            circular_dependencies: Some(Severity::Off),
1638            duplicate_exports: Some(Severity::Off),
1639            re_export_cycle: Some(Severity::Off),
1640            ..PartialRulesConfig::default()
1641        };
1642        assert_eq!(
1643            ineffective_inter_file_override_rules(&rules),
1644            vec!["duplicate-exports", "re-export-cycle"]
1645        );
1646
1647        let only_circular = PartialRulesConfig {
1648            circular_dependencies: Some(Severity::Off),
1649            ..PartialRulesConfig::default()
1650        };
1651        assert!(ineffective_inter_file_override_rules(&only_circular).is_empty());
1652    }
1653
1654    #[test]
1655    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1656        reset_inter_file_warn_dedup_for_test();
1657        let files_a = vec!["__test_dedup_a/*".to_string()];
1658        let files_b = vec!["__test_dedup_b/*".to_string()];
1659
1660        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1661        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1662        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1663
1664        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1665        assert!(!record_inter_file_warn_seen(
1666            "circular-dependency",
1667            &files_a
1668        ));
1669
1670        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1671
1672        let files_reordered = vec![
1673            "__test_dedup_b/*".to_string(),
1674            "__test_dedup_a/*".to_string(),
1675        ];
1676        let files_natural = vec![
1677            "__test_dedup_a/*".to_string(),
1678            "__test_dedup_b/*".to_string(),
1679        ];
1680        reset_inter_file_warn_dedup_for_test();
1681        assert!(record_inter_file_warn_seen(
1682            "duplicate-exports",
1683            &files_natural
1684        ));
1685        assert!(!record_inter_file_warn_seen(
1686            "duplicate-exports",
1687            &files_reordered
1688        ));
1689    }
1690
1691    #[test]
1692    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1693        reset_inter_file_warn_dedup_for_test();
1694        let files = vec!["__test_resolve_dedup/**".to_string()];
1695        let build_config = || FallowConfig {
1696            schema: None,
1697            minimum_version: None,
1698            extends: vec![],
1699            entry: vec![],
1700            ignore_patterns: vec![],
1701            ignore_findings: vec![],
1702            framework: vec![],
1703            workspaces: None,
1704            ignore_dependencies: vec![],
1705            ignore_command_entries: vec![],
1706            ignore_unresolved_imports: vec![],
1707            ignore_exports: vec![],
1708            ignore_catalog_references: vec![],
1709            ignore_dependency_overrides: vec![],
1710            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1711            used_class_members: vec![],
1712            ignore_decorators: vec![],
1713            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1714            circular_dependencies: crate::CircularDependenciesConfig::default(),
1715            duplicates: DuplicatesConfig::default(),
1716            similar_code: SimilarCodeConfig::default(),
1717            mutation: MutationConfig::default(),
1718            health: HealthConfig::default(),
1719            rules: RulesConfig::default(),
1720            boundaries: BoundaryConfig::default(),
1721            production: false.into(),
1722            plugins: vec![],
1723            overrides: vec![ConfigOverride {
1724                files: files.clone(),
1725                rules: PartialRulesConfig {
1726                    duplicate_exports: Some(Severity::Off),
1727                    ..Default::default()
1728                },
1729            }],
1730            regression: None,
1731            type_aware: crate::TypeAwareConfig::default(),
1732            audit: crate::config::AuditConfig::default(),
1733            codeowners: None,
1734            public_packages: vec![],
1735            flags: FlagsConfig::default(),
1736            security: SecurityConfig::default(),
1737            fix: crate::config::FixConfig::default(),
1738            resolve: ResolveConfig::default(),
1739            sealed: false,
1740            include_entry_exports: false,
1741            show_cascade: false,
1742            auto_imports: false,
1743            fail_on_parse_error: false,
1744            cache: CacheConfig::default(),
1745            ..Default::default()
1746        };
1747        for _ in 0..10 {
1748            let _ = build_config().resolve(
1749                PathBuf::from("/project"),
1750                OutputFormat::Human,
1751                1,
1752                true,
1753                true,
1754                None,
1755            );
1756        }
1757        assert!(
1758            !record_inter_file_warn_seen("duplicate-exports", &files),
1759            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1760        );
1761    }
1762
1763    /// Helper to build a FallowConfig with minimal boilerplate.
1764    fn make_config(production: bool) -> FallowConfig {
1765        FallowConfig {
1766            schema: None,
1767            minimum_version: None,
1768            extends: vec![],
1769            entry: vec![],
1770            ignore_patterns: vec![],
1771            ignore_findings: vec![],
1772            framework: vec![],
1773            workspaces: None,
1774            ignore_dependencies: vec![],
1775            ignore_command_entries: vec![],
1776            ignore_unresolved_imports: vec![],
1777            ignore_exports: vec![],
1778            ignore_catalog_references: vec![],
1779            ignore_dependency_overrides: vec![],
1780            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1781            used_class_members: vec![],
1782            ignore_decorators: vec![],
1783            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1784            circular_dependencies: crate::CircularDependenciesConfig::default(),
1785            duplicates: DuplicatesConfig::default(),
1786            similar_code: SimilarCodeConfig::default(),
1787            mutation: MutationConfig::default(),
1788            health: HealthConfig::default(),
1789            rules: RulesConfig::default(),
1790            boundaries: BoundaryConfig::default(),
1791            production: production.into(),
1792            plugins: vec![],
1793            overrides: vec![],
1794            regression: None,
1795            type_aware: crate::TypeAwareConfig::default(),
1796            audit: crate::config::AuditConfig::default(),
1797            codeowners: None,
1798            public_packages: vec![],
1799            flags: FlagsConfig::default(),
1800            security: SecurityConfig::default(),
1801            fix: crate::config::FixConfig::default(),
1802            resolve: ResolveConfig::default(),
1803            sealed: false,
1804            include_entry_exports: false,
1805            show_cascade: false,
1806            auto_imports: false,
1807            fail_on_parse_error: false,
1808            cache: CacheConfig::default(),
1809            ..Default::default()
1810        }
1811    }
1812
1813    #[test]
1814    fn resolve_tracks_rule_pack_sources_in_config_order() {
1815        let dir = tempfile::tempdir().unwrap();
1816        std::fs::create_dir_all(dir.path().join("rule-packs")).unwrap();
1817        std::fs::write(
1818            dir.path().join("rule-packs/team-policy.jsonc"),
1819            r#"{
1820  "version": 1,
1821  "name": "team-policy",
1822  "rules": [
1823    {
1824      "id": "no-moment",
1825      "kind": "banned-import",
1826      "specifiers": ["moment"]
1827    }
1828  ]
1829}
1830"#,
1831        )
1832        .unwrap();
1833
1834        let mut config = make_config(false);
1835        config.rule_packs = vec!["rule-packs/team-policy.jsonc".to_string()];
1836
1837        let resolved = config.resolve(
1838            dir.path().to_path_buf(),
1839            OutputFormat::Human,
1840            1,
1841            true,
1842            true,
1843            None,
1844        );
1845
1846        assert_eq!(resolved.rule_packs.len(), 1);
1847        assert_eq!(resolved.rule_packs[0].name, "team-policy");
1848        assert_eq!(
1849            resolved.rule_pack_sources,
1850            vec![PathBuf::from("rule-packs/team-policy.jsonc")]
1851        );
1852    }
1853
1854    #[test]
1855    fn resolve_production_forces_dev_deps_off() {
1856        let resolved = make_config(true).resolve(
1857            PathBuf::from("/project"),
1858            OutputFormat::Human,
1859            1,
1860            true,
1861            true,
1862            None,
1863        );
1864        assert_eq!(
1865            resolved.rules.unused_dev_dependencies,
1866            Severity::Off,
1867            "production mode should force unused_dev_dependencies to off"
1868        );
1869    }
1870
1871    #[test]
1872    fn resolve_production_forces_optional_deps_off() {
1873        let resolved = make_config(true).resolve(
1874            PathBuf::from("/project"),
1875            OutputFormat::Human,
1876            1,
1877            true,
1878            true,
1879            None,
1880        );
1881        assert_eq!(
1882            resolved.rules.unused_optional_dependencies,
1883            Severity::Off,
1884            "production mode should force unused_optional_dependencies to off"
1885        );
1886    }
1887
1888    #[test]
1889    fn resolve_production_preserves_other_rules() {
1890        let resolved = make_config(true).resolve(
1891            PathBuf::from("/project"),
1892            OutputFormat::Human,
1893            1,
1894            true,
1895            true,
1896            None,
1897        );
1898        assert_eq!(resolved.rules.unused_files, Severity::Error);
1899        assert_eq!(resolved.rules.unused_exports, Severity::Error);
1900        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1901    }
1902
1903    #[test]
1904    fn resolve_non_production_keeps_dev_deps_default() {
1905        let resolved = make_config(false).resolve(
1906            PathBuf::from("/project"),
1907            OutputFormat::Human,
1908            1,
1909            true,
1910            true,
1911            None,
1912        );
1913        assert_eq!(
1914            resolved.rules.unused_dev_dependencies,
1915            Severity::Warn,
1916            "non-production should keep default severity"
1917        );
1918        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1919    }
1920
1921    #[test]
1922    fn resolve_production_flag_stored() {
1923        let resolved = make_config(true).resolve(
1924            PathBuf::from("/project"),
1925            OutputFormat::Human,
1926            1,
1927            true,
1928            true,
1929            None,
1930        );
1931        assert!(resolved.production);
1932
1933        let resolved2 = make_config(false).resolve(
1934            PathBuf::from("/project"),
1935            OutputFormat::Human,
1936            1,
1937            true,
1938            true,
1939            None,
1940        );
1941        assert!(!resolved2.production);
1942    }
1943
1944    #[test]
1945    fn resolve_default_ignores_node_modules() {
1946        let resolved = make_config(false).resolve(
1947            PathBuf::from("/project"),
1948            OutputFormat::Human,
1949            1,
1950            true,
1951            true,
1952            None,
1953        );
1954        assert!(
1955            resolved
1956                .ignore_patterns
1957                .is_match("node_modules/lodash/index.js")
1958        );
1959        assert!(
1960            resolved
1961                .ignore_patterns
1962                .is_match("packages/a/node_modules/react/index.js")
1963        );
1964    }
1965
1966    #[test]
1967    fn resolve_default_ignores_dist() {
1968        let resolved = make_config(false).resolve(
1969            PathBuf::from("/project"),
1970            OutputFormat::Human,
1971            1,
1972            true,
1973            true,
1974            None,
1975        );
1976        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1977        assert!(
1978            resolved
1979                .ignore_patterns
1980                .is_match("packages/ui/dist/index.js")
1981        );
1982    }
1983
1984    /// Issue #2638: source discovery maps a `GlobSet` match index back to the
1985    /// pattern text through this layout, so the split point and the order of
1986    /// the built-ins are an output contract, not an implementation detail.
1987    /// A future reordering of `compile_ignore_patterns` fails here first.
1988    #[test]
1989    fn the_compiled_ignore_union_puts_user_patterns_first_and_the_built_ins_in_order() {
1990        let mut config = make_config(false);
1991        config.ignore_patterns = vec!["vendor/**".to_owned(), "legacy/**".to_owned()];
1992        let resolved = config.resolve(
1993            PathBuf::from("/project"),
1994            OutputFormat::Human,
1995            1,
1996            true,
1997            true,
1998            None,
1999        );
2000
2001        assert_eq!(resolved.user_ignore_pattern_count, 2);
2002        assert_eq!(
2003            resolved.ignore_patterns.len(),
2004            2 + DEFAULT_IGNORE_PATTERNS.len(),
2005            "the union only ever adds"
2006        );
2007        assert_eq!(
2008            resolved.ignore_patterns.matches("vendor/a.ts"),
2009            vec![0],
2010            "a user pattern keeps its configured index"
2011        );
2012        for (offset, pattern) in DEFAULT_IGNORE_PATTERNS.iter().enumerate() {
2013            let sample = match *pattern {
2014                "**/node_modules/**" => "node_modules/react/index.js",
2015                "**/dist/**" => "dist/a.ts",
2016                "**/build/**" => "build/a.ts",
2017                "**/.git/**" => ".git/hooks/a.js",
2018                "**/coverage/**" => "coverage/a.ts",
2019                "**/*.min.js" => "a.min.js",
2020                "**/*.min.mjs" => "a.min.mjs",
2021                "**/*.min.cjs" => "a.min.cjs",
2022                "**/*.bundle.js" => "a.bundle.js",
2023                other => panic!("no sample path for built-in ignore {other}"),
2024            };
2025            assert_eq!(
2026                resolved.ignore_patterns.matches(sample),
2027                vec![resolved.user_ignore_pattern_count + offset],
2028                "{pattern} must sit at its DEFAULT_IGNORE_PATTERNS offset"
2029            );
2030        }
2031    }
2032
2033    /// A file both a user pattern and a built-in match reports the user index
2034    /// first, which is how discovery tells an explicit project choice from a
2035    /// built-in the user never asked for (issue #2638).
2036    #[test]
2037    fn a_user_pattern_that_overlaps_a_built_in_matches_at_the_lower_index() {
2038        let mut config = make_config(false);
2039        config.ignore_patterns = vec!["dist/**".to_owned()];
2040        let resolved = config.resolve(
2041            PathBuf::from("/project"),
2042            OutputFormat::Human,
2043            1,
2044            true,
2045            true,
2046            None,
2047        );
2048
2049        let matches = resolved.ignore_patterns.matches("dist/a.ts");
2050        assert_eq!(matches.first(), Some(&0));
2051        assert!(matches.len() > 1, "the built-in still matches too");
2052    }
2053
2054    #[test]
2055    fn resolve_default_ignores_build_at_any_depth() {
2056        let resolved = make_config(false).resolve(
2057            PathBuf::from("/project"),
2058            OutputFormat::Human,
2059            1,
2060            true,
2061            true,
2062            None,
2063        );
2064        assert!(
2065            resolved.ignore_patterns.is_match("build/output.js"),
2066            "root build/ should be ignored"
2067        );
2068        assert!(
2069            resolved.ignore_patterns.is_match("src/build/helper.ts"),
2070            "nested build/ should be ignored, like dist/ and coverage/"
2071        );
2072        assert!(
2073            resolved
2074                .ignore_patterns
2075                .is_match("projects/app/build/index.js"),
2076            "build/ inside a workspace package should be ignored"
2077        );
2078    }
2079
2080    #[test]
2081    fn resolve_default_ignores_match_build_only_as_a_whole_segment() {
2082        let resolved = make_config(false).resolve(
2083            PathBuf::from("/project"),
2084            OutputFormat::Human,
2085            1,
2086            true,
2087            true,
2088            None,
2089        );
2090        assert!(!resolved.ignore_patterns.is_match("src/build.ts"));
2091        assert!(!resolved.ignore_patterns.is_match("src/rebuild/helper.ts"));
2092        assert!(!resolved.ignore_patterns.is_match("src/buildings/a.ts"));
2093        assert!(!resolved.ignore_patterns.is_match("src/prebuild/a.ts"));
2094    }
2095
2096    /// A workspace package directory named `build` keeps its entry in workspace
2097    /// discovery, because a declared member holding a manifest survives
2098    /// `ignorePatterns`, but everything inside it is filtered out: the source
2099    /// files never reach the walker in `crates/core/src/discover/walk.rs`, and
2100    /// the manifest never reaches the dependency filter in
2101    /// `crates/core/src/analyze/unused_deps.rs`. Both consumers read this one
2102    /// globset, so pin both paths here and keep the consequence a deliberate
2103    /// choice rather than a documentation guess.
2104    #[test]
2105    fn resolve_default_ignores_cover_a_workspace_package_named_build() {
2106        let resolved = make_config(false).resolve(
2107            PathBuf::from("/project"),
2108            OutputFormat::Human,
2109            1,
2110            true,
2111            true,
2112            None,
2113        );
2114        assert!(
2115            resolved
2116                .ignore_patterns
2117                .is_match("packages/build/package.json"),
2118            "the manifest stops contributing unused-dependency findings"
2119        );
2120        assert!(
2121            resolved
2122                .ignore_patterns
2123                .is_match("packages/build/src/index.ts"),
2124            "the package's source stops being analyzed entirely"
2125        );
2126        assert!(
2127            resolved
2128                .ignore_patterns
2129                .is_match("packages/build/src/nested/deep.ts"),
2130            "including source below the package's own subdirectories"
2131        );
2132    }
2133
2134    /// A framework config inside a nested `build/` directory is filtered out of
2135    /// discovery with everything else under the segment, so the path aliases it
2136    /// declares are lost and imports through them are reported as unlisted
2137    /// dependencies. Pinned so that consequence is a recorded choice.
2138    #[test]
2139    fn resolve_default_ignores_cover_a_framework_config_inside_build() {
2140        let resolved = make_config(false).resolve(
2141            PathBuf::from("/project"),
2142            OutputFormat::Human,
2143            1,
2144            true,
2145            true,
2146            None,
2147        );
2148        assert!(
2149            resolved
2150                .ignore_patterns
2151                .is_match("app/build/webpack.config.js")
2152        );
2153    }
2154
2155    #[test]
2156    fn resolve_default_ignores_minified_files() {
2157        let resolved = make_config(false).resolve(
2158            PathBuf::from("/project"),
2159            OutputFormat::Human,
2160            1,
2161            true,
2162            true,
2163            None,
2164        );
2165        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
2166        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
2167        assert!(resolved.ignore_patterns.is_match("lib/legacy.min.cjs"));
2168        assert!(resolved.ignore_patterns.is_match("public/app.bundle.js"));
2169        assert!(
2170            resolved
2171                .ignore_patterns
2172                .is_match("src/vendor/app.bundle.js")
2173        );
2174        // Hand-written source with a similar name stays analyzed.
2175        assert!(!resolved.ignore_patterns.is_match("src/bundle.ts"));
2176        assert!(!resolved.ignore_patterns.is_match("src/app.cjs"));
2177    }
2178
2179    #[test]
2180    fn resolve_max_file_size_bytes_default_and_unlimited() {
2181        // Unset keeps the built-in default.
2182        assert_eq!(
2183            resolve_max_file_size_bytes(None),
2184            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
2185        );
2186        // `0` means no limit.
2187        assert_eq!(resolve_max_file_size_bytes(Some(0)), None);
2188        // Any other value is that many megabytes in bytes.
2189        assert_eq!(resolve_max_file_size_bytes(Some(2)), Some(2 * 1024 * 1024));
2190        assert_eq!(DEFAULT_MAX_FILE_SIZE_MB, 5);
2191    }
2192
2193    #[test]
2194    fn resolve_sets_default_max_file_size() {
2195        let resolved = make_config(false).resolve(
2196            PathBuf::from("/project"),
2197            OutputFormat::Human,
2198            1,
2199            true,
2200            true,
2201            None,
2202        );
2203        assert_eq!(
2204            resolved.max_file_size_bytes,
2205            Some(DEFAULT_MAX_FILE_SIZE_BYTES)
2206        );
2207    }
2208
2209    #[test]
2210    fn resolve_default_ignores_git() {
2211        let resolved = make_config(false).resolve(
2212            PathBuf::from("/project"),
2213            OutputFormat::Human,
2214            1,
2215            true,
2216            true,
2217            None,
2218        );
2219        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
2220    }
2221
2222    #[test]
2223    fn resolve_default_ignores_coverage() {
2224        let resolved = make_config(false).resolve(
2225            PathBuf::from("/project"),
2226            OutputFormat::Human,
2227            1,
2228            true,
2229            true,
2230            None,
2231        );
2232        assert!(
2233            resolved
2234                .ignore_patterns
2235                .is_match("coverage/lcov-report/index.js")
2236        );
2237    }
2238
2239    #[test]
2240    fn resolve_source_files_not_ignored_by_default() {
2241        let resolved = make_config(false).resolve(
2242            PathBuf::from("/project"),
2243            OutputFormat::Human,
2244            1,
2245            true,
2246            true,
2247            None,
2248        );
2249        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
2250        assert!(
2251            !resolved
2252                .ignore_patterns
2253                .is_match("src/components/Button.tsx")
2254        );
2255        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
2256    }
2257
2258    #[test]
2259    fn resolve_custom_ignore_patterns_merged_with_defaults() {
2260        let mut config = make_config(false);
2261        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
2262        let resolved = config.resolve(
2263            PathBuf::from("/project"),
2264            OutputFormat::Human,
2265            1,
2266            true,
2267            true,
2268            None,
2269        );
2270        assert!(
2271            resolved
2272                .ignore_patterns
2273                .is_match("src/__generated__/types.ts")
2274        );
2275        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
2276    }
2277
2278    #[test]
2279    fn resolve_normalizes_leading_dot_ignore_patterns() {
2280        let mut config = make_config(false);
2281        config.ignore_patterns = vec!["./src/generated/**".to_string()];
2282        let resolved = config.resolve(
2283            PathBuf::from("/project"),
2284            OutputFormat::Human,
2285            1,
2286            true,
2287            true,
2288            None,
2289        );
2290
2291        assert!(resolved.ignore_patterns.is_match("src/generated/client.ts"));
2292        assert!(
2293            !resolved
2294                .ignore_patterns
2295                .is_match("./src/generated/client.ts")
2296        );
2297    }
2298
2299    #[test]
2300    fn resolve_normalizes_leading_dot_ignore_unresolved_imports() {
2301        let mut config = make_config(false);
2302        config.ignore_unresolved_imports = vec!["./src/generated/**".to_string()];
2303        let resolved = config.resolve(
2304            PathBuf::from("/project"),
2305            OutputFormat::Human,
2306            1,
2307            true,
2308            true,
2309            None,
2310        );
2311
2312        assert!(
2313            resolved
2314                .ignore_unresolved_imports
2315                .iter()
2316                .any(|matcher| matcher.is_match("src/generated/client"))
2317        );
2318        assert!(
2319            !resolved
2320                .ignore_unresolved_imports
2321                .iter()
2322                .any(|matcher| matcher.is_match("./src/generated/client"))
2323        );
2324    }
2325
2326    #[test]
2327    fn resolve_passes_through_entry_patterns() {
2328        let mut config = make_config(false);
2329        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
2330        let resolved = config.resolve(
2331            PathBuf::from("/project"),
2332            OutputFormat::Human,
2333            1,
2334            true,
2335            true,
2336            None,
2337        );
2338        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
2339    }
2340
2341    #[test]
2342    fn resolve_passes_through_ignore_dependencies() {
2343        let mut config = make_config(false);
2344        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
2345        let resolved = config.resolve(
2346            PathBuf::from("/project"),
2347            OutputFormat::Human,
2348            1,
2349            true,
2350            true,
2351            None,
2352        );
2353        assert!(resolved.ignore_dependencies.is_ignored("postcss"));
2354        assert!(resolved.ignore_dependencies.is_ignored("autoprefixer"));
2355        assert!(!resolved.ignore_dependencies.is_ignored("postcss-cli"));
2356    }
2357
2358    #[test]
2359    fn resolve_passes_through_ignore_command_entries() {
2360        let mut config = make_config(false);
2361        config.ignore_command_entries = vec!["my-codegen".to_string()];
2362        let resolved = config.resolve(
2363            PathBuf::from("/project"),
2364            OutputFormat::Human,
2365            1,
2366            true,
2367            true,
2368            None,
2369        );
2370        assert_eq!(resolved.ignore_command_entries, vec!["my-codegen"]);
2371    }
2372
2373    #[test]
2374    fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
2375        let mut config = make_config(false);
2376        config.ignore_unresolved_imports = vec![
2377            "@example/icons".to_string(),
2378            "@example/icons/**".to_string(),
2379            "../generated/**".to_string(),
2380        ];
2381        let resolved = config.resolve(
2382            PathBuf::from("/project"),
2383            OutputFormat::Human,
2384            1,
2385            true,
2386            true,
2387            None,
2388        );
2389
2390        assert!(
2391            resolved
2392                .ignore_unresolved_imports
2393                .iter()
2394                .any(|matcher| matcher.is_match("@example/icons"))
2395        );
2396        assert!(
2397            resolved
2398                .ignore_unresolved_imports
2399                .iter()
2400                .any(|matcher| matcher.is_match("@example/icons/metadata"))
2401        );
2402        assert!(
2403            resolved
2404                .ignore_unresolved_imports
2405                .iter()
2406                .any(|matcher| matcher.is_match("../generated/client"))
2407        );
2408    }
2409
2410    #[test]
2411    fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
2412        let mut config = make_config(false);
2413        config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
2414        let resolved = config.resolve(
2415            PathBuf::from("/project"),
2416            OutputFormat::Human,
2417            1,
2418            true,
2419            true,
2420            None,
2421        );
2422
2423        assert!(
2424            !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
2425            "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
2426        );
2427        assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
2428    }
2429
2430    #[test]
2431    fn resolve_sets_cache_dir() {
2432        let resolved = make_config(false).resolve(
2433            PathBuf::from("/my/project"),
2434            OutputFormat::Human,
2435            1,
2436            true,
2437            true,
2438            None,
2439        );
2440        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
2441    }
2442
2443    #[test]
2444    fn resolve_uses_relative_configured_cache_dir_from_root() {
2445        let config = FallowConfig {
2446            cache: crate::CacheConfig {
2447                dir: Some(PathBuf::from(".cache/fallow")),
2448                ..Default::default()
2449            },
2450            ..make_config(false)
2451        };
2452        let resolved = config.resolve(
2453            PathBuf::from("/my/project"),
2454            OutputFormat::Human,
2455            1,
2456            false,
2457            true,
2458            None,
2459        );
2460        assert_eq!(
2461            resolved.cache_dir,
2462            PathBuf::from("/my/project/.cache/fallow")
2463        );
2464    }
2465
2466    #[test]
2467    fn cache_dir_override_wins_over_configured_cache_dir() {
2468        let config = FallowConfig {
2469            cache: crate::CacheConfig {
2470                dir: Some(PathBuf::from(".cache/from-config")),
2471                ..Default::default()
2472            },
2473            ..make_config(false)
2474        };
2475        let mut resolved = config.resolve(
2476            PathBuf::from("/my/project"),
2477            OutputFormat::Human,
2478            1,
2479            false,
2480            true,
2481            None,
2482        );
2483
2484        resolved.override_cache_dir(PathBuf::from(".cache/from-env"));
2485        assert_eq!(
2486            resolved.cache_dir,
2487            PathBuf::from("/my/project/.cache/from-env")
2488        );
2489        resolved.override_cache_dir(PathBuf::from("/tmp/fallow-cache"));
2490        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
2491    }
2492
2493    fn resolved_with_cache_dir(root: &str, cache_dir: &str) -> ResolvedConfig {
2494        let mut resolved = make_config(false).resolve(
2495            PathBuf::from(root),
2496            OutputFormat::Human,
2497            1,
2498            false,
2499            true,
2500            None,
2501        );
2502        resolved.override_cache_dir(PathBuf::from(cache_dir));
2503        resolved
2504    }
2505
2506    #[test]
2507    fn scoped_shared_cache_dir_gives_each_root_its_own_subdirectory() {
2508        let mut first = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2509        let mut second = resolved_with_cache_dir("/other/app", "/var/cache/fallow");
2510        first.scope_shared_cache_dir_to_root();
2511        second.scope_shared_cache_dir_to_root();
2512
2513        assert_eq!(
2514            first.cache_dir.parent(),
2515            Some(Path::new("/var/cache/fallow"))
2516        );
2517        assert_eq!(
2518            second.cache_dir.parent(),
2519            Some(Path::new("/var/cache/fallow"))
2520        );
2521        assert_ne!(
2522            first.cache_dir, second.cache_dir,
2523            "two roots with the same folder name must not share a cache"
2524        );
2525        let name = first
2526            .cache_dir
2527            .file_name()
2528            .unwrap()
2529            .to_string_lossy()
2530            .into_owned();
2531        assert!(name.starts_with("app-"), "the folder name leads: {name}");
2532    }
2533
2534    #[test]
2535    fn scoped_cache_dir_is_stable_for_one_root() {
2536        let mut first = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2537        let mut again = resolved_with_cache_dir("/work/app", "/var/cache/fallow");
2538        first.scope_shared_cache_dir_to_root();
2539        again.scope_shared_cache_dir_to_root();
2540        assert_eq!(first.cache_dir, again.cache_dir);
2541    }
2542
2543    #[test]
2544    fn scoping_keeps_a_cache_dir_inside_the_root() {
2545        let mut relative = resolved_with_cache_dir("/work/app", ".cache/fallow");
2546        relative.scope_shared_cache_dir_to_root();
2547        assert_eq!(relative.cache_dir, PathBuf::from("/work/app/.cache/fallow"));
2548
2549        let mut absolute = resolved_with_cache_dir("/work/app", "/work/app/.fallow");
2550        absolute.scope_shared_cache_dir_to_root();
2551        assert_eq!(absolute.cache_dir, PathBuf::from("/work/app/.fallow"));
2552    }
2553
2554    #[test]
2555    fn resolve_keeps_absolute_configured_cache_dir() {
2556        let config = FallowConfig {
2557            cache: crate::CacheConfig {
2558                dir: Some(PathBuf::from("/tmp/fallow-cache")),
2559                ..Default::default()
2560            },
2561            ..make_config(false)
2562        };
2563        let resolved = config.resolve(
2564            PathBuf::from("/my/project"),
2565            OutputFormat::Human,
2566            1,
2567            false,
2568            true,
2569            None,
2570        );
2571        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/fallow-cache"));
2572    }
2573
2574    #[test]
2575    fn resolve_passes_through_thread_count() {
2576        let resolved = make_config(false).resolve(
2577            PathBuf::from("/project"),
2578            OutputFormat::Human,
2579            8,
2580            true,
2581            true,
2582            None,
2583        );
2584        assert_eq!(resolved.threads, 8);
2585    }
2586
2587    #[test]
2588    fn resolve_passes_through_quiet_flag() {
2589        let resolved = make_config(false).resolve(
2590            PathBuf::from("/project"),
2591            OutputFormat::Human,
2592            1,
2593            true,
2594            false,
2595            None,
2596        );
2597        assert!(!resolved.quiet);
2598
2599        let resolved2 = make_config(false).resolve(
2600            PathBuf::from("/project"),
2601            OutputFormat::Human,
2602            1,
2603            true,
2604            true,
2605            None,
2606        );
2607        assert!(resolved2.quiet);
2608    }
2609
2610    #[test]
2611    fn resolve_passes_through_no_cache_flag() {
2612        let resolved_no_cache = make_config(false).resolve(
2613            PathBuf::from("/project"),
2614            OutputFormat::Human,
2615            1,
2616            true,
2617            true,
2618            None,
2619        );
2620        assert!(resolved_no_cache.no_cache);
2621
2622        let resolved_with_cache = make_config(false).resolve(
2623            PathBuf::from("/project"),
2624            OutputFormat::Human,
2625            1,
2626            false,
2627            true,
2628            None,
2629        );
2630        assert!(!resolved_with_cache.no_cache);
2631    }
2632
2633    #[test]
2634    #[should_panic(expected = "validated at config load time")]
2635    fn resolve_panics_on_unvalidated_invalid_override_glob() {
2636        let mut config = make_config(false);
2637        config.overrides = vec![ConfigOverride {
2638            files: vec!["[invalid".to_string()],
2639            rules: PartialRulesConfig {
2640                unused_files: Some(Severity::Off),
2641                ..Default::default()
2642            },
2643        }];
2644        let _ = config.resolve(
2645            PathBuf::from("/project"),
2646            OutputFormat::Human,
2647            1,
2648            true,
2649            true,
2650            None,
2651        );
2652    }
2653
2654    #[test]
2655    fn resolve_override_with_empty_files_skipped() {
2656        let mut config = make_config(false);
2657        config.overrides = vec![ConfigOverride {
2658            files: vec![],
2659            rules: PartialRulesConfig {
2660                unused_files: Some(Severity::Off),
2661                ..Default::default()
2662            },
2663        }];
2664        let resolved = config.resolve(
2665            PathBuf::from("/project"),
2666            OutputFormat::Human,
2667            1,
2668            true,
2669            true,
2670            None,
2671        );
2672        assert!(
2673            resolved.overrides.is_empty(),
2674            "override with no file patterns should be skipped"
2675        );
2676    }
2677
2678    #[test]
2679    fn resolve_multiple_valid_overrides() {
2680        let mut config = make_config(false);
2681        config.overrides = vec![
2682            ConfigOverride {
2683                files: vec!["*.test.ts".to_string()],
2684                rules: PartialRulesConfig {
2685                    unused_exports: Some(Severity::Off),
2686                    ..Default::default()
2687                },
2688            },
2689            ConfigOverride {
2690                files: vec!["*.stories.tsx".to_string()],
2691                rules: PartialRulesConfig {
2692                    unused_files: Some(Severity::Off),
2693                    ..Default::default()
2694                },
2695            },
2696        ];
2697        let resolved = config.resolve(
2698            PathBuf::from("/project"),
2699            OutputFormat::Human,
2700            1,
2701            true,
2702            true,
2703            None,
2704        );
2705        assert_eq!(resolved.overrides.len(), 2);
2706    }
2707
2708    #[test]
2709    fn ignore_export_rule_deserialize() {
2710        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
2711        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2712        assert_eq!(rule.file, "src/types/*.ts");
2713        assert_eq!(rule.exports, vec!["*"]);
2714    }
2715
2716    #[test]
2717    fn ignore_export_rule_specific_exports() {
2718        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
2719        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
2720        assert_eq!(rule.exports.len(), 3);
2721        assert!(rule.exports.contains(&"FOO".to_string()));
2722    }
2723
2724    mod proptests {
2725        use super::*;
2726        use proptest::prelude::*;
2727
2728        fn arb_resolved_config(production: bool) -> ResolvedConfig {
2729            make_config(production).resolve(
2730                PathBuf::from("/project"),
2731                OutputFormat::Human,
2732                1,
2733                true,
2734                true,
2735                None,
2736            )
2737        }
2738
2739        proptest! {
2740            /// Resolved config always has non-empty ignore patterns (defaults are always added).
2741            #[test]
2742            fn resolved_config_has_default_ignores(production in any::<bool>()) {
2743                let resolved = arb_resolved_config(production);
2744                prop_assert!(
2745                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2746                    "Default ignore should match node_modules"
2747                );
2748                prop_assert!(
2749                    resolved.ignore_patterns.is_match("dist/bundle.js"),
2750                    "Default ignore should match dist"
2751                );
2752            }
2753
2754            /// Production mode always forces dev and optional deps to Off.
2755            #[test]
2756            fn production_forces_dev_deps_off(_unused in Just(())) {
2757                let resolved = arb_resolved_config(true);
2758                prop_assert_eq!(
2759                    resolved.rules.unused_dev_dependencies,
2760                    Severity::Off,
2761                    "Production should force unused_dev_dependencies off"
2762                );
2763                prop_assert_eq!(
2764                    resolved.rules.unused_optional_dependencies,
2765                    Severity::Off,
2766                    "Production should force unused_optional_dependencies off"
2767                );
2768            }
2769
2770            /// Non-production mode preserves default severity for dev deps.
2771            #[test]
2772            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
2773                let resolved = arb_resolved_config(false);
2774                prop_assert_eq!(
2775                    resolved.rules.unused_dev_dependencies,
2776                    Severity::Warn,
2777                    "Non-production should keep default dev dep severity"
2778                );
2779            }
2780
2781            /// Default cache dir is root/.fallow.
2782            #[test]
2783            fn cache_dir_defaults_to_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
2784                let root = PathBuf::from(format!("/project/{dir_suffix}"));
2785                let expected_cache = root.join(".fallow");
2786                let resolved = make_config(false).resolve(
2787                    root,
2788                    OutputFormat::Human,
2789                    1,
2790                    true,
2791                    true,
2792                    None,
2793                );
2794                prop_assert_eq!(
2795                    resolved.cache_dir, expected_cache,
2796                    "Default cache dir should be root/.fallow"
2797                );
2798            }
2799
2800            /// Thread count is always passed through exactly.
2801            #[test]
2802            fn threads_passed_through(threads in 1..64usize) {
2803                let resolved = make_config(false).resolve(
2804                    PathBuf::from("/project"),
2805                    OutputFormat::Human,
2806                    threads,
2807                    true,
2808                    true, None,
2809                );
2810                prop_assert_eq!(
2811                    resolved.threads, threads,
2812                    "Thread count should be passed through"
2813                );
2814            }
2815
2816            /// Custom ignore patterns are merged with defaults, not replacing them.
2817            /// Uses a pattern regex that cannot match node_modules paths, so the
2818            /// assertion proves the default pattern is what provides the match.
2819            #[test]
2820            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
2821                let mut config = make_config(false);
2822                config.ignore_patterns = vec![pattern];
2823                let resolved = config.resolve(
2824                    PathBuf::from("/project"),
2825                    OutputFormat::Human,
2826                    1,
2827                    true,
2828                    true, None,
2829                );
2830                prop_assert!(
2831                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
2832                    "Default node_modules ignore should still be active"
2833                );
2834            }
2835        }
2836    }
2837
2838    #[test]
2839    fn resolve_expands_boundary_preset() {
2840        use crate::config::boundaries::BoundaryPreset;
2841
2842        let mut config = make_config(false);
2843        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2844        let resolved = config.resolve(
2845            PathBuf::from("/project"),
2846            OutputFormat::Human,
2847            1,
2848            true,
2849            true,
2850            None,
2851        );
2852        assert_eq!(resolved.boundaries.zones.len(), 3);
2853        assert_eq!(resolved.boundaries.rules.len(), 3);
2854        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
2855        assert_eq!(
2856            resolved.boundaries.classify_zone("src/adapters/http.ts"),
2857            Some("adapters")
2858        );
2859    }
2860
2861    #[test]
2862    fn resolve_boundary_preset_with_user_override() {
2863        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
2864
2865        let mut config = make_config(false);
2866        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
2867        config.boundaries.zones = vec![BoundaryZone {
2868            name: "domain".to_string(),
2869            patterns: vec!["src/core/**".to_string()],
2870            auto_discover: vec![],
2871            root: None,
2872        }];
2873        let resolved = config.resolve(
2874            PathBuf::from("/project"),
2875            OutputFormat::Human,
2876            1,
2877            true,
2878            true,
2879            None,
2880        );
2881        assert_eq!(resolved.boundaries.zones.len(), 3);
2882        assert_eq!(
2883            resolved.boundaries.classify_zone("src/core/user.ts"),
2884            Some("domain")
2885        );
2886        assert_eq!(
2887            resolved.boundaries.classify_zone("src/domain/user.ts"),
2888            None
2889        );
2890    }
2891
2892    #[test]
2893    fn resolve_no_preset_unchanged() {
2894        let config = make_config(false);
2895        let resolved = config.resolve(
2896            PathBuf::from("/project"),
2897            OutputFormat::Human,
2898            1,
2899            true,
2900            true,
2901            None,
2902        );
2903        assert!(resolved.boundaries.is_empty());
2904    }
2905}