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