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