Skip to main content

fallow_config/config/
resolution.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::FallowConfig;
22use super::IgnoreExportsUsedInFileConfig;
23
24/// Process-local dedup state for inter-file rule warnings.
25///
26/// Workspace mode calls `FallowConfig::resolve()` once per package, so a single
27/// top-level config with `overrides.rules.{duplicate-exports,circular-dependency}`
28/// would otherwise emit the same warning N times. The set is keyed on a stable
29/// hash of (rule name, sorted glob list) so logically identical override blocks
30/// dedupe across all package resolves.
31///
32/// The state persists across resolves within a single process. That matches the
33/// CLI's "one warning per invocation" expectation. In long-running hosts
34/// (`fallow watch`, the LSP server, NAPI consumers re-using a worker, the MCP
35/// server) the same set survives between re-runs and re-loads, so a user who
36/// edits the config and triggers a re-analysis sees the warning at most once
37/// per process lifetime. That is the documented behavior; restarting the host
38/// re-arms the warning.
39static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
40
41/// Stable hash of `(rule_name, sorted glob list)`.
42///
43/// Sorting deduplicates `["a/*", "b/*"]` against `["b/*", "a/*"]`. The element-
44/// wise hash loop is explicit so the lint sees the sorted Vec as read.
45fn inter_file_warn_key(rule_name: &str, files: &[String]) -> u64 {
46    let mut sorted: Vec<&str> = files.iter().map(String::as_str).collect();
47    sorted.sort_unstable();
48    let mut hasher = DefaultHasher::new();
49    rule_name.hash(&mut hasher);
50    for s in &sorted {
51        s.hash(&mut hasher);
52    }
53    hasher.finish()
54}
55
56/// Returns `true` if this `(rule_name, files)` warning has not yet been recorded
57/// in the current process; `false` if it has already fired (or the mutex was
58/// poisoned, in which case we behave as if the warning had not fired yet so the
59/// user still sees one warning).
60fn record_inter_file_warn_seen(rule_name: &str, files: &[String]) -> bool {
61    let seen = INTER_FILE_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
62    let key = inter_file_warn_key(rule_name, files);
63    seen.lock().map_or(true, |mut set| set.insert(key))
64}
65
66#[cfg(test)]
67fn reset_inter_file_warn_dedup_for_test() {
68    if let Some(seen) = INTER_FILE_WARN_SEEN.get()
69        && let Ok(mut set) = seen.lock()
70    {
71        set.clear();
72    }
73}
74
75/// Rule for ignoring specific exports.
76#[derive(Debug, Deserialize, Serialize, JsonSchema)]
77pub struct IgnoreExportRule {
78    /// Glob pattern for files.
79    pub file: String,
80    /// Export names to ignore (`*` for all).
81    pub exports: Vec<String>,
82}
83
84/// `IgnoreExportRule` with the glob pre-compiled into a matcher.
85///
86/// Workspace mode runs `find_unused_exports` and `find_duplicate_exports` once
87/// per package, each of which previously re-compiled the same set of globs from
88/// `ignore_export_rules`. Compiling once at `ResolvedConfig` construction and
89/// reading `&[CompiledIgnoreExportRule]` from both detectors removes that work.
90#[derive(Debug)]
91pub struct CompiledIgnoreExportRule {
92    pub matcher: globset::GlobMatcher,
93    pub exports: Vec<String>,
94}
95
96/// Rule for suppressing an `unresolved-catalog-reference` finding.
97///
98/// A finding is suppressed when ALL provided fields match the finding:
99/// - `package` matches the consumed package name exactly (case-sensitive).
100/// - `catalog`, if set, matches the referenced catalog name (`"default"` for
101///   bare `catalog:` references; named catalogs use their declared key). When
102///   omitted, any catalog matches.
103/// - `consumer`, if set, is a glob matched against the consumer `package.json`
104///   path relative to the project root. When omitted, any consumer matches.
105///
106/// Typical use cases:
107/// - Staged migrations: catalog entry is being added in a separate PR
108/// - Library-internal placeholder packages whose target catalog isn't ready yet
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct IgnoreCatalogReferenceRule {
112    /// Package name being referenced via the catalog protocol (exact match).
113    pub package: String,
114    /// Catalog name to scope the suppression to. `None` matches any catalog.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub catalog: Option<String>,
117    /// Glob (root-relative) for the consumer `package.json`. `None` matches any consumer.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub consumer: Option<String>,
120}
121
122/// `IgnoreCatalogReferenceRule` with the optional consumer glob pre-compiled.
123#[derive(Debug)]
124pub struct CompiledIgnoreCatalogReferenceRule {
125    pub package: String,
126    pub catalog: Option<String>,
127    /// `None` means "match any consumer path"; `Some` matches only paths the glob accepts.
128    pub consumer_matcher: Option<globset::GlobMatcher>,
129}
130
131impl CompiledIgnoreCatalogReferenceRule {
132    /// Whether this rule suppresses an `unresolved-catalog-reference` finding
133    /// for the given (package, catalog, consumer-path) triple. The consumer
134    /// path must be project-root-relative.
135    #[must_use]
136    pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
137        if self.package != package {
138            return false;
139        }
140        if let Some(catalog_filter) = &self.catalog
141            && catalog_filter != catalog
142        {
143            return false;
144        }
145        if let Some(matcher) = &self.consumer_matcher
146            && !matcher.is_match(consumer_path)
147        {
148            return false;
149        }
150        true
151    }
152}
153
154/// Rule for suppressing an `unused-dependency-override` or
155/// `misconfigured-dependency-override` finding.
156///
157/// A finding is suppressed when ALL provided fields match the finding:
158/// - `package` matches the override's target package name exactly
159///   (case-sensitive). For parent-chain overrides (`react>react-dom`), the
160///   target is the rightmost segment (`react-dom`).
161/// - `source`, if set, scopes the suppression to overrides declared in that
162///   source file. Accepts `"pnpm-workspace.yaml"` or `"package.json"`.
163///   When omitted, both sources match.
164///
165/// Typical use cases:
166/// - Library-internal CI tooling overrides we cannot drop yet
167/// - Overrides targeting purely-transitive packages (CVE-fix pattern)
168#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct IgnoreDependencyOverrideRule {
171    /// Override target package name (exact match; case-sensitive).
172    pub package: String,
173    /// Source file scope: `"pnpm-workspace.yaml"` or `"package.json"`.
174    /// `None` matches both sources.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<String>,
177}
178
179/// `IgnoreDependencyOverrideRule` ready for matching.
180#[derive(Debug)]
181pub struct CompiledIgnoreDependencyOverrideRule {
182    pub package: String,
183    /// `None` matches any source; `Some` matches only the named source.
184    pub source: Option<String>,
185}
186
187impl CompiledIgnoreDependencyOverrideRule {
188    /// Whether this rule suppresses a dependency-override finding for the
189    /// given (target_package, source_label) pair. `source_label` should be
190    /// `"pnpm-workspace.yaml"` or `"package.json"`.
191    #[must_use]
192    pub fn matches(&self, package: &str, source_label: &str) -> bool {
193        if self.package != package {
194            return false;
195        }
196        if let Some(source_filter) = &self.source
197            && source_filter != source_label
198        {
199            return false;
200        }
201        true
202    }
203}
204
205/// Per-file override entry.
206#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct ConfigOverride {
209    /// Glob patterns to match files against (relative to config file location).
210    pub files: Vec<String>,
211    /// Partial rules — only specified fields override the base rules.
212    #[serde(default)]
213    pub rules: PartialRulesConfig,
214}
215
216/// Resolved override with pre-compiled glob matchers.
217#[derive(Debug)]
218pub struct ResolvedOverride {
219    pub matchers: Vec<globset::GlobMatcher>,
220    pub rules: PartialRulesConfig,
221}
222
223/// Fully resolved configuration with all globs pre-compiled.
224#[derive(Debug)]
225pub struct ResolvedConfig {
226    pub root: PathBuf,
227    pub entry_patterns: Vec<String>,
228    pub ignore_patterns: GlobSet,
229    pub output: OutputFormat,
230    pub cache_dir: PathBuf,
231    pub threads: usize,
232    pub no_cache: bool,
233    pub ignore_dependencies: Vec<String>,
234    pub ignore_export_rules: Vec<IgnoreExportRule>,
235    /// Pre-compiled glob matchers for `ignoreExports`.
236    ///
237    /// Populated alongside `ignore_export_rules` so detectors that need to test
238    /// "does this file match a configured `ignoreExports` glob?" can read the
239    /// compiled matchers without re-running `globset::Glob::new` per call.
240    pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
241    /// Pre-compiled rules for suppressing `unresolved-catalog-reference` findings.
242    pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
243    /// Pre-compiled rules for suppressing dependency-override findings (both
244    /// `unused-dependency-override` and `misconfigured-dependency-override`).
245    pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
246    /// Whether same-file references should suppress unused-export findings.
247    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
248    /// Class member names that should never be flagged as unused-class-members.
249    /// Union of top-level config and active plugin contributions; merged during
250    /// config resolution so analysis code reads a single list.
251    pub used_class_members: Vec<UsedClassMemberRule>,
252    pub duplicates: DuplicatesConfig,
253    pub health: HealthConfig,
254    pub rules: RulesConfig,
255    /// Resolved architecture boundary configuration with pre-compiled glob matchers.
256    pub boundaries: ResolvedBoundaryConfig,
257    /// Whether production mode is active.
258    pub production: bool,
259    /// Suppress progress output and non-essential stderr messages.
260    pub quiet: bool,
261    /// External plugin definitions (from plugin files + inline framework definitions).
262    pub external_plugins: Vec<ExternalPluginDef>,
263    /// Glob patterns for dynamically loaded files (treated as always-used).
264    pub dynamically_loaded: Vec<String>,
265    /// Per-file rule overrides with pre-compiled glob matchers.
266    pub overrides: Vec<ResolvedOverride>,
267    /// Regression config (passed through from user config, not resolved).
268    pub regression: Option<super::RegressionConfig>,
269    /// Audit baseline paths (passed through from user config, not resolved).
270    pub audit: super::AuditConfig,
271    /// Optional CODEOWNERS file path (passed through for `--group-by owner`).
272    pub codeowners: Option<String>,
273    /// Workspace package name patterns that are public libraries.
274    /// Exported API surface from these packages is not flagged as unused.
275    pub public_packages: Vec<String>,
276    /// Feature flag detection configuration.
277    pub flags: FlagsConfig,
278    /// Module resolver configuration (user-supplied import/export conditions).
279    pub resolve: ResolveConfig,
280    /// When true, entry file exports are subject to unused-export detection
281    /// instead of being automatically marked as used. Set via the global CLI flag
282    /// `--include-entry-exports` or via `includeEntryExports: true` in the fallow
283    /// config file; the CLI flag ORs with the config value (CLI wins when set).
284    pub include_entry_exports: bool,
285}
286
287impl FallowConfig {
288    /// Resolve into a fully resolved config with compiled globs.
289    pub fn resolve(
290        self,
291        root: PathBuf,
292        output: OutputFormat,
293        threads: usize,
294        no_cache: bool,
295        quiet: bool,
296    ) -> ResolvedConfig {
297        let mut ignore_builder = GlobSetBuilder::new();
298        for pattern in &self.ignore_patterns {
299            match Glob::new(pattern) {
300                Ok(glob) => {
301                    ignore_builder.add(glob);
302                }
303                Err(e) => {
304                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
305                }
306            }
307        }
308
309        // Default ignores
310        // Note: `build/` is only ignored at the project root (not `**/build/**`)
311        // because nested `build/` directories like `test/build/` may contain source files.
312        let default_ignores = [
313            "**/node_modules/**",
314            "**/dist/**",
315            "build/**",
316            "**/.git/**",
317            "**/coverage/**",
318            "**/*.min.js",
319            "**/*.min.mjs",
320        ];
321        for pattern in &default_ignores {
322            if let Ok(glob) = Glob::new(pattern) {
323                ignore_builder.add(glob);
324            }
325        }
326
327        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
328        let cache_dir = root.join(".fallow");
329
330        let mut rules = self.rules;
331
332        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
333        let production = self.production.global();
334        if production {
335            rules.unused_dev_dependencies = Severity::Off;
336            rules.unused_optional_dependencies = Severity::Off;
337        }
338
339        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
340        // Merge inline framework definitions into external plugins
341        external_plugins.extend(self.framework);
342
343        // Expand boundary preset (if configured) before validation.
344        // Detect source root from tsconfig.json, falling back to "src".
345        let mut boundaries = self.boundaries;
346        if boundaries.preset.is_some() {
347            let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
348                .filter(|r| {
349                    r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
350                })
351                .unwrap_or_else(|| "src".to_owned());
352            if source_root != "src" {
353                tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
354            }
355            boundaries.expand(&source_root);
356        }
357
358        // Validate and compile architecture boundary config
359        let validation_errors = boundaries.validate_zone_references();
360        for (rule_idx, zone_name) in &validation_errors {
361            tracing::error!(
362                "boundary rule {} references undefined zone '{zone_name}'",
363                rule_idx
364            );
365        }
366        for message in boundaries.validate_root_prefixes() {
367            tracing::error!("{message}");
368        }
369        let boundaries = boundaries.resolve();
370
371        // Pre-compile override glob matchers
372        let overrides = self
373            .overrides
374            .into_iter()
375            .filter_map(|o| {
376                // Inter-file rules group findings across multiple files (a
377                // single duplicate-exports finding spans N files; a single
378                // circular-dependency finding spans M files in a cycle), so a
379                // per-file `overrides.rules` setting cannot meaningfully turn
380                // them off: the override only fires when the path being looked
381                // up matches, but the finding belongs to a group of paths, not
382                // to one. Warn at load time and point users at the working
383                // escape hatch (`ignoreExports` for duplicates, file-level
384                // `// fallow-ignore-file circular-dependency` for cycles).
385                if o.rules.duplicate_exports.is_some()
386                    && record_inter_file_warn_seen("duplicate-exports", &o.files)
387                {
388                    let files = o.files.join(", ");
389                    tracing::warn!(
390                        "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."
391                    );
392                }
393                if o.rules.circular_dependencies.is_some()
394                    && record_inter_file_warn_seen("circular-dependency", &o.files)
395                {
396                    let files = o.files.join(", ");
397                    tracing::warn!(
398                        "overrides.rules.circular-dependency has no effect for files matching [{files}]: circular-dependency is an inter-file rule. Use a file-level `// fallow-ignore-file circular-dependency` comment in one participating file instead."
399                    );
400                }
401                let matchers: Vec<globset::GlobMatcher> = o
402                    .files
403                    .iter()
404                    .filter_map(|pattern| match Glob::new(pattern) {
405                        Ok(glob) => Some(glob.compile_matcher()),
406                        Err(e) => {
407                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
408                            None
409                        }
410                    })
411                    .collect();
412                if matchers.is_empty() {
413                    None
414                } else {
415                    Some(ResolvedOverride {
416                        matchers,
417                        rules: o.rules,
418                    })
419                }
420            })
421            .collect();
422
423        // Compile `ignoreExports` once at resolve time so both `find_unused_exports`
424        // and `find_duplicate_exports` can read pre-built matchers from
425        // `ResolvedConfig` instead of re-compiling per call. Invalid globs warn
426        // once here, replacing the previous "warn-on-every-detector-call" path.
427        let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
428            .ignore_exports
429            .iter()
430            .filter_map(|rule| match Glob::new(&rule.file) {
431                Ok(g) => Some(CompiledIgnoreExportRule {
432                    matcher: g.compile_matcher(),
433                    exports: rule.exports.clone(),
434                }),
435                Err(e) => {
436                    tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
437                    None
438                }
439            })
440            .collect();
441
442        let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
443            .ignore_catalog_references
444            .iter()
445            .filter_map(|rule| {
446                let consumer_matcher = match &rule.consumer {
447                    Some(pattern) => match Glob::new(pattern) {
448                        Ok(g) => Some(g.compile_matcher()),
449                        Err(e) => {
450                            tracing::warn!(
451                                "invalid ignoreCatalogReferences consumer glob '{pattern}': {e}"
452                            );
453                            return None;
454                        }
455                    },
456                    None => None,
457                };
458                Some(CompiledIgnoreCatalogReferenceRule {
459                    package: rule.package.clone(),
460                    catalog: rule.catalog.clone(),
461                    consumer_matcher,
462                })
463            })
464            .collect();
465
466        let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
467            .ignore_dependency_overrides
468            .iter()
469            .map(|rule| CompiledIgnoreDependencyOverrideRule {
470                package: rule.package.clone(),
471                source: rule.source.clone(),
472            })
473            .collect();
474
475        ResolvedConfig {
476            root,
477            entry_patterns: self.entry,
478            ignore_patterns: compiled_ignore_patterns,
479            output,
480            cache_dir,
481            threads,
482            no_cache,
483            ignore_dependencies: self.ignore_dependencies,
484            ignore_export_rules: self.ignore_exports,
485            compiled_ignore_exports,
486            compiled_ignore_catalog_references,
487            compiled_ignore_dependency_overrides,
488            ignore_exports_used_in_file: self.ignore_exports_used_in_file,
489            used_class_members: self.used_class_members,
490            duplicates: self.duplicates,
491            health: self.health,
492            rules,
493            boundaries,
494            production,
495            quiet,
496            external_plugins,
497            dynamically_loaded: self.dynamically_loaded,
498            overrides,
499            regression: self.regression,
500            audit: self.audit,
501            codeowners: self.codeowners,
502            public_packages: self.public_packages,
503            flags: self.flags,
504            resolve: self.resolve,
505            include_entry_exports: self.include_entry_exports,
506        }
507    }
508}
509
510impl ResolvedConfig {
511    /// Resolve the effective rules for a given file path.
512    /// Starts with base rules and applies matching overrides in order.
513    #[must_use]
514    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
515        if self.overrides.is_empty() {
516            return self.rules.clone();
517        }
518
519        let relative = path.strip_prefix(&self.root).unwrap_or(path);
520        let relative_str = relative.to_string_lossy();
521
522        let mut rules = self.rules.clone();
523        for override_entry in &self.overrides {
524            let matches = override_entry
525                .matchers
526                .iter()
527                .any(|m| m.is_match(relative_str.as_ref()));
528            if matches {
529                rules.apply_partial(&override_entry.rules);
530            }
531        }
532        rules
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::config::boundaries::BoundaryConfig;
540    use crate::config::health::HealthConfig;
541
542    #[test]
543    fn overrides_deserialize() {
544        let json_str = r#"{
545            "overrides": [{
546                "files": ["*.test.ts"],
547                "rules": {
548                    "unused-exports": "off"
549                }
550            }]
551        }"#;
552        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
553        assert_eq!(config.overrides.len(), 1);
554        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
555        assert_eq!(
556            config.overrides[0].rules.unused_exports,
557            Some(Severity::Off)
558        );
559        assert_eq!(config.overrides[0].rules.unused_files, None);
560    }
561
562    #[test]
563    fn resolve_rules_for_path_no_overrides() {
564        let config = FallowConfig {
565            schema: None,
566            extends: vec![],
567            entry: vec![],
568            ignore_patterns: vec![],
569            framework: vec![],
570            workspaces: None,
571            ignore_dependencies: vec![],
572            ignore_exports: vec![],
573            ignore_catalog_references: vec![],
574            ignore_dependency_overrides: vec![],
575            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
576            used_class_members: vec![],
577            duplicates: DuplicatesConfig::default(),
578            health: HealthConfig::default(),
579            rules: RulesConfig::default(),
580            boundaries: BoundaryConfig::default(),
581            production: false.into(),
582            plugins: vec![],
583            dynamically_loaded: vec![],
584            overrides: vec![],
585            regression: None,
586            audit: crate::config::AuditConfig::default(),
587            codeowners: None,
588            public_packages: vec![],
589            flags: FlagsConfig::default(),
590            resolve: ResolveConfig::default(),
591            sealed: false,
592            include_entry_exports: false,
593        };
594        let resolved = config.resolve(
595            PathBuf::from("/project"),
596            OutputFormat::Human,
597            1,
598            true,
599            true,
600        );
601        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
602        assert_eq!(rules.unused_files, Severity::Error);
603    }
604
605    #[test]
606    fn resolve_rules_for_path_with_matching_override() {
607        let config = FallowConfig {
608            schema: None,
609            extends: vec![],
610            entry: vec![],
611            ignore_patterns: vec![],
612            framework: vec![],
613            workspaces: None,
614            ignore_dependencies: vec![],
615            ignore_exports: vec![],
616            ignore_catalog_references: vec![],
617            ignore_dependency_overrides: vec![],
618            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
619            used_class_members: vec![],
620            duplicates: DuplicatesConfig::default(),
621            health: HealthConfig::default(),
622            rules: RulesConfig::default(),
623            boundaries: BoundaryConfig::default(),
624            production: false.into(),
625            plugins: vec![],
626            dynamically_loaded: vec![],
627            overrides: vec![ConfigOverride {
628                files: vec!["*.test.ts".to_string()],
629                rules: PartialRulesConfig {
630                    unused_exports: Some(Severity::Off),
631                    ..Default::default()
632                },
633            }],
634            regression: None,
635            audit: crate::config::AuditConfig::default(),
636            codeowners: None,
637            public_packages: vec![],
638            flags: FlagsConfig::default(),
639            resolve: ResolveConfig::default(),
640            sealed: false,
641            include_entry_exports: false,
642        };
643        let resolved = config.resolve(
644            PathBuf::from("/project"),
645            OutputFormat::Human,
646            1,
647            true,
648            true,
649        );
650
651        // Test file matches override
652        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
653        assert_eq!(test_rules.unused_exports, Severity::Off);
654        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
655
656        // Non-test file does not match
657        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
658        assert_eq!(src_rules.unused_exports, Severity::Error);
659    }
660
661    #[test]
662    fn resolve_rules_for_path_later_override_wins() {
663        let config = FallowConfig {
664            schema: None,
665            extends: vec![],
666            entry: vec![],
667            ignore_patterns: vec![],
668            framework: vec![],
669            workspaces: None,
670            ignore_dependencies: vec![],
671            ignore_exports: vec![],
672            ignore_catalog_references: vec![],
673            ignore_dependency_overrides: vec![],
674            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
675            used_class_members: vec![],
676            duplicates: DuplicatesConfig::default(),
677            health: HealthConfig::default(),
678            rules: RulesConfig::default(),
679            boundaries: BoundaryConfig::default(),
680            production: false.into(),
681            plugins: vec![],
682            dynamically_loaded: vec![],
683            overrides: vec![
684                ConfigOverride {
685                    files: vec!["*.ts".to_string()],
686                    rules: PartialRulesConfig {
687                        unused_files: Some(Severity::Warn),
688                        ..Default::default()
689                    },
690                },
691                ConfigOverride {
692                    files: vec!["*.test.ts".to_string()],
693                    rules: PartialRulesConfig {
694                        unused_files: Some(Severity::Off),
695                        ..Default::default()
696                    },
697                },
698            ],
699            regression: None,
700            audit: crate::config::AuditConfig::default(),
701            codeowners: None,
702            public_packages: vec![],
703            flags: FlagsConfig::default(),
704            resolve: ResolveConfig::default(),
705            sealed: false,
706            include_entry_exports: false,
707        };
708        let resolved = config.resolve(
709            PathBuf::from("/project"),
710            OutputFormat::Human,
711            1,
712            true,
713            true,
714        );
715
716        // First override matches *.ts, second matches *.test.ts; second wins
717        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
718        assert_eq!(rules.unused_files, Severity::Off);
719
720        // Non-test .ts file only matches first override
721        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
722        assert_eq!(rules2.unused_files, Severity::Warn);
723    }
724
725    #[test]
726    fn resolve_keeps_inter_file_rule_override_after_warning() {
727        // Setting `overrides.rules.duplicate-exports` for a file glob is a no-op
728        // at finding-time (duplicate-exports groups span multiple files), but the
729        // override must still resolve cleanly so other co-located rule settings
730        // on the same override are honored. The resolver emits a tracing warning;
731        // here we assert the override is still installed for non-inter-file rules.
732        let config = FallowConfig {
733            schema: None,
734            extends: vec![],
735            entry: vec![],
736            ignore_patterns: vec![],
737            framework: vec![],
738            workspaces: None,
739            ignore_dependencies: vec![],
740            ignore_exports: vec![],
741            ignore_catalog_references: vec![],
742            ignore_dependency_overrides: vec![],
743            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
744            used_class_members: vec![],
745            duplicates: DuplicatesConfig::default(),
746            health: HealthConfig::default(),
747            rules: RulesConfig::default(),
748            boundaries: BoundaryConfig::default(),
749            production: false.into(),
750            plugins: vec![],
751            dynamically_loaded: vec![],
752            overrides: vec![ConfigOverride {
753                files: vec!["**/ui/**".to_string()],
754                rules: PartialRulesConfig {
755                    duplicate_exports: Some(Severity::Off),
756                    unused_files: Some(Severity::Warn),
757                    ..Default::default()
758                },
759            }],
760            regression: None,
761            audit: crate::config::AuditConfig::default(),
762            codeowners: None,
763            public_packages: vec![],
764            flags: FlagsConfig::default(),
765            resolve: ResolveConfig::default(),
766            sealed: false,
767            include_entry_exports: false,
768        };
769        let resolved = config.resolve(
770            PathBuf::from("/project"),
771            OutputFormat::Human,
772            1,
773            true,
774            true,
775        );
776        assert_eq!(
777            resolved.overrides.len(),
778            1,
779            "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
780        );
781        let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
782        assert_eq!(rules.unused_files, Severity::Warn);
783    }
784
785    #[test]
786    fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
787        // Reset shared state so test ordering does not affect the assertions
788        // below. Uses unique glob strings (`__test_dedup_*`) so other tests in
789        // this module that exercise the warn path do not collide.
790        reset_inter_file_warn_dedup_for_test();
791        let files_a = vec!["__test_dedup_a/*".to_string()];
792        let files_b = vec!["__test_dedup_b/*".to_string()];
793
794        // First call fires; subsequent identical calls do not.
795        assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
796        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
797        assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
798
799        // Different rule name is a distinct key.
800        assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
801        assert!(!record_inter_file_warn_seen(
802            "circular-dependency",
803            &files_a
804        ));
805
806        // Different glob list is a distinct key.
807        assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
808
809        // Order-insensitive glob list collapses to the same key.
810        let files_reordered = vec![
811            "__test_dedup_b/*".to_string(),
812            "__test_dedup_a/*".to_string(),
813        ];
814        let files_natural = vec![
815            "__test_dedup_a/*".to_string(),
816            "__test_dedup_b/*".to_string(),
817        ];
818        reset_inter_file_warn_dedup_for_test();
819        assert!(record_inter_file_warn_seen(
820            "duplicate-exports",
821            &files_natural
822        ));
823        assert!(!record_inter_file_warn_seen(
824            "duplicate-exports",
825            &files_reordered
826        ));
827    }
828
829    #[test]
830    fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
831        // Drive `FallowConfig::resolve()` ten times with identical
832        // `overrides.rules.duplicate-exports` to mirror workspace mode (one
833        // resolve per package). The dedup must surface the warn key as
834        // already-seen on every call after the first.
835        reset_inter_file_warn_dedup_for_test();
836        let files = vec!["__test_resolve_dedup/**".to_string()];
837        let build_config = || FallowConfig {
838            schema: None,
839            extends: vec![],
840            entry: vec![],
841            ignore_patterns: vec![],
842            framework: vec![],
843            workspaces: None,
844            ignore_dependencies: vec![],
845            ignore_exports: vec![],
846            ignore_catalog_references: vec![],
847            ignore_dependency_overrides: vec![],
848            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
849            used_class_members: vec![],
850            duplicates: DuplicatesConfig::default(),
851            health: HealthConfig::default(),
852            rules: RulesConfig::default(),
853            boundaries: BoundaryConfig::default(),
854            production: false.into(),
855            plugins: vec![],
856            dynamically_loaded: vec![],
857            overrides: vec![ConfigOverride {
858                files: files.clone(),
859                rules: PartialRulesConfig {
860                    duplicate_exports: Some(Severity::Off),
861                    ..Default::default()
862                },
863            }],
864            regression: None,
865            audit: crate::config::AuditConfig::default(),
866            codeowners: None,
867            public_packages: vec![],
868            flags: FlagsConfig::default(),
869            resolve: ResolveConfig::default(),
870            sealed: false,
871            include_entry_exports: false,
872        };
873        for _ in 0..10 {
874            let _ = build_config().resolve(
875                PathBuf::from("/project"),
876                OutputFormat::Human,
877                1,
878                true,
879                true,
880            );
881        }
882        // After 10 resolves the dedup state holds the warn key. Asking the
883        // dedup helper for the SAME key returns false (already seen) instead
884        // of true (would fire).
885        assert!(
886            !record_inter_file_warn_seen("duplicate-exports", &files),
887            "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
888        );
889    }
890
891    /// Helper to build a FallowConfig with minimal boilerplate.
892    fn make_config(production: bool) -> FallowConfig {
893        FallowConfig {
894            schema: None,
895            extends: vec![],
896            entry: vec![],
897            ignore_patterns: vec![],
898            framework: vec![],
899            workspaces: None,
900            ignore_dependencies: vec![],
901            ignore_exports: vec![],
902            ignore_catalog_references: vec![],
903            ignore_dependency_overrides: vec![],
904            ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
905            used_class_members: vec![],
906            duplicates: DuplicatesConfig::default(),
907            health: HealthConfig::default(),
908            rules: RulesConfig::default(),
909            boundaries: BoundaryConfig::default(),
910            production: production.into(),
911            plugins: vec![],
912            dynamically_loaded: vec![],
913            overrides: vec![],
914            regression: None,
915            audit: crate::config::AuditConfig::default(),
916            codeowners: None,
917            public_packages: vec![],
918            flags: FlagsConfig::default(),
919            resolve: ResolveConfig::default(),
920            sealed: false,
921            include_entry_exports: false,
922        }
923    }
924
925    // ── Production mode ─────────────────────────────────────────────
926
927    #[test]
928    fn resolve_production_forces_dev_deps_off() {
929        let resolved = make_config(true).resolve(
930            PathBuf::from("/project"),
931            OutputFormat::Human,
932            1,
933            true,
934            true,
935        );
936        assert_eq!(
937            resolved.rules.unused_dev_dependencies,
938            Severity::Off,
939            "production mode should force unused_dev_dependencies to off"
940        );
941    }
942
943    #[test]
944    fn resolve_production_forces_optional_deps_off() {
945        let resolved = make_config(true).resolve(
946            PathBuf::from("/project"),
947            OutputFormat::Human,
948            1,
949            true,
950            true,
951        );
952        assert_eq!(
953            resolved.rules.unused_optional_dependencies,
954            Severity::Off,
955            "production mode should force unused_optional_dependencies to off"
956        );
957    }
958
959    #[test]
960    fn resolve_production_preserves_other_rules() {
961        let resolved = make_config(true).resolve(
962            PathBuf::from("/project"),
963            OutputFormat::Human,
964            1,
965            true,
966            true,
967        );
968        // Other rules should remain at their defaults
969        assert_eq!(resolved.rules.unused_files, Severity::Error);
970        assert_eq!(resolved.rules.unused_exports, Severity::Error);
971        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
972    }
973
974    #[test]
975    fn resolve_non_production_keeps_dev_deps_default() {
976        let resolved = make_config(false).resolve(
977            PathBuf::from("/project"),
978            OutputFormat::Human,
979            1,
980            true,
981            true,
982        );
983        assert_eq!(
984            resolved.rules.unused_dev_dependencies,
985            Severity::Warn,
986            "non-production should keep default severity"
987        );
988        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
989    }
990
991    #[test]
992    fn resolve_production_flag_stored() {
993        let resolved = make_config(true).resolve(
994            PathBuf::from("/project"),
995            OutputFormat::Human,
996            1,
997            true,
998            true,
999        );
1000        assert!(resolved.production);
1001
1002        let resolved2 = make_config(false).resolve(
1003            PathBuf::from("/project"),
1004            OutputFormat::Human,
1005            1,
1006            true,
1007            true,
1008        );
1009        assert!(!resolved2.production);
1010    }
1011
1012    // ── Default ignore patterns ─────────────────────────────────────
1013
1014    #[test]
1015    fn resolve_default_ignores_node_modules() {
1016        let resolved = make_config(false).resolve(
1017            PathBuf::from("/project"),
1018            OutputFormat::Human,
1019            1,
1020            true,
1021            true,
1022        );
1023        assert!(
1024            resolved
1025                .ignore_patterns
1026                .is_match("node_modules/lodash/index.js")
1027        );
1028        assert!(
1029            resolved
1030                .ignore_patterns
1031                .is_match("packages/a/node_modules/react/index.js")
1032        );
1033    }
1034
1035    #[test]
1036    fn resolve_default_ignores_dist() {
1037        let resolved = make_config(false).resolve(
1038            PathBuf::from("/project"),
1039            OutputFormat::Human,
1040            1,
1041            true,
1042            true,
1043        );
1044        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1045        assert!(
1046            resolved
1047                .ignore_patterns
1048                .is_match("packages/ui/dist/index.js")
1049        );
1050    }
1051
1052    #[test]
1053    fn resolve_default_ignores_root_build_only() {
1054        let resolved = make_config(false).resolve(
1055            PathBuf::from("/project"),
1056            OutputFormat::Human,
1057            1,
1058            true,
1059            true,
1060        );
1061        assert!(
1062            resolved.ignore_patterns.is_match("build/output.js"),
1063            "root build/ should be ignored"
1064        );
1065        // The pattern is `build/**` (root-only), not `**/build/**`
1066        assert!(
1067            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1068            "nested build/ should NOT be ignored by default"
1069        );
1070    }
1071
1072    #[test]
1073    fn resolve_default_ignores_minified_files() {
1074        let resolved = make_config(false).resolve(
1075            PathBuf::from("/project"),
1076            OutputFormat::Human,
1077            1,
1078            true,
1079            true,
1080        );
1081        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1082        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1083    }
1084
1085    #[test]
1086    fn resolve_default_ignores_git() {
1087        let resolved = make_config(false).resolve(
1088            PathBuf::from("/project"),
1089            OutputFormat::Human,
1090            1,
1091            true,
1092            true,
1093        );
1094        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1095    }
1096
1097    #[test]
1098    fn resolve_default_ignores_coverage() {
1099        let resolved = make_config(false).resolve(
1100            PathBuf::from("/project"),
1101            OutputFormat::Human,
1102            1,
1103            true,
1104            true,
1105        );
1106        assert!(
1107            resolved
1108                .ignore_patterns
1109                .is_match("coverage/lcov-report/index.js")
1110        );
1111    }
1112
1113    #[test]
1114    fn resolve_source_files_not_ignored_by_default() {
1115        let resolved = make_config(false).resolve(
1116            PathBuf::from("/project"),
1117            OutputFormat::Human,
1118            1,
1119            true,
1120            true,
1121        );
1122        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1123        assert!(
1124            !resolved
1125                .ignore_patterns
1126                .is_match("src/components/Button.tsx")
1127        );
1128        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1129    }
1130
1131    // ── Custom ignore patterns ──────────────────────────────────────
1132
1133    #[test]
1134    fn resolve_custom_ignore_patterns_merged_with_defaults() {
1135        let mut config = make_config(false);
1136        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1137        let resolved = config.resolve(
1138            PathBuf::from("/project"),
1139            OutputFormat::Human,
1140            1,
1141            true,
1142            true,
1143        );
1144        // Custom pattern works
1145        assert!(
1146            resolved
1147                .ignore_patterns
1148                .is_match("src/__generated__/types.ts")
1149        );
1150        // Default patterns still work
1151        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1152    }
1153
1154    // ── Config fields passthrough ───────────────────────────────────
1155
1156    #[test]
1157    fn resolve_passes_through_entry_patterns() {
1158        let mut config = make_config(false);
1159        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1160        let resolved = config.resolve(
1161            PathBuf::from("/project"),
1162            OutputFormat::Human,
1163            1,
1164            true,
1165            true,
1166        );
1167        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1168    }
1169
1170    #[test]
1171    fn resolve_passes_through_ignore_dependencies() {
1172        let mut config = make_config(false);
1173        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1174        let resolved = config.resolve(
1175            PathBuf::from("/project"),
1176            OutputFormat::Human,
1177            1,
1178            true,
1179            true,
1180        );
1181        assert_eq!(
1182            resolved.ignore_dependencies,
1183            vec!["postcss", "autoprefixer"]
1184        );
1185    }
1186
1187    #[test]
1188    fn resolve_sets_cache_dir() {
1189        let resolved = make_config(false).resolve(
1190            PathBuf::from("/my/project"),
1191            OutputFormat::Human,
1192            1,
1193            true,
1194            true,
1195        );
1196        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1197    }
1198
1199    #[test]
1200    fn resolve_passes_through_thread_count() {
1201        let resolved = make_config(false).resolve(
1202            PathBuf::from("/project"),
1203            OutputFormat::Human,
1204            8,
1205            true,
1206            true,
1207        );
1208        assert_eq!(resolved.threads, 8);
1209    }
1210
1211    #[test]
1212    fn resolve_passes_through_quiet_flag() {
1213        let resolved = make_config(false).resolve(
1214            PathBuf::from("/project"),
1215            OutputFormat::Human,
1216            1,
1217            true,
1218            false,
1219        );
1220        assert!(!resolved.quiet);
1221
1222        let resolved2 = make_config(false).resolve(
1223            PathBuf::from("/project"),
1224            OutputFormat::Human,
1225            1,
1226            true,
1227            true,
1228        );
1229        assert!(resolved2.quiet);
1230    }
1231
1232    #[test]
1233    fn resolve_passes_through_no_cache_flag() {
1234        let resolved_no_cache = make_config(false).resolve(
1235            PathBuf::from("/project"),
1236            OutputFormat::Human,
1237            1,
1238            true,
1239            true,
1240        );
1241        assert!(resolved_no_cache.no_cache);
1242
1243        let resolved_with_cache = make_config(false).resolve(
1244            PathBuf::from("/project"),
1245            OutputFormat::Human,
1246            1,
1247            false,
1248            true,
1249        );
1250        assert!(!resolved_with_cache.no_cache);
1251    }
1252
1253    // ── Override resolution edge cases ───────────────────────────────
1254
1255    #[test]
1256    fn resolve_override_with_invalid_glob_skipped() {
1257        let mut config = make_config(false);
1258        config.overrides = vec![ConfigOverride {
1259            files: vec!["[invalid".to_string()],
1260            rules: PartialRulesConfig {
1261                unused_files: Some(Severity::Off),
1262                ..Default::default()
1263            },
1264        }];
1265        let resolved = config.resolve(
1266            PathBuf::from("/project"),
1267            OutputFormat::Human,
1268            1,
1269            true,
1270            true,
1271        );
1272        // Invalid glob should be skipped, so no overrides should be compiled
1273        assert!(
1274            resolved.overrides.is_empty(),
1275            "override with invalid glob should be skipped"
1276        );
1277    }
1278
1279    #[test]
1280    fn resolve_override_with_empty_files_skipped() {
1281        let mut config = make_config(false);
1282        config.overrides = vec![ConfigOverride {
1283            files: vec![],
1284            rules: PartialRulesConfig {
1285                unused_files: Some(Severity::Off),
1286                ..Default::default()
1287            },
1288        }];
1289        let resolved = config.resolve(
1290            PathBuf::from("/project"),
1291            OutputFormat::Human,
1292            1,
1293            true,
1294            true,
1295        );
1296        assert!(
1297            resolved.overrides.is_empty(),
1298            "override with no file patterns should be skipped"
1299        );
1300    }
1301
1302    #[test]
1303    fn resolve_multiple_valid_overrides() {
1304        let mut config = make_config(false);
1305        config.overrides = vec![
1306            ConfigOverride {
1307                files: vec!["*.test.ts".to_string()],
1308                rules: PartialRulesConfig {
1309                    unused_exports: Some(Severity::Off),
1310                    ..Default::default()
1311                },
1312            },
1313            ConfigOverride {
1314                files: vec!["*.stories.tsx".to_string()],
1315                rules: PartialRulesConfig {
1316                    unused_files: Some(Severity::Off),
1317                    ..Default::default()
1318                },
1319            },
1320        ];
1321        let resolved = config.resolve(
1322            PathBuf::from("/project"),
1323            OutputFormat::Human,
1324            1,
1325            true,
1326            true,
1327        );
1328        assert_eq!(resolved.overrides.len(), 2);
1329    }
1330
1331    // ── IgnoreExportRule ────────────────────────────────────────────
1332
1333    #[test]
1334    fn ignore_export_rule_deserialize() {
1335        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1336        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1337        assert_eq!(rule.file, "src/types/*.ts");
1338        assert_eq!(rule.exports, vec!["*"]);
1339    }
1340
1341    #[test]
1342    fn ignore_export_rule_specific_exports() {
1343        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1344        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1345        assert_eq!(rule.exports.len(), 3);
1346        assert!(rule.exports.contains(&"FOO".to_string()));
1347    }
1348
1349    mod proptests {
1350        use super::*;
1351        use proptest::prelude::*;
1352
1353        fn arb_resolved_config(production: bool) -> ResolvedConfig {
1354            make_config(production).resolve(
1355                PathBuf::from("/project"),
1356                OutputFormat::Human,
1357                1,
1358                true,
1359                true,
1360            )
1361        }
1362
1363        proptest! {
1364            /// Resolved config always has non-empty ignore patterns (defaults are always added).
1365            #[test]
1366            fn resolved_config_has_default_ignores(production in any::<bool>()) {
1367                let resolved = arb_resolved_config(production);
1368                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
1369                prop_assert!(
1370                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1371                    "Default ignore should match node_modules"
1372                );
1373                prop_assert!(
1374                    resolved.ignore_patterns.is_match("dist/bundle.js"),
1375                    "Default ignore should match dist"
1376                );
1377            }
1378
1379            /// Production mode always forces dev and optional deps to Off.
1380            #[test]
1381            fn production_forces_dev_deps_off(_unused in Just(())) {
1382                let resolved = arb_resolved_config(true);
1383                prop_assert_eq!(
1384                    resolved.rules.unused_dev_dependencies,
1385                    Severity::Off,
1386                    "Production should force unused_dev_dependencies off"
1387                );
1388                prop_assert_eq!(
1389                    resolved.rules.unused_optional_dependencies,
1390                    Severity::Off,
1391                    "Production should force unused_optional_dependencies off"
1392                );
1393            }
1394
1395            /// Non-production mode preserves default severity for dev deps.
1396            #[test]
1397            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1398                let resolved = arb_resolved_config(false);
1399                prop_assert_eq!(
1400                    resolved.rules.unused_dev_dependencies,
1401                    Severity::Warn,
1402                    "Non-production should keep default dev dep severity"
1403                );
1404            }
1405
1406            /// Cache dir is always root/.fallow.
1407            #[test]
1408            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1409                let root = PathBuf::from(format!("/project/{dir_suffix}"));
1410                let expected_cache = root.join(".fallow");
1411                let resolved = make_config(false).resolve(
1412                    root,
1413                    OutputFormat::Human,
1414                    1,
1415                    true,
1416                    true,
1417                );
1418                prop_assert_eq!(
1419                    resolved.cache_dir, expected_cache,
1420                    "Cache dir should be root/.fallow"
1421                );
1422            }
1423
1424            /// Thread count is always passed through exactly.
1425            #[test]
1426            fn threads_passed_through(threads in 1..64usize) {
1427                let resolved = make_config(false).resolve(
1428                    PathBuf::from("/project"),
1429                    OutputFormat::Human,
1430                    threads,
1431                    true,
1432                    true,
1433                );
1434                prop_assert_eq!(
1435                    resolved.threads, threads,
1436                    "Thread count should be passed through"
1437                );
1438            }
1439
1440            /// Custom ignore patterns are merged with defaults, not replacing them.
1441            /// Uses a pattern regex that cannot match node_modules paths, so the
1442            /// assertion proves the default pattern is what provides the match.
1443            #[test]
1444            fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1445                let mut config = make_config(false);
1446                config.ignore_patterns = vec![pattern];
1447                let resolved = config.resolve(
1448                    PathBuf::from("/project"),
1449                    OutputFormat::Human,
1450                    1,
1451                    true,
1452                    true,
1453                );
1454                // Defaults should still be present (the custom pattern cannot
1455                // match this path, so only the default **/node_modules/** can)
1456                prop_assert!(
1457                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1458                    "Default node_modules ignore should still be active"
1459                );
1460            }
1461        }
1462    }
1463
1464    // ── Boundary preset expansion ──────────────────────────────────
1465
1466    #[test]
1467    fn resolve_expands_boundary_preset() {
1468        use crate::config::boundaries::BoundaryPreset;
1469
1470        let mut config = make_config(false);
1471        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1472        let resolved = config.resolve(
1473            PathBuf::from("/project"),
1474            OutputFormat::Human,
1475            1,
1476            true,
1477            true,
1478        );
1479        // Preset should have been expanded into zones (no tsconfig → fallback to "src")
1480        assert_eq!(resolved.boundaries.zones.len(), 3);
1481        assert_eq!(resolved.boundaries.rules.len(), 3);
1482        assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1483        assert_eq!(
1484            resolved.boundaries.classify_zone("src/adapters/http.ts"),
1485            Some("adapters")
1486        );
1487    }
1488
1489    #[test]
1490    fn resolve_boundary_preset_with_user_override() {
1491        use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1492
1493        let mut config = make_config(false);
1494        config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1495        config.boundaries.zones = vec![BoundaryZone {
1496            name: "domain".to_string(),
1497            patterns: vec!["src/core/**".to_string()],
1498            root: None,
1499        }];
1500        let resolved = config.resolve(
1501            PathBuf::from("/project"),
1502            OutputFormat::Human,
1503            1,
1504            true,
1505            true,
1506        );
1507        // User zone "domain" replaced preset zone "domain"
1508        assert_eq!(resolved.boundaries.zones.len(), 3);
1509        // The user's pattern should be used for domain zone
1510        assert_eq!(
1511            resolved.boundaries.classify_zone("src/core/user.ts"),
1512            Some("domain")
1513        );
1514        // Original preset pattern should NOT match
1515        assert_eq!(
1516            resolved.boundaries.classify_zone("src/domain/user.ts"),
1517            None
1518        );
1519    }
1520
1521    #[test]
1522    fn resolve_no_preset_unchanged() {
1523        let config = make_config(false);
1524        let resolved = config.resolve(
1525            PathBuf::from("/project"),
1526            OutputFormat::Human,
1527            1,
1528            true,
1529            true,
1530        );
1531        assert!(resolved.boundaries.is_empty());
1532    }
1533}