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