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