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