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    /// Per-file severity overrides with globs pre-compiled, in config order.
333    pub overrides: Vec<ResolvedOverride>,
334    /// Authored Git baseline refs keyed by workspace root; validated against
335    /// discovered packages when an analysis requests package-scoped changes.
336    pub workspace_changed_since: BTreeMap<String, String>,
337    /// Workspace package root globs from `workspaces.patterns`, added to the
338    /// patterns that the manifests declare.
339    pub workspace_patterns: Vec<String>,
340    /// Saved regression baseline for `--fail-on-regression`, when embedded.
341    pub regression: Option<super::RegressionConfig>,
342    /// In-repo `fallow audit` defaults, passed through unchanged.
343    pub audit: super::AuditConfig,
344    /// Configured CODEOWNERS path override; `None` probes the standard
345    /// locations.
346    pub codeowners: Option<String>,
347    /// Workspace package names (or globs over them) whose public API is
348    /// treated as externally consumed, making their export surface a
349    /// reachability root.
350    pub public_packages: Vec<String>,
351    /// Feature-flag detection settings, passed through unchanged.
352    pub flags: FlagsConfig,
353    /// Security catalogue scoping with `requestReceivers` normalized
354    /// (trimmed, lowercased, deduplicated).
355    pub security: SecurityConfig,
356    /// `fallow fix` behavior settings, passed through unchanged.
357    pub fix: super::FixConfig,
358    /// Module-resolver settings (extra `exports` conditions), passed through
359    /// unchanged.
360    pub resolve: ResolveConfig,
361    /// When true, entry-point exports are subject to `unused-export`
362    /// detection instead of being auto-credited as used.
363    pub include_entry_exports: bool,
364    /// When true, the report keeps the export and member findings of an
365    /// unused file. When false, the rule pass hides them.
366    pub show_cascade: bool,
367    /// When true, drop Nuxt convention entry-pattern fallbacks that
368    /// `nuxt.config` does not explicitly declare; auto-import graph edges are
369    /// synthesized regardless.
370    pub auto_imports: bool,
371    /// When true, a source file that did not parse cleanly fails the run
372    /// through the `parse-error` gate. The CLI flag `--fail-on-parse-error`
373    /// arms the same gate.
374    pub fail_on_parse_error: bool,
375    /// Source files strictly larger than this many bytes are skipped at
376    /// discovery (never read, parsed, or analyzed), guarding against the
377    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
378    /// causes (issue #1086). `None` means no limit. Declaration files
379    /// (`.d.ts`/`.d.mts`/`.d.cts`) are exempt regardless of size because they
380    /// are reachability roots for global types. Defaults to
381    /// [`DEFAULT_MAX_FILE_SIZE_MB`] MB; the CLI overrides it post-resolve from
382    /// `--max-file-size` / `FALLOW_MAX_FILE_SIZE` (`0` = unlimited).
383    pub max_file_size_bytes: Option<u64>,
384    /// Which revision this analysis pass describes. Always
385    /// [`AnalysisSnapshot::Current`] out of [`FallowConfig::resolve`]; the CLI
386    /// sets [`AnalysisSnapshot::Base`] post-resolve for the isolated
387    /// `audit --base` pass so diagnostics can name the base revision.
388    pub analysis_snapshot: AnalysisSnapshot,
389    /// A stable hash of the config that decides which dead-code findings a
390    /// run reports: the merged user config after `extends`, without the keys
391    /// that only shape other commands or output, plus the loaded external
392    /// plugin and rule pack definitions. It holds no path of this machine,
393    /// so two checkouts of one commit give the same value. Settings that a
394    /// surface changes after resolution (for example `--include-entry-exports`)
395    /// are not in it; the analysis fingerprint adds them.
396    pub detection_config_digest: String,
397    /// User config path glob lists with a single `*` or `?` that matched `/`
398    /// under the old glob rule. Source discovery compares the old and the new
399    /// rule on these lists and warns for each pattern that lost matches.
400    pub legacy_glob_groups: Vec<super::LegacyGlobGroup>,
401}
402
403/// Top-level config keys that do not change which dead-code findings a run
404/// reports. The detection digest leaves them out, so a health threshold or a
405/// cache setting does not change the analysis fingerprint.
406const NON_DETECTION_CONFIG_KEYS: &[&str] = &[
407    "minimumVersion",
408    "duplicates",
409    "similarCode",
410    "mutation",
411    "health",
412    "security",
413    "fix",
414    "codeowners",
415    "regression",
416    "audit",
417    "failOnParseError",
418    "cache",
419];
420
421/// Serialize `value` with every object key sorted, so a map field gives the
422/// same bytes whatever its insertion order.
423fn canonical_json(value: serde_json::Value) -> serde_json::Value {
424    match value {
425        serde_json::Value::Object(map) => {
426            let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
427            entries.sort_by(|a, b| a.0.cmp(&b.0));
428            serde_json::Value::Object(
429                entries
430                    .into_iter()
431                    .map(|(key, value)| (key, canonical_json(value)))
432                    .collect(),
433            )
434        }
435        serde_json::Value::Array(items) => {
436            serde_json::Value::Array(items.into_iter().map(canonical_json).collect())
437        }
438        other => other,
439    }
440}
441
442fn canonical_json_string<T: Serialize>(value: &T) -> String {
443    serde_json::to_value(value)
444        .map(canonical_json)
445        .map(|value| value.to_string())
446        .unwrap_or_default()
447}
448
449/// The canonical JSON of the detection-affecting user config keys.
450fn detection_config_json(config: &FallowConfig) -> String {
451    let mut value = serde_json::to_value(config).unwrap_or_default();
452    if let serde_json::Value::Object(map) = &mut value {
453        for key in NON_DETECTION_CONFIG_KEYS {
454            map.shift_remove(*key);
455        }
456    }
457    canonical_json(value).to_string()
458}
459
460/// Default per-file size ceiling (in megabytes) for source discovery. A value
461/// chosen so hand-written source effectively never reaches it while generated
462/// API clients, vendored bundles, and minified blobs do. See issue #1086.
463pub const DEFAULT_MAX_FILE_SIZE_MB: u32 = 5;
464
465/// [`DEFAULT_MAX_FILE_SIZE_MB`] expressed in bytes.
466pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = DEFAULT_MAX_FILE_SIZE_MB as u64 * 1024 * 1024;
467
468/// Convert a user-supplied megabyte ceiling into the byte limit stored on
469/// [`ResolvedConfig::max_file_size_bytes`]. `Some(0)` means "no limit"
470/// (`None`); any other `Some(n)` is `n` MB in bytes; `None` (unset) keeps the
471/// built-in [`DEFAULT_MAX_FILE_SIZE_BYTES`].
472#[must_use]
473pub fn resolve_max_file_size_bytes(max_file_size_mb: Option<u32>) -> Option<u64> {
474    match max_file_size_mb {
475        None => Some(DEFAULT_MAX_FILE_SIZE_BYTES),
476        Some(0) => None,
477        Some(mb) => Some(u64::from(mb) * 1024 * 1024),
478    }
479}
480
481/// Hash the extraction-affecting configuration a persisted cache is keyed on:
482/// the external plugin names and the user flag patterns, which the parse
483/// applies. Built-in-only flag patterns add nothing, so a config without a
484/// `flags` section keeps the hash it had before flag patterns joined it.
485///
486/// Public because a run is not the only thing that needs it: `fallow doctor`
487/// inspects the cache without running an analysis, and it resolves config with
488/// caching disabled, which zeroes the stored hash. Comparing against that zero
489/// reported every healthy cache as config drift.
490#[must_use]
491pub fn cache_config_hash(external_plugins: &[ExternalPluginDef], flags: &FlagsConfig) -> u64 {
492    let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
493    names.sort_unstable();
494    let mut hasher = xxhash_rust::xxh3::Xxh3::new();
495    for name in names {
496        hash_str(&mut hasher, name);
497    }
498    let patterns = flags.patterns();
499    if !patterns.is_builtin_only() {
500        hasher.update(b"\0flags");
501        hasher.update(&(patterns.sdk_patterns.len() as u64).to_le_bytes());
502        for (function, name_arg, provider) in &patterns.sdk_patterns {
503            hash_str(&mut hasher, function);
504            hasher.update(&(*name_arg as u64).to_le_bytes());
505            hash_str(&mut hasher, provider);
506        }
507        hasher.update(&(patterns.env_prefixes.len() as u64).to_le_bytes());
508        for prefix in &patterns.env_prefixes {
509            hash_str(&mut hasher, prefix);
510        }
511        hasher.update(&[u8::from(patterns.config_object_heuristics)]);
512    }
513    hasher.digest()
514}
515
516fn hash_str(hasher: &mut xxhash_rust::xxh3::Xxh3, value: &str) {
517    hasher.update(&(value.len() as u32).to_le_bytes());
518    hasher.update(value.as_bytes());
519}
520
521/// Environment variable that moves the persistent analysis cache.
522pub const CACHE_DIR_ENV: &str = "FALLOW_CACHE_DIR";
523
524/// Read the non-empty `FALLOW_CACHE_DIR` value from the process environment.
525///
526/// Every host that loads a project config (CLI, LSP, MCP, Node bindings)
527/// applies this value with [`ResolvedConfig::override_cache_dir`], so the
528/// variable has one meaning on every surface. It wins over `cache.dir`.
529#[must_use]
530pub fn cache_dir_env_override() -> Option<PathBuf> {
531    std::env::var_os(CACHE_DIR_ENV).and_then(cache_dir_from_env_value)
532}
533
534/// Parse a raw `FALLOW_CACHE_DIR` value. An empty value is no override.
535#[must_use]
536pub fn cache_dir_from_env_value(raw: OsString) -> Option<PathBuf> {
537    Some(PathBuf::from(raw)).filter(|path| !path.as_os_str().is_empty())
538}
539
540/// Environment variable that caps the extraction cache size in megabytes.
541/// It is not a CLI flag because the cap is a platform or CI concern, not an
542/// analysis input (ADR-009).
543pub const CACHE_MAX_SIZE_ENV: &str = "FALLOW_CACHE_MAX_SIZE";
544
545/// Read `FALLOW_CACHE_MAX_SIZE` from the process environment. It wins over
546/// `cache.maxSizeMb` on every host, like [`cache_dir_env_override`].
547#[must_use]
548pub fn cache_max_size_env_override() -> Option<u32> {
549    std::env::var_os(CACHE_MAX_SIZE_ENV)
550        .as_deref()
551        .and_then(cache_max_size_from_env_value)
552}
553
554/// Parse a raw `FALLOW_CACHE_MAX_SIZE` value. Only a positive whole number of
555/// megabytes is an override.
556#[must_use]
557pub fn cache_max_size_from_env_value(raw: &OsStr) -> Option<u32> {
558    raw.to_str()?
559        .trim()
560        .parse::<u32>()
561        .ok()
562        .filter(|mb| *mb > 0)
563}
564
565/// Environment variable that turns `workspaces.changedSince` off for every
566/// run of a process, like `--no-package-baselines` does for one run. A CI job
567/// that saves or gates a whole-project baseline sets it once.
568pub const PACKAGE_BASELINES_ENV: &str = "FALLOW_PACKAGE_BASELINES";
569
570/// Whether a raw `FALLOW_PACKAGE_BASELINES` value turns the package map off.
571/// `false`, `0`, `no` and `off` do, in any case. Every other value keeps the
572/// map, so a typo never widens a run.
573#[must_use]
574pub fn package_baselines_disabled_by_env_value(raw: &OsStr) -> bool {
575    raw.to_str().is_some_and(|value| {
576        matches!(
577            value.trim().to_ascii_lowercase().as_str(),
578            "false" | "0" | "no" | "off"
579        )
580    })
581}
582
583fn resolve_cache_dir(root: &Path, configured: Option<PathBuf>) -> PathBuf {
584    let Some(dir) = configured else {
585        return root.join(".fallow");
586    };
587    if dir.is_absolute() {
588        dir
589    } else {
590        root.join(dir)
591    }
592}
593
594fn cache_dir_is_inside_root(root: &Path, cache_dir: &Path) -> bool {
595    if cache_dir.starts_with(root) {
596        return true;
597    }
598    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
599    let canonical_cache =
600        dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
601    canonical_cache.starts_with(&canonical_root)
602}
603
604/// Name of the subdirectory that one project root owns inside a shared cache
605/// directory: the root's folder name for a reader, then a hash of the full
606/// root path, so two roots with the same folder name stay apart.
607fn per_root_cache_subdir(root: &Path) -> String {
608    let path = root.to_string_lossy().replace('\\', "/");
609    let hash = xxhash_rust::xxh3::xxh3_64(path.as_bytes());
610    let name: String = root
611        .file_name()
612        .map(|name| name.to_string_lossy())
613        .unwrap_or_default()
614        .chars()
615        .map(|c| {
616            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
617                c
618            } else {
619                '_'
620            }
621        })
622        .collect();
623    if name.is_empty() {
624        format!("{hash:016x}")
625    } else {
626        format!("{name}-{hash:016x}")
627    }
628}
629
630pub(super) fn normalize_user_glob_pattern(pattern: &str) -> &str {
631    pattern.strip_prefix("./").unwrap_or(pattern)
632}
633
634/// Built-in discovery ignore patterns, unioned into
635/// [`ResolvedConfig::ignore_patterns`] after the user's own `ignorePatterns`.
636///
637/// Public and ordered because the order IS the index layout of that union:
638/// user patterns occupy `0..user_ignore_pattern_count` and these follow, in
639/// this order. Source discovery maps a match index back to the pattern text
640/// through that layout when it reports which built-in removed a candidate
641/// source file (issue #2638), so reordering this list or changing where it is
642/// appended changes an output contract.
643///
644/// A `!` entry in `ignorePatterns` is not part of this union. It is an
645/// exception that the ignore set applies after the union, so it can lift a
646/// built-in for its own subtree (issue #2940). The `node_modules` and `.git`
647/// patterns can never be lifted.
648pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
649    "**/node_modules/**",
650    "**/dist/**",
651    "**/build/**",
652    "**/.git/**",
653    "**/coverage/**",
654    "**/*.min.js",
655    "**/*.min.mjs",
656    "**/*.min.cjs",
657    "**/*.bundle.js",
658];
659
660#[expect(
661    clippy::expect_used,
662    reason = "user glob patterns are validated before config resolution"
663)]
664fn compile_ignore_unresolved_imports(patterns: &[String]) -> Vec<GlobMatcher> {
665    patterns
666        .iter()
667        .map(|pattern| {
668            let normalized = normalize_user_glob_pattern(pattern);
669            Glob::new(normalized)
670                .expect("ignoreUnresolvedImports entry was validated at config load time")
671                .compile_matcher()
672        })
673        .collect()
674}
675
676fn resolve_rules_for_production(mut rules: RulesConfig, production: bool) -> RulesConfig {
677    if production {
678        rules.unused_dev_dependencies = Severity::Off;
679        rules.unused_optional_dependencies = Severity::Off;
680    }
681    rules
682}
683
684fn resolve_boundaries(
685    mut boundaries: super::boundaries::BoundaryConfig,
686    root: &Path,
687) -> ResolvedBoundaryConfig {
688    expand_boundary_preset(&mut boundaries, root);
689    let logical_groups = boundaries.expand_auto_discover(root);
690    let mut resolved = boundaries.resolve();
691    resolved.logical_groups = logical_groups;
692    resolved
693}
694
695/// Expand the boundary preset in place, with the tsconfig `rootDir` as the
696/// source root. Does nothing without a preset.
697pub(super) fn expand_boundary_preset(
698    boundaries: &mut super::boundaries::BoundaryConfig,
699    root: &Path,
700) {
701    if boundaries.preset.is_some() {
702        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
703            .filter(|r| r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute())
704            .unwrap_or_else(|| "src".to_owned());
705        if source_root != "src" {
706            tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
707        }
708        boundaries.expand(&source_root);
709    }
710}
711
712/// Inter-file rules that a per-file override cannot change.
713///
714/// `circular-dependency` is not in this list: a cycle takes the highest
715/// severity of its files, and a cycle whose files all resolve to `off` is
716/// dropped, so a per-file override does change the result.
717fn ineffective_inter_file_override_rules(rules: &PartialRulesConfig) -> Vec<&'static str> {
718    let mut names = Vec::new();
719    if rules.duplicate_exports.is_some() {
720        names.push("duplicate-exports");
721    }
722    if rules.re_export_cycle.is_some() {
723        names.push("re-export-cycle");
724    }
725    names
726}
727
728fn warn_inter_file_overrides(rules: &PartialRulesConfig, files: &[String]) {
729    let ineffective = ineffective_inter_file_override_rules(rules);
730    if ineffective.contains(&"duplicate-exports")
731        && record_inter_file_warn_seen("duplicate-exports", files)
732    {
733        let files = files.join(", ");
734        tracing::warn!(
735            "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."
736        );
737    }
738    if ineffective.contains(&"re-export-cycle")
739        && record_inter_file_warn_seen("re-export-cycle", files)
740    {
741        let files = files.join(", ");
742        tracing::warn!(
743            "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."
744        );
745    }
746}
747
748#[expect(
749    clippy::expect_used,
750    reason = "override glob patterns are validated before config resolution"
751)]
752fn compile_overrides(overrides: Vec<ConfigOverride>) -> Vec<ResolvedOverride> {
753    overrides
754        .into_iter()
755        .filter_map(|override_entry| {
756            warn_inter_file_overrides(&override_entry.rules, &override_entry.files);
757            let matchers: Vec<globset::GlobMatcher> = override_entry
758                .files
759                .iter()
760                .map(|pattern| {
761                    compile_path_glob(pattern)
762                        .expect("overrides[].files pattern was validated at config load time")
763                        .compile_matcher()
764                })
765                .collect();
766            if matchers.is_empty() {
767                None
768            } else {
769                Some(ResolvedOverride {
770                    matchers,
771                    rules: override_entry.rules,
772                })
773            }
774        })
775        .collect()
776}
777
778/// Compile `ignoreExports` file globs into matchers paired with export names.
779#[expect(
780    clippy::expect_used,
781    reason = "user glob patterns are validated before config resolution"
782)]
783fn compile_ignore_export_rules(rules: &[IgnoreExportRule]) -> Vec<CompiledIgnoreExportRule> {
784    rules
785        .iter()
786        .map(|rule| CompiledIgnoreExportRule {
787            matcher: compile_path_glob(&rule.file)
788                .expect("ignoreExports[].file was validated at config load time")
789                .compile_matcher(),
790            exports: rule.exports.clone(),
791        })
792        .collect()
793}
794
795/// Compile `ignoreCatalogReferences` rules, pre-compiling the consumer glob.
796#[expect(
797    clippy::expect_used,
798    reason = "user glob patterns are validated before config resolution"
799)]
800fn compile_ignore_catalog_reference_rules(
801    rules: &[IgnoreCatalogReferenceRule],
802) -> Vec<CompiledIgnoreCatalogReferenceRule> {
803    rules
804        .iter()
805        .map(|rule| CompiledIgnoreCatalogReferenceRule {
806            package: rule.package.clone(),
807            catalog: rule.catalog.clone(),
808            consumer_matcher: rule.consumer.as_ref().map(|pattern| {
809                compile_path_glob(pattern)
810                    .expect("ignoreCatalogReferences[].consumer was validated at config load time")
811                    .compile_matcher()
812            }),
813        })
814        .collect()
815}
816
817/// Convert `ignoreDependencyOverrides` rules into their match-ready form.
818fn compile_ignore_dependency_override_rules(
819    rules: &[IgnoreDependencyOverrideRule],
820) -> Vec<CompiledIgnoreDependencyOverrideRule> {
821    rules
822        .iter()
823        .map(|rule| CompiledIgnoreDependencyOverrideRule {
824            package: rule.package.clone(),
825            source: rule.source.clone(),
826        })
827        .collect()
828}
829
830struct CompiledIgnoreSettings {
831    patterns: IgnorePatternSet,
832    user_pattern_count: usize,
833    findings: FindingIgnoreMatcher,
834    unresolved_imports: Vec<GlobMatcher>,
835    exports: Vec<CompiledIgnoreExportRule>,
836    catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
837    dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
838}
839
840fn compile_ignore_settings(config: &FallowConfig) -> CompiledIgnoreSettings {
841    let (patterns, user_pattern_count) = compile_ignore_patterns(&config.ignore_patterns);
842    CompiledIgnoreSettings {
843        patterns,
844        user_pattern_count,
845        findings: FindingIgnoreMatcher::compile(&config.ignore_findings),
846        unresolved_imports: compile_ignore_unresolved_imports(&config.ignore_unresolved_imports),
847        exports: compile_ignore_export_rules(&config.ignore_exports),
848        catalog_references: compile_ignore_catalog_reference_rules(
849            &config.ignore_catalog_references,
850        ),
851        dependency_overrides: compile_ignore_dependency_override_rules(
852            &config.ignore_dependency_overrides,
853        ),
854    }
855}
856
857struct ResolvedPluginSettings {
858    external_plugins: Vec<ExternalPluginDef>,
859    rule_packs: Vec<crate::rule_pack::RulePackDef>,
860    rule_pack_sources: Vec<PathBuf>,
861}
862
863fn resolve_plugin_settings(
864    root: &Path,
865    configured_plugins: &[String],
866    framework: Vec<ExternalPluginDef>,
867    rule_packs: &[String],
868) -> ResolvedPluginSettings {
869    let mut external_plugins = discover_external_plugins(root, configured_plugins);
870    external_plugins.extend(framework);
871
872    let configured_rule_packs = rule_packs;
873    let rule_packs =
874        crate::rule_pack::load_rule_packs(root, configured_rule_packs).unwrap_or_else(|errors| {
875            for error in &errors {
876                tracing::error!("invalid rule pack: {error}");
877            }
878            Vec::new()
879        });
880    let rule_pack_sources = if rule_packs.len() == configured_rule_packs.len() {
881        configured_rule_packs.iter().map(PathBuf::from).collect()
882    } else {
883        Vec::new()
884    };
885
886    ResolvedPluginSettings {
887        external_plugins,
888        rule_packs,
889        rule_pack_sources,
890    }
891}
892
893struct ResolvedCacheSettings {
894    dir: PathBuf,
895    max_size_mb: Option<u32>,
896    config_hash: u64,
897}
898
899struct ResolvedProductionRules {
900    production: bool,
901    rules: RulesConfig,
902}
903
904fn resolve_production_rules(
905    production_config: ProductionConfig,
906    rules: RulesConfig,
907) -> ResolvedProductionRules {
908    let production = production_config.global();
909    ResolvedProductionRules {
910        production,
911        rules: resolve_rules_for_production(rules, production),
912    }
913}
914
915fn resolve_cache_settings(
916    root: &Path,
917    configured_dir: Option<PathBuf>,
918    configured_max_size_mb: Option<u32>,
919    override_max_size_mb: Option<u32>,
920    no_cache: bool,
921    config_hash: impl FnOnce() -> u64,
922) -> ResolvedCacheSettings {
923    ResolvedCacheSettings {
924        dir: resolve_cache_dir(root, configured_dir),
925        max_size_mb: override_max_size_mb.or(configured_max_size_mb),
926        config_hash: if no_cache { 0 } else { config_hash() },
927    }
928}
929
930fn normalize_security_config(security: SecurityConfig) -> SecurityConfig {
931    SecurityConfig {
932        request_receivers: security.normalized_request_receivers(),
933        ..security
934    }
935}
936
937struct ResolvedPathPolicySettings {
938    boundaries: ResolvedBoundaryConfig,
939    overrides: Vec<ResolvedOverride>,
940}
941
942fn resolve_path_policy_settings(
943    boundaries: BoundaryConfig,
944    overrides: Vec<ConfigOverride>,
945    root: &Path,
946) -> ResolvedPathPolicySettings {
947    ResolvedPathPolicySettings {
948        boundaries: resolve_boundaries(boundaries, root),
949        overrides: compile_overrides(overrides),
950    }
951}
952
953fn compile_unused_component_props_ignore(pattern: Option<&str>) -> Option<regex::Regex> {
954    pattern.and_then(|pattern| match regex::Regex::new(pattern) {
955        Ok(re) => Some(re),
956        Err(error) => {
957            tracing::warn!(
958                %error,
959                "ignoring invalid unusedComponentProps.ignorePattern; this config was \
960                 not validated through FallowConfig::load"
961            );
962            None
963        }
964    })
965}
966
967impl FallowConfig {
968    /// Resolve into a fully resolved config with compiled globs.
969    #[expect(
970        clippy::too_many_arguments,
971        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"
972    )]
973    pub fn resolve(
974        self,
975        root: PathBuf,
976        output: OutputFormat,
977        threads: usize,
978        no_cache: bool,
979        quiet: bool,
980        cache_max_size_mb: Option<u32>,
981    ) -> ResolvedConfig {
982        let mut legacy_glob_groups = super::legacy_glob::collect_config_legacy_glob_groups(&self);
983        let compiled_ignores = compile_ignore_settings(&self);
984        let config_json = detection_config_json(&self);
985
986        let production_rules = resolve_production_rules(self.production, self.rules);
987
988        let plugins =
989            resolve_plugin_settings(&root, &self.plugins, self.framework, &self.rule_packs);
990
991        let cache = resolve_cache_settings(
992            &root,
993            self.cache.dir,
994            self.cache.max_size_mb,
995            cache_max_size_mb,
996            no_cache,
997            || cache_config_hash(&plugins.external_plugins, &self.flags),
998        );
999
1000        let detection_config_digest = fallow_types::identity::fnv1a64_parts(&[
1001            "config",
1002            &config_json,
1003            &canonical_json_string(&{
1004                let mut sorted: Vec<&ExternalPluginDef> = plugins.external_plugins.iter().collect();
1005                sorted.sort_by(|a, b| a.name.cmp(&b.name));
1006                sorted
1007            }),
1008            &canonical_json_string(&plugins.rule_packs),
1009        ]);
1010
1011        super::legacy_glob::extend_legacy_glob_groups(
1012            &mut legacy_glob_groups,
1013            &plugins.external_plugins,
1014            &plugins.rule_packs,
1015        );
1016
1017        let path_policy = resolve_path_policy_settings(self.boundaries, self.overrides, &root);
1018        let (workspace_patterns, workspace_changed_since) = self
1019            .workspaces
1020            .map(|workspaces| (workspaces.patterns, workspaces.changed_since))
1021            .unwrap_or_default();
1022
1023        let unused_component_props_ignore = compile_unused_component_props_ignore(
1024            self.unused_component_props.ignore_pattern.as_deref(),
1025        );
1026
1027        ResolvedConfig {
1028            root,
1029            entry_patterns: self.entry,
1030            ignore_patterns: compiled_ignores.patterns,
1031            user_ignore_pattern_count: compiled_ignores.user_pattern_count,
1032            entry_pattern_effect: EntryEffectRecord::default(),
1033            ignore_findings: compiled_ignores.findings,
1034            output,
1035            cache_dir: cache.dir,
1036            threads,
1037            no_cache,
1038            cache_max_size_mb: cache.max_size_mb,
1039            cache_config_hash: cache.config_hash,
1040            ignore_dependencies: IgnoreDependencyMatcher::compile(&self.ignore_dependencies),
1041            ignore_command_entries: self.ignore_command_entries,
1042            ignore_unresolved_imports: compiled_ignores.unresolved_imports,
1043            ignore_export_rules: self.ignore_exports,
1044            compiled_ignore_exports: compiled_ignores.exports,
1045            compiled_ignore_catalog_references: compiled_ignores.catalog_references,
1046            compiled_ignore_dependency_overrides: compiled_ignores.dependency_overrides,
1047            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
1048            used_class_members: self.used_class_members,
1049            ignore_decorators: self.ignore_decorators,
1050            unused_component_props_ignore,
1051            circular_dependencies: self.circular_dependencies,
1052            duplicates: self.duplicates,
1053            similar_code: self.similar_code,
1054            mutation: self.mutation,
1055            health: self.health,
1056            type_aware: self.type_aware,
1057            rules: production_rules.rules,
1058            boundaries: path_policy.boundaries,
1059            rule_packs: plugins.rule_packs,
1060            rule_pack_sources: plugins.rule_pack_sources,
1061            production: production_rules.production,
1062            quiet,
1063            external_plugins: plugins.external_plugins,
1064            dynamically_loaded: self.dynamically_loaded,
1065            overrides: path_policy.overrides,
1066            workspace_changed_since,
1067            workspace_patterns,
1068            regression: self.regression,
1069            audit: self.audit,
1070            codeowners: self.codeowners,
1071            public_packages: self.public_packages,
1072            flags: self.flags,
1073            security: normalize_security_config(self.security),
1074            fix: self.fix,
1075            resolve: self.resolve,
1076            include_entry_exports: self.include_entry_exports,
1077            show_cascade: self.show_cascade,
1078            auto_imports: self.auto_imports,
1079            fail_on_parse_error: self.fail_on_parse_error,
1080            max_file_size_bytes: Some(DEFAULT_MAX_FILE_SIZE_BYTES),
1081            analysis_snapshot: AnalysisSnapshot::Current,
1082            detection_config_digest,
1083            legacy_glob_groups,
1084        }
1085    }
1086}
1087
1088impl ResolvedConfig {
1089    /// Apply a `FALLOW_PACKAGE_BASELINES` value: a false value empties
1090    /// `workspaces.changedSince`, so no run of the process reads the map.
1091    pub fn apply_package_baselines_env(&mut self, raw: Option<&OsStr>) {
1092        if raw.is_some_and(package_baselines_disabled_by_env_value) {
1093            self.workspace_changed_since.clear();
1094        }
1095    }
1096
1097    /// Replace the resolved cache directory with a host override, such as
1098    /// `FALLOW_CACHE_DIR`. A relative path resolves from the project root,
1099    /// the same base as `cache.dir`.
1100    pub fn override_cache_dir(&mut self, dir: PathBuf) {
1101        self.cache_dir = resolve_cache_dir(&self.root, Some(dir));
1102    }
1103
1104    /// Give this project root its own subdirectory when the cache directory
1105    /// is outside the root.
1106    ///
1107    /// A host that analyzes more than one root in one process, such as the
1108    /// language server with a multi-root workspace, calls this. Without it,
1109    /// the roots write the same `cache.bin` and `graph-cache.bin`, and each
1110    /// save replaces the cache of the other root. The CLI does not call it,
1111    /// so a shared CI cache stays valid when the checkout path changes. A
1112    /// cache directory inside the root already belongs to that root and
1113    /// stays unchanged.
1114    pub fn scope_shared_cache_dir_to_root(&mut self) {
1115        if cache_dir_is_inside_root(&self.root, &self.cache_dir) {
1116            return;
1117        }
1118        self.cache_dir = self.cache_dir.join(per_root_cache_subdir(&self.root));
1119    }
1120
1121    /// Resolve the effective rules for a given file path.
1122    /// Starts with base rules and applies matching overrides in order.
1123    #[must_use]
1124    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
1125        if self.overrides.is_empty() {
1126            return self.rules.clone();
1127        }
1128
1129        let relative = path.strip_prefix(&self.root).unwrap_or(path);
1130        let relative_str = relative.to_string_lossy();
1131
1132        let mut rules = self.rules.clone();
1133        for override_entry in &self.overrides {
1134            let matches = override_entry
1135                .matchers
1136                .iter()
1137                .any(|m| m.is_match(relative_str.as_ref()));
1138            if matches {
1139                rules.apply_partial(&override_entry.rules);
1140            }
1141        }
1142        rules
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use crate::CacheConfig;
1150    use crate::config::boundaries::BoundaryConfig;
1151    use crate::config::health::HealthConfig;
1152
1153    #[test]
1154    fn mutation_section_reaches_resolved_config_and_leaves_the_detection_digest() {
1155        let resolve = |json: &str| {
1156            let authored: FallowConfig = serde_json::from_str(json).expect("config deserializes");
1157            authored.resolve(
1158                PathBuf::from("/project"),
1159                OutputFormat::Json,
1160                1,
1161                true,
1162                true,
1163                None,
1164            )
1165        };
1166        let plain = resolve("{}");
1167        let with_mutation = resolve(r#"{"mutation":{"minScore":75,"operators":["removed-call"]}}"#);
1168        assert_eq!(with_mutation.mutation.min_score, Some(75.0));
1169        assert_eq!(
1170            with_mutation.mutation.operators,
1171            Some(vec![crate::MutationOperatorName::RemovedCall])
1172        );
1173        assert_eq!(
1174            plain.detection_config_digest,
1175            with_mutation.detection_config_digest
1176        );
1177        let detecting = resolve(r#"{"ignorePatterns":["gen/**"]}"#);
1178        assert_ne!(
1179            plain.detection_config_digest,
1180            detecting.detection_config_digest
1181        );
1182    }
1183
1184    /// A single `*` in a config path glob stays within one path segment.
1185    /// Only `**` crosses directories.
1186    #[test]
1187    fn single_star_stays_within_one_segment_in_config_path_globs() {
1188        let authored: FallowConfig = serde_json::from_str(
1189            r#"{
1190                "ignorePatterns": ["gen/*.ts"],
1191                "ignoreFindings": ["legacy/*.ts"],
1192                "overrides": [{ "files": ["old/*.ts"], "rules": { "unused-files": "off" } }],
1193                "ignoreExports": [{ "file": "api/*.ts", "exports": ["*"] }],
1194                "ignoreCatalogReferences": [{ "package": "react", "consumer": "apps/*/package.json" }],
1195                "boundaries": {
1196                    "zones": [{ "name": "ui", "patterns": ["ui/*.ts"] }],
1197                    "coverage": { "allowUnmatched": ["scripts/*.ts"] }
1198                }
1199            }"#,
1200        )
1201        .expect("config deserializes");
1202        let resolved = authored.resolve(
1203            PathBuf::from("/project"),
1204            OutputFormat::Json,
1205            1,
1206            true,
1207            true,
1208            None,
1209        );
1210
1211        assert!(resolved.ignore_patterns.is_match("gen/a.ts"));
1212        assert!(!resolved.ignore_patterns.is_match("gen/deep/a.ts"));
1213
1214        assert!(resolved.ignore_findings.is_ignored("legacy/a.ts"));
1215        assert!(!resolved.ignore_findings.is_ignored("legacy/deep/a.ts"));
1216
1217        let off = |path: &str| {
1218            resolved
1219                .resolve_rules_for_path(Path::new(path))
1220                .unused_files
1221                == Severity::Off
1222        };
1223        assert!(off("/project/old/a.ts"));
1224        assert!(!off("/project/old/deep/a.ts"));
1225
1226        let export_rule = &resolved.compiled_ignore_exports[0].matcher;
1227        assert!(export_rule.is_match("api/a.ts"));
1228        assert!(!export_rule.is_match("api/deep/a.ts"));
1229
1230        let catalog_rule = &resolved.compiled_ignore_catalog_references[0];
1231        assert!(catalog_rule.matches("react", "default", "apps/web/package.json"));
1232        assert!(!catalog_rule.matches("react", "default", "apps/web/nested/package.json"));
1233
1234        assert_eq!(resolved.boundaries.classify_zone("ui/a.ts"), Some("ui"));
1235        assert_eq!(resolved.boundaries.classify_zone("ui/deep/a.ts"), None);
1236
1237        let allow_unmatched = &resolved.boundaries.coverage.allow_unmatched[0];
1238        assert!(allow_unmatched.is_match("scripts/a.ts"));
1239        assert!(!allow_unmatched.is_match("scripts/deep/a.ts"));
1240    }
1241
1242    #[test]
1243    fn workspace_patterns_reach_resolved_config() {
1244        let authored: FallowConfig =
1245            serde_json::from_str(r#"{"workspaces":{"patterns":["apps/*/*"]}}"#)
1246                .expect("workspace patterns deserialize");
1247        let resolved = authored.resolve(
1248            PathBuf::from("/project"),
1249            OutputFormat::Json,
1250            1,
1251            true,
1252            true,
1253            None,
1254        );
1255
1256        assert_eq!(resolved.workspace_patterns, vec!["apps/*/*".to_string()]);
1257    }
1258
1259    #[test]
1260    fn workspace_changed_since_reaches_resolved_config() {
1261        let authored: FallowConfig = serde_json::from_str(
1262            r#"{"workspaces":{"changedSince":{"packages/web":"main","packages/legacy":"release/2024.10"}}}"#,
1263        )
1264        .expect("workspace baselines deserialize");
1265        let resolved = authored.resolve(
1266            PathBuf::from("/project"),
1267            OutputFormat::Json,
1268            1,
1269            true,
1270            true,
1271            None,
1272        );
1273
1274        assert_eq!(resolved.workspace_changed_since.len(), 2);
1275        assert_eq!(resolved.workspace_changed_since["packages/web"], "main");
1276        assert_eq!(
1277            resolved.workspace_changed_since["packages/legacy"],
1278            "release/2024.10"
1279        );
1280    }
1281
1282    #[test]
1283    fn cache_config_hash_keys_on_user_flag_patterns() {
1284        let builtin = cache_config_hash(&[], &FlagsConfig::default());
1285        let with_pattern = |function: &str| FlagsConfig {
1286            sdk_patterns: vec![super::super::flags::SdkPattern {
1287                function: function.to_string(),
1288                name_arg: 0,
1289                provider: None,
1290            }],
1291            ..FlagsConfig::default()
1292        };
1293
1294        assert_eq!(
1295            builtin,
1296            xxhash_rust::xxh3::Xxh3::new().digest(),
1297            "a config without flag patterns keeps the plugin-only hash"
1298        );
1299        assert_ne!(builtin, cache_config_hash(&[], &with_pattern("isOn")));
1300        assert_ne!(
1301            cache_config_hash(&[], &with_pattern("isOn")),
1302            cache_config_hash(&[], &with_pattern("isOff"))
1303        );
1304        assert_ne!(
1305            builtin,
1306            cache_config_hash(
1307                &[],
1308                &FlagsConfig {
1309                    config_object_heuristics: true,
1310                    ..FlagsConfig::default()
1311                }
1312            )
1313        );
1314        assert_ne!(
1315            builtin,
1316            cache_config_hash(
1317                &[],
1318                &FlagsConfig {
1319                    env_prefixes: vec!["MYAPP_".to_string()],
1320                    ..FlagsConfig::default()
1321                }
1322            )
1323        );
1324    }
1325
1326    #[test]
1327    fn overrides_deserialize() {
1328        let json_str = r#"{
1329            "overrides": [{
1330                "files": ["*.test.ts"],
1331                "rules": {
1332                    "unused-exports": "off"
1333                }
1334            }]
1335        }"#;
1336        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1337        assert_eq!(config.overrides.len(), 1);
1338        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1339        assert_eq!(
1340            config.overrides[0].rules.unused_exports,
1341            Some(Severity::Off)
1342        );
1343        assert_eq!(config.overrides[0].rules.unused_files, None);
1344    }
1345
1346    #[test]
1347    fn resolve_rules_for_path_no_overrides() {
1348        let config = FallowConfig {
1349            schema: None,
1350            minimum_version: None,
1351            extends: vec![],
1352            entry: vec![],
1353            ignore_patterns: vec![],
1354            ignore_findings: vec![],
1355            framework: vec![],
1356            workspaces: None,
1357            ignore_dependencies: vec![],
1358            ignore_command_entries: vec![],
1359            ignore_unresolved_imports: vec![],
1360            ignore_exports: vec![],
1361            ignore_catalog_references: vec![],
1362            ignore_dependency_overrides: vec![],
1363            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1364            used_class_members: vec![],
1365            ignore_decorators: vec![],
1366            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1367            circular_dependencies: crate::CircularDependenciesConfig::default(),
1368            duplicates: DuplicatesConfig::default(),
1369            similar_code: SimilarCodeConfig::default(),
1370            mutation: MutationConfig::default(),
1371            health: HealthConfig::default(),
1372            rules: RulesConfig::default(),
1373            boundaries: BoundaryConfig::default(),
1374            production: false.into(),
1375            plugins: vec![],
1376            rule_packs: vec![],
1377            dynamically_loaded: vec![],
1378            overrides: vec![],
1379            regression: None,
1380            type_aware: crate::TypeAwareConfig::default(),
1381            audit: crate::config::AuditConfig::default(),
1382            codeowners: None,
1383            public_packages: vec![],
1384            flags: FlagsConfig::default(),
1385            security: SecurityConfig::default(),
1386            fix: crate::config::FixConfig::default(),
1387            resolve: ResolveConfig::default(),
1388            sealed: false,
1389            include_entry_exports: false,
1390            show_cascade: false,
1391            auto_imports: false,
1392            fail_on_parse_error: false,
1393            cache: CacheConfig::default(),
1394        };
1395        let resolved = config.resolve(
1396            PathBuf::from("/project"),
1397            OutputFormat::Human,
1398            1,
1399            true,
1400            true,
1401            None,
1402        );
1403        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1404        assert_eq!(rules.unused_files, Severity::Error);
1405    }
1406
1407    #[test]
1408    fn resolve_rules_for_path_with_matching_override() {
1409        let config = FallowConfig {
1410            schema: None,
1411            minimum_version: None,
1412            extends: vec![],
1413            entry: vec![],
1414            ignore_patterns: vec![],
1415            ignore_findings: vec![],
1416            framework: vec![],
1417            workspaces: None,
1418            ignore_dependencies: vec![],
1419            ignore_command_entries: vec![],
1420            ignore_unresolved_imports: vec![],
1421            ignore_exports: vec![],
1422            ignore_catalog_references: vec![],
1423            ignore_dependency_overrides: vec![],
1424            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1425            used_class_members: vec![],
1426            ignore_decorators: vec![],
1427            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1428            circular_dependencies: crate::CircularDependenciesConfig::default(),
1429            duplicates: DuplicatesConfig::default(),
1430            similar_code: SimilarCodeConfig::default(),
1431            mutation: MutationConfig::default(),
1432            health: HealthConfig::default(),
1433            rules: RulesConfig::default(),
1434            boundaries: BoundaryConfig::default(),
1435            production: false.into(),
1436            plugins: vec![],
1437            rule_packs: vec![],
1438            dynamically_loaded: vec![],
1439            overrides: vec![ConfigOverride {
1440                files: vec!["**/*.test.ts".to_string()],
1441                rules: PartialRulesConfig {
1442                    unused_exports: Some(Severity::Off),
1443                    ..Default::default()
1444                },
1445            }],
1446            regression: None,
1447            type_aware: crate::TypeAwareConfig::default(),
1448            audit: crate::config::AuditConfig::default(),
1449            codeowners: None,
1450            public_packages: vec![],
1451            flags: FlagsConfig::default(),
1452            security: SecurityConfig::default(),
1453            fix: crate::config::FixConfig::default(),
1454            resolve: ResolveConfig::default(),
1455            sealed: false,
1456            include_entry_exports: false,
1457            show_cascade: false,
1458            auto_imports: false,
1459            fail_on_parse_error: false,
1460            cache: CacheConfig::default(),
1461        };
1462        let resolved = config.resolve(
1463            PathBuf::from("/project"),
1464            OutputFormat::Human,
1465            1,
1466            true,
1467            true,
1468            None,
1469        );
1470
1471        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1472        assert_eq!(test_rules.unused_exports, Severity::Off);
1473        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
1474
1475        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1476        assert_eq!(src_rules.unused_exports, Severity::Error);
1477    }
1478
1479    #[test]
1480    fn resolve_rules_for_path_later_override_wins() {
1481        let config = FallowConfig {
1482            schema: None,
1483            minimum_version: None,
1484            extends: vec![],
1485            entry: vec![],
1486            ignore_patterns: vec![],
1487            ignore_findings: vec![],
1488            framework: vec![],
1489            workspaces: None,
1490            ignore_dependencies: vec![],
1491            ignore_command_entries: vec![],
1492            ignore_unresolved_imports: vec![],
1493            ignore_exports: vec![],
1494            ignore_catalog_references: vec![],
1495            ignore_dependency_overrides: vec![],
1496            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1497            used_class_members: vec![],
1498            ignore_decorators: vec![],
1499            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1500            circular_dependencies: crate::CircularDependenciesConfig::default(),
1501            duplicates: DuplicatesConfig::default(),
1502            similar_code: SimilarCodeConfig::default(),
1503            mutation: MutationConfig::default(),
1504            health: HealthConfig::default(),
1505            rules: RulesConfig::default(),
1506            boundaries: BoundaryConfig::default(),
1507            production: false.into(),
1508            plugins: vec![],
1509            rule_packs: vec![],
1510            dynamically_loaded: vec![],
1511            overrides: vec![
1512                ConfigOverride {
1513                    files: vec!["*.ts".to_string()],
1514                    rules: PartialRulesConfig {
1515                        unused_files: Some(Severity::Warn),
1516                        ..Default::default()
1517                    },
1518                },
1519                ConfigOverride {
1520                    files: vec!["*.test.ts".to_string()],
1521                    rules: PartialRulesConfig {
1522                        unused_files: Some(Severity::Off),
1523                        ..Default::default()
1524                    },
1525                },
1526            ],
1527            regression: None,
1528            type_aware: crate::TypeAwareConfig::default(),
1529            audit: crate::config::AuditConfig::default(),
1530            codeowners: None,
1531            public_packages: vec![],
1532            flags: FlagsConfig::default(),
1533            security: SecurityConfig::default(),
1534            fix: crate::config::FixConfig::default(),
1535            resolve: ResolveConfig::default(),
1536            sealed: false,
1537            include_entry_exports: false,
1538            show_cascade: false,
1539            auto_imports: false,
1540            fail_on_parse_error: false,
1541            cache: CacheConfig::default(),
1542        };
1543        let resolved = config.resolve(
1544            PathBuf::from("/project"),
1545            OutputFormat::Human,
1546            1,
1547            true,
1548            true,
1549            None,
1550        );
1551
1552        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1553        assert_eq!(rules.unused_files, Severity::Off);
1554
1555        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1556        assert_eq!(rules2.unused_files, Severity::Warn);
1557    }
1558
1559    #[test]
1560    fn resolve_keeps_inter_file_rule_override_after_warning() {
1561        let config = FallowConfig {
1562            schema: None,
1563            minimum_version: None,
1564            extends: vec![],
1565            entry: vec![],
1566            ignore_patterns: vec![],
1567            ignore_findings: vec![],
1568            framework: vec![],
1569            workspaces: None,
1570            ignore_dependencies: vec![],
1571            ignore_command_entries: vec![],
1572            ignore_unresolved_imports: vec![],
1573            ignore_exports: vec![],
1574            ignore_catalog_references: vec![],
1575            ignore_dependency_overrides: vec![],
1576            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1577            used_class_members: vec![],
1578            ignore_decorators: vec![],
1579            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1580            circular_dependencies: crate::CircularDependenciesConfig::default(),
1581            duplicates: DuplicatesConfig::default(),
1582            similar_code: SimilarCodeConfig::default(),
1583            mutation: MutationConfig::default(),
1584            health: HealthConfig::default(),
1585            rules: RulesConfig::default(),
1586            boundaries: BoundaryConfig::default(),
1587            production: false.into(),
1588            plugins: vec![],
1589            rule_packs: vec![],
1590            dynamically_loaded: vec![],
1591            overrides: vec![ConfigOverride {
1592                files: vec!["**/ui/**".to_string()],
1593                rules: PartialRulesConfig {
1594                    duplicate_exports: Some(Severity::Off),
1595                    unused_files: Some(Severity::Warn),
1596                    ..Default::default()
1597                },
1598            }],
1599            regression: None,
1600            type_aware: crate::TypeAwareConfig::default(),
1601            audit: crate::config::AuditConfig::default(),
1602            codeowners: None,
1603            public_packages: vec![],
1604            flags: FlagsConfig::default(),
1605            security: SecurityConfig::default(),
1606            fix: crate::config::FixConfig::default(),
1607            resolve: ResolveConfig::default(),
1608            sealed: false,
1609            include_entry_exports: false,
1610            show_cascade: false,
1611            auto_imports: false,
1612            fail_on_parse_error: false,
1613            cache: CacheConfig::default(),
1614        };
1615        let resolved = config.resolve(
1616            PathBuf::from("/project"),
1617            OutputFormat::Human,
1618            1,
1619            true,
1620            true,
1621            None,
1622        );
1623        assert_eq!(
1624            resolved.overrides.len(),
1625            1,
1626            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
1627        );
1628        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
1629        assert_eq!(rules.unused_files, Severity::Warn);
1630    }
1631
1632    #[test]
1633    fn circular_dependency_override_is_not_reported_as_ineffective() {
1634        let rules = PartialRulesConfig {
1635            circular_dependencies: Some(Severity::Off),
1636            duplicate_exports: Some(Severity::Off),
1637            re_export_cycle: Some(Severity::Off),
1638            ..PartialRulesConfig::default()
1639        };
1640        assert_eq!(
1641            ineffective_inter_file_override_rules(&rules),
1642            vec!["duplicate-exports", "re-export-cycle"]
1643        );
1644
1645        let only_circular = PartialRulesConfig {
1646            circular_dependencies: Some(Severity::Off),
1647            ..PartialRulesConfig::default()
1648        };
1649        assert!(ineffective_inter_file_override_rules(&only_circular).is_empty());
1650    }
1651
1652    #[test]
1653    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
1654        reset_inter_file_warn_dedup_for_test();
1655        let files_a = vec!["__test_dedup_a/*".to_string()];
1656        let files_b = vec!["__test_dedup_b/*".to_string()];
1657
1658        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
1659        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1660        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
1661
1662        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
1663        assert!(!record_inter_file_warn_seen(
1664            "circular-dependency",
1665            &files_a
1666        ));
1667
1668        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
1669
1670        let files_reordered = vec![
1671            "__test_dedup_b/*".to_string(),
1672            "__test_dedup_a/*".to_string(),
1673        ];
1674        let files_natural = vec![
1675            "__test_dedup_a/*".to_string(),
1676            "__test_dedup_b/*".to_string(),
1677        ];
1678        reset_inter_file_warn_dedup_for_test();
1679        assert!(record_inter_file_warn_seen(
1680            "duplicate-exports",
1681            &files_natural
1682        ));
1683        assert!(!record_inter_file_warn_seen(
1684            "duplicate-exports",
1685            &files_reordered
1686        ));
1687    }
1688
1689    #[test]
1690    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
1691        reset_inter_file_warn_dedup_for_test();
1692        let files = vec!["__test_resolve_dedup/**".to_string()];
1693        let build_config = || FallowConfig {
1694            schema: None,
1695            minimum_version: None,
1696            extends: vec![],
1697            entry: vec![],
1698            ignore_patterns: vec![],
1699            ignore_findings: vec![],
1700            framework: vec![],
1701            workspaces: None,
1702            ignore_dependencies: vec![],
1703            ignore_command_entries: vec![],
1704            ignore_unresolved_imports: vec![],
1705            ignore_exports: vec![],
1706            ignore_catalog_references: vec![],
1707            ignore_dependency_overrides: vec![],
1708            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1709            used_class_members: vec![],
1710            ignore_decorators: vec![],
1711            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1712            circular_dependencies: crate::CircularDependenciesConfig::default(),
1713            duplicates: DuplicatesConfig::default(),
1714            similar_code: SimilarCodeConfig::default(),
1715            mutation: MutationConfig::default(),
1716            health: HealthConfig::default(),
1717            rules: RulesConfig::default(),
1718            boundaries: BoundaryConfig::default(),
1719            production: false.into(),
1720            plugins: vec![],
1721            rule_packs: vec![],
1722            dynamically_loaded: 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        };
1746        for _ in 0..10 {
1747            let _ = build_config().resolve(
1748                PathBuf::from("/project"),
1749                OutputFormat::Human,
1750                1,
1751                true,
1752                true,
1753                None,
1754            );
1755        }
1756        assert!(
1757            !record_inter_file_warn_seen("duplicate-exports", &files),
1758            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1759        );
1760    }
1761
1762    /// Helper to build a FallowConfig with minimal boilerplate.
1763    fn make_config(production: bool) -> FallowConfig {
1764        FallowConfig {
1765            schema: None,
1766            minimum_version: None,
1767            extends: vec![],
1768            entry: vec![],
1769            ignore_patterns: vec![],
1770            ignore_findings: vec![],
1771            framework: vec![],
1772            workspaces: None,
1773            ignore_dependencies: vec![],
1774            ignore_command_entries: vec![],
1775            ignore_unresolved_imports: vec![],
1776            ignore_exports: vec![],
1777            ignore_catalog_references: vec![],
1778            ignore_dependency_overrides: vec![],
1779            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1780            used_class_members: vec![],
1781            ignore_decorators: vec![],
1782            unused_component_props: crate::UnusedComponentPropsConfig::default(),
1783            circular_dependencies: crate::CircularDependenciesConfig::default(),
1784            duplicates: DuplicatesConfig::default(),
1785            similar_code: SimilarCodeConfig::default(),
1786            mutation: MutationConfig::default(),
1787            health: HealthConfig::default(),
1788            rules: RulesConfig::default(),
1789            boundaries: BoundaryConfig::default(),
1790            production: production.into(),
1791            plugins: vec![],
1792            rule_packs: vec![],
1793            dynamically_loaded: vec![],
1794            overrides: vec![],
1795            regression: None,
1796            type_aware: crate::TypeAwareConfig::default(),
1797            audit: crate::config::AuditConfig::default(),
1798            codeowners: None,
1799            public_packages: vec![],
1800            flags: FlagsConfig::default(),
1801            security: SecurityConfig::default(),
1802            fix: crate::config::FixConfig::default(),
1803            resolve: ResolveConfig::default(),
1804            sealed: false,
1805            include_entry_exports: false,
1806            show_cascade: false,
1807            auto_imports: false,
1808            fail_on_parse_error: false,
1809            cache: CacheConfig::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}