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
24static INTER_FILE_WARN_SEEN: OnceLock<Mutex<FxHashSet<u64>>> = OnceLock::new();
40
41fn 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
56fn 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
77pub struct IgnoreExportRule {
78 pub file: String,
80 pub exports: Vec<String>,
82}
83
84#[derive(Debug)]
91pub struct CompiledIgnoreExportRule {
92 pub matcher: globset::GlobMatcher,
93 pub exports: Vec<String>,
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct IgnoreCatalogReferenceRule {
112 pub package: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub catalog: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub consumer: Option<String>,
120}
121
122#[derive(Debug)]
124pub struct CompiledIgnoreCatalogReferenceRule {
125 pub package: String,
126 pub catalog: Option<String>,
127 pub consumer_matcher: Option<globset::GlobMatcher>,
129}
130
131impl CompiledIgnoreCatalogReferenceRule {
132 #[must_use]
136 pub fn matches(&self, package: &str, catalog: &str, consumer_path: &str) -> bool {
137 if self.package != package {
138 return false;
139 }
140 if let Some(catalog_filter) = &self.catalog
141 && catalog_filter != catalog
142 {
143 return false;
144 }
145 if let Some(matcher) = &self.consumer_matcher
146 && !matcher.is_match(consumer_path)
147 {
148 return false;
149 }
150 true
151 }
152}
153
154#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct IgnoreDependencyOverrideRule {
171 pub package: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub source: Option<String>,
177}
178
179#[derive(Debug)]
181pub struct CompiledIgnoreDependencyOverrideRule {
182 pub package: String,
183 pub source: Option<String>,
185}
186
187impl CompiledIgnoreDependencyOverrideRule {
188 #[must_use]
192 pub fn matches(&self, package: &str, source_label: &str) -> bool {
193 if self.package != package {
194 return false;
195 }
196 if let Some(source_filter) = &self.source
197 && source_filter != source_label
198 {
199 return false;
200 }
201 true
202 }
203}
204
205#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct ConfigOverride {
209 pub files: Vec<String>,
211 #[serde(default)]
213 pub rules: PartialRulesConfig,
214}
215
216#[derive(Debug)]
218pub struct ResolvedOverride {
219 pub matchers: Vec<globset::GlobMatcher>,
220 pub rules: PartialRulesConfig,
221}
222
223#[derive(Debug)]
225pub struct ResolvedConfig {
226 pub root: PathBuf,
227 pub entry_patterns: Vec<String>,
228 pub ignore_patterns: GlobSet,
229 pub output: OutputFormat,
230 pub cache_dir: PathBuf,
231 pub threads: usize,
232 pub no_cache: bool,
233 pub cache_max_size_mb: Option<u32>,
241 pub cache_config_hash: u64,
250 pub ignore_dependencies: Vec<String>,
251 pub ignore_export_rules: Vec<IgnoreExportRule>,
252 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
258 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
260 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
263 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
265 pub used_class_members: Vec<UsedClassMemberRule>,
269 pub ignore_decorators: Vec<String>,
274 pub duplicates: DuplicatesConfig,
275 pub health: HealthConfig,
276 pub rules: RulesConfig,
277 pub boundaries: ResolvedBoundaryConfig,
279 pub production: bool,
281 pub quiet: bool,
283 pub external_plugins: Vec<ExternalPluginDef>,
285 pub dynamically_loaded: Vec<String>,
287 pub overrides: Vec<ResolvedOverride>,
289 pub regression: Option<super::RegressionConfig>,
291 pub audit: super::AuditConfig,
293 pub codeowners: Option<String>,
295 pub public_packages: Vec<String>,
298 pub flags: FlagsConfig,
300 pub fix: super::FixConfig,
302 pub resolve: ResolveConfig,
304 pub include_entry_exports: bool,
309}
310
311fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
328 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
329 names.sort_unstable();
330 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
331 for name in names {
332 hasher.update(&(name.len() as u32).to_le_bytes());
335 hasher.update(name.as_bytes());
336 }
337 hasher.digest()
338}
339
340impl FallowConfig {
341 pub fn resolve(
349 self,
350 root: PathBuf,
351 output: OutputFormat,
352 threads: usize,
353 no_cache: bool,
354 quiet: bool,
355 cache_max_size_mb: Option<u32>,
356 ) -> ResolvedConfig {
357 let mut ignore_builder = GlobSetBuilder::new();
362 for pattern in &self.ignore_patterns {
363 ignore_builder.add(
364 Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
365 );
366 }
367
368 let default_ignores = [
372 "**/node_modules/**",
373 "**/dist/**",
374 "build/**",
375 "**/.git/**",
376 "**/coverage/**",
377 "**/*.min.js",
378 "**/*.min.mjs",
379 ];
380 for pattern in &default_ignores {
381 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
382 }
383
384 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
385 let cache_dir = root.join(".fallow");
386
387 let mut rules = self.rules;
388
389 let production = self.production.global();
391 if production {
392 rules.unused_dev_dependencies = Severity::Off;
393 rules.unused_optional_dependencies = Severity::Off;
394 }
395
396 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
397 external_plugins.extend(self.framework);
399
400 let mut boundaries = self.boundaries;
403 if boundaries.preset.is_some() {
404 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
405 .filter(|r| {
406 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
407 })
408 .unwrap_or_else(|| "src".to_owned());
409 if source_root != "src" {
410 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
411 }
412 boundaries.expand(&source_root);
413 }
414 let logical_groups = boundaries.expand_auto_discover(&root);
427
428 let mut boundaries = boundaries.resolve();
436 boundaries.logical_groups = logical_groups;
440
441 let overrides = self
443 .overrides
444 .into_iter()
445 .filter_map(|o| {
446 if o.rules.duplicate_exports.is_some()
456 && record_inter_file_warn_seen("duplicate-exports", &o.files)
457 {
458 let files = o.files.join(", ");
459 tracing::warn!(
460 "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."
461 );
462 }
463 if o.rules.circular_dependencies.is_some()
464 && record_inter_file_warn_seen("circular-dependency", &o.files)
465 {
466 let files = o.files.join(", ");
467 tracing::warn!(
468 "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."
469 );
470 }
471 if o.rules.re_export_cycle.is_some()
472 && record_inter_file_warn_seen("re-export-cycle", &o.files)
473 {
474 let files = o.files.join(", ");
475 tracing::warn!(
476 "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."
477 );
478 }
479 let matchers: Vec<globset::GlobMatcher> = o
480 .files
481 .iter()
482 .map(|pattern| {
483 Glob::new(pattern)
484 .expect("overrides[].files pattern was validated at config load time")
485 .compile_matcher()
486 })
487 .collect();
488 if matchers.is_empty() {
489 None
490 } else {
491 Some(ResolvedOverride {
492 matchers,
493 rules: o.rules,
494 })
495 }
496 })
497 .collect();
498
499 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
503 .ignore_exports
504 .iter()
505 .map(|rule| CompiledIgnoreExportRule {
506 matcher: Glob::new(&rule.file)
507 .expect("ignoreExports[].file was validated at config load time")
508 .compile_matcher(),
509 exports: rule.exports.clone(),
510 })
511 .collect();
512
513 let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
514 .ignore_catalog_references
515 .iter()
516 .map(|rule| CompiledIgnoreCatalogReferenceRule {
517 package: rule.package.clone(),
518 catalog: rule.catalog.clone(),
519 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
520 Glob::new(pattern)
521 .expect(
522 "ignoreCatalogReferences[].consumer was validated at config load time",
523 )
524 .compile_matcher()
525 }),
526 })
527 .collect();
528
529 let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
530 .ignore_dependency_overrides
531 .iter()
532 .map(|rule| CompiledIgnoreDependencyOverrideRule {
533 package: rule.package.clone(),
534 source: rule.source.clone(),
535 })
536 .collect();
537
538 let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
545
546 let cache_config_hash = if no_cache {
552 0
553 } else {
554 compute_cache_config_hash(&external_plugins)
555 };
556
557 ResolvedConfig {
558 root,
559 entry_patterns: self.entry,
560 ignore_patterns: compiled_ignore_patterns,
561 output,
562 cache_dir,
563 threads,
564 no_cache,
565 cache_max_size_mb,
566 cache_config_hash,
567 ignore_dependencies: self.ignore_dependencies,
568 ignore_export_rules: self.ignore_exports,
569 compiled_ignore_exports,
570 compiled_ignore_catalog_references,
571 compiled_ignore_dependency_overrides,
572 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
573 used_class_members: self.used_class_members,
574 ignore_decorators: self.ignore_decorators,
575 duplicates: self.duplicates,
576 health: self.health,
577 rules,
578 boundaries,
579 production,
580 quiet,
581 external_plugins,
582 dynamically_loaded: self.dynamically_loaded,
583 overrides,
584 regression: self.regression,
585 audit: self.audit,
586 codeowners: self.codeowners,
587 public_packages: self.public_packages,
588 flags: self.flags,
589 fix: self.fix,
590 resolve: self.resolve,
591 include_entry_exports: self.include_entry_exports,
592 }
593 }
594}
595
596impl ResolvedConfig {
597 #[must_use]
600 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
601 if self.overrides.is_empty() {
602 return self.rules.clone();
603 }
604
605 let relative = path.strip_prefix(&self.root).unwrap_or(path);
606 let relative_str = relative.to_string_lossy();
607
608 let mut rules = self.rules.clone();
609 for override_entry in &self.overrides {
610 let matches = override_entry
611 .matchers
612 .iter()
613 .any(|m| m.is_match(relative_str.as_ref()));
614 if matches {
615 rules.apply_partial(&override_entry.rules);
616 }
617 }
618 rules
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::CacheConfig;
626 use crate::config::boundaries::BoundaryConfig;
627 use crate::config::health::HealthConfig;
628
629 #[test]
630 fn overrides_deserialize() {
631 let json_str = r#"{
632 "overrides": [{
633 "files": ["*.test.ts"],
634 "rules": {
635 "unused-exports": "off"
636 }
637 }]
638 }"#;
639 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
640 assert_eq!(config.overrides.len(), 1);
641 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
642 assert_eq!(
643 config.overrides[0].rules.unused_exports,
644 Some(Severity::Off)
645 );
646 assert_eq!(config.overrides[0].rules.unused_files, None);
647 }
648
649 #[test]
650 fn resolve_rules_for_path_no_overrides() {
651 let config = FallowConfig {
652 schema: None,
653 extends: vec![],
654 entry: vec![],
655 ignore_patterns: vec![],
656 framework: vec![],
657 workspaces: None,
658 ignore_dependencies: vec![],
659 ignore_exports: vec![],
660 ignore_catalog_references: vec![],
661 ignore_dependency_overrides: vec![],
662 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
663 used_class_members: vec![],
664 ignore_decorators: vec![],
665 duplicates: DuplicatesConfig::default(),
666 health: HealthConfig::default(),
667 rules: RulesConfig::default(),
668 boundaries: BoundaryConfig::default(),
669 production: false.into(),
670 plugins: vec![],
671 dynamically_loaded: vec![],
672 overrides: vec![],
673 regression: None,
674 audit: crate::config::AuditConfig::default(),
675 codeowners: None,
676 public_packages: vec![],
677 flags: FlagsConfig::default(),
678 fix: crate::config::FixConfig::default(),
679 resolve: ResolveConfig::default(),
680 sealed: false,
681 include_entry_exports: false,
682 cache: CacheConfig::default(),
683 };
684 let resolved = config.resolve(
685 PathBuf::from("/project"),
686 OutputFormat::Human,
687 1,
688 true,
689 true,
690 None,
691 );
692 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
693 assert_eq!(rules.unused_files, Severity::Error);
694 }
695
696 #[test]
697 fn resolve_rules_for_path_with_matching_override() {
698 let config = FallowConfig {
699 schema: None,
700 extends: vec![],
701 entry: vec![],
702 ignore_patterns: vec![],
703 framework: vec![],
704 workspaces: None,
705 ignore_dependencies: vec![],
706 ignore_exports: vec![],
707 ignore_catalog_references: vec![],
708 ignore_dependency_overrides: vec![],
709 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
710 used_class_members: vec![],
711 ignore_decorators: vec![],
712 duplicates: DuplicatesConfig::default(),
713 health: HealthConfig::default(),
714 rules: RulesConfig::default(),
715 boundaries: BoundaryConfig::default(),
716 production: false.into(),
717 plugins: vec![],
718 dynamically_loaded: vec![],
719 overrides: vec![ConfigOverride {
720 files: vec!["*.test.ts".to_string()],
721 rules: PartialRulesConfig {
722 unused_exports: Some(Severity::Off),
723 ..Default::default()
724 },
725 }],
726 regression: None,
727 audit: crate::config::AuditConfig::default(),
728 codeowners: None,
729 public_packages: vec![],
730 flags: FlagsConfig::default(),
731 fix: crate::config::FixConfig::default(),
732 resolve: ResolveConfig::default(),
733 sealed: false,
734 include_entry_exports: false,
735 cache: CacheConfig::default(),
736 };
737 let resolved = config.resolve(
738 PathBuf::from("/project"),
739 OutputFormat::Human,
740 1,
741 true,
742 true,
743 None,
744 );
745
746 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
748 assert_eq!(test_rules.unused_exports, Severity::Off);
749 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
753 assert_eq!(src_rules.unused_exports, Severity::Error);
754 }
755
756 #[test]
757 fn resolve_rules_for_path_later_override_wins() {
758 let 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_exports: vec![],
767 ignore_catalog_references: vec![],
768 ignore_dependency_overrides: vec![],
769 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
770 used_class_members: vec![],
771 ignore_decorators: vec![],
772 duplicates: DuplicatesConfig::default(),
773 health: HealthConfig::default(),
774 rules: RulesConfig::default(),
775 boundaries: BoundaryConfig::default(),
776 production: false.into(),
777 plugins: vec![],
778 dynamically_loaded: vec![],
779 overrides: vec![
780 ConfigOverride {
781 files: vec!["*.ts".to_string()],
782 rules: PartialRulesConfig {
783 unused_files: Some(Severity::Warn),
784 ..Default::default()
785 },
786 },
787 ConfigOverride {
788 files: vec!["*.test.ts".to_string()],
789 rules: PartialRulesConfig {
790 unused_files: Some(Severity::Off),
791 ..Default::default()
792 },
793 },
794 ],
795 regression: None,
796 audit: crate::config::AuditConfig::default(),
797 codeowners: None,
798 public_packages: vec![],
799 flags: FlagsConfig::default(),
800 fix: crate::config::FixConfig::default(),
801 resolve: ResolveConfig::default(),
802 sealed: false,
803 include_entry_exports: false,
804 cache: CacheConfig::default(),
805 };
806 let resolved = config.resolve(
807 PathBuf::from("/project"),
808 OutputFormat::Human,
809 1,
810 true,
811 true,
812 None,
813 );
814
815 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
817 assert_eq!(rules.unused_files, Severity::Off);
818
819 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
821 assert_eq!(rules2.unused_files, Severity::Warn);
822 }
823
824 #[test]
825 fn resolve_keeps_inter_file_rule_override_after_warning() {
826 let config = FallowConfig {
832 schema: None,
833 extends: vec![],
834 entry: vec![],
835 ignore_patterns: vec![],
836 framework: vec![],
837 workspaces: None,
838 ignore_dependencies: vec![],
839 ignore_exports: vec![],
840 ignore_catalog_references: vec![],
841 ignore_dependency_overrides: vec![],
842 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
843 used_class_members: vec![],
844 ignore_decorators: vec![],
845 duplicates: DuplicatesConfig::default(),
846 health: HealthConfig::default(),
847 rules: RulesConfig::default(),
848 boundaries: BoundaryConfig::default(),
849 production: false.into(),
850 plugins: vec![],
851 dynamically_loaded: vec![],
852 overrides: vec![ConfigOverride {
853 files: vec!["**/ui/**".to_string()],
854 rules: PartialRulesConfig {
855 duplicate_exports: Some(Severity::Off),
856 unused_files: Some(Severity::Warn),
857 ..Default::default()
858 },
859 }],
860 regression: None,
861 audit: crate::config::AuditConfig::default(),
862 codeowners: None,
863 public_packages: vec![],
864 flags: FlagsConfig::default(),
865 fix: crate::config::FixConfig::default(),
866 resolve: ResolveConfig::default(),
867 sealed: false,
868 include_entry_exports: false,
869 cache: CacheConfig::default(),
870 };
871 let resolved = config.resolve(
872 PathBuf::from("/project"),
873 OutputFormat::Human,
874 1,
875 true,
876 true,
877 None,
878 );
879 assert_eq!(
880 resolved.overrides.len(),
881 1,
882 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
883 );
884 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
885 assert_eq!(rules.unused_files, Severity::Warn);
886 }
887
888 #[test]
889 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
890 reset_inter_file_warn_dedup_for_test();
894 let files_a = vec!["__test_dedup_a/*".to_string()];
895 let files_b = vec!["__test_dedup_b/*".to_string()];
896
897 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
899 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
900 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
901
902 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
904 assert!(!record_inter_file_warn_seen(
905 "circular-dependency",
906 &files_a
907 ));
908
909 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
911
912 let files_reordered = vec![
914 "__test_dedup_b/*".to_string(),
915 "__test_dedup_a/*".to_string(),
916 ];
917 let files_natural = vec![
918 "__test_dedup_a/*".to_string(),
919 "__test_dedup_b/*".to_string(),
920 ];
921 reset_inter_file_warn_dedup_for_test();
922 assert!(record_inter_file_warn_seen(
923 "duplicate-exports",
924 &files_natural
925 ));
926 assert!(!record_inter_file_warn_seen(
927 "duplicate-exports",
928 &files_reordered
929 ));
930 }
931
932 #[test]
933 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
934 reset_inter_file_warn_dedup_for_test();
939 let files = vec!["__test_resolve_dedup/**".to_string()];
940 let build_config = || FallowConfig {
941 schema: None,
942 extends: vec![],
943 entry: vec![],
944 ignore_patterns: vec![],
945 framework: vec![],
946 workspaces: None,
947 ignore_dependencies: vec![],
948 ignore_exports: vec![],
949 ignore_catalog_references: vec![],
950 ignore_dependency_overrides: vec![],
951 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
952 used_class_members: vec![],
953 ignore_decorators: vec![],
954 duplicates: DuplicatesConfig::default(),
955 health: HealthConfig::default(),
956 rules: RulesConfig::default(),
957 boundaries: BoundaryConfig::default(),
958 production: false.into(),
959 plugins: vec![],
960 dynamically_loaded: vec![],
961 overrides: vec![ConfigOverride {
962 files: files.clone(),
963 rules: PartialRulesConfig {
964 duplicate_exports: Some(Severity::Off),
965 ..Default::default()
966 },
967 }],
968 regression: None,
969 audit: crate::config::AuditConfig::default(),
970 codeowners: None,
971 public_packages: vec![],
972 flags: FlagsConfig::default(),
973 fix: crate::config::FixConfig::default(),
974 resolve: ResolveConfig::default(),
975 sealed: false,
976 include_entry_exports: false,
977 cache: CacheConfig::default(),
978 };
979 for _ in 0..10 {
980 let _ = build_config().resolve(
981 PathBuf::from("/project"),
982 OutputFormat::Human,
983 1,
984 true,
985 true,
986 None,
987 );
988 }
989 assert!(
993 !record_inter_file_warn_seen("duplicate-exports", &files),
994 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
995 );
996 }
997
998 fn make_config(production: bool) -> FallowConfig {
1000 FallowConfig {
1001 schema: None,
1002 extends: vec![],
1003 entry: vec![],
1004 ignore_patterns: vec![],
1005 framework: vec![],
1006 workspaces: None,
1007 ignore_dependencies: vec![],
1008 ignore_exports: vec![],
1009 ignore_catalog_references: vec![],
1010 ignore_dependency_overrides: vec![],
1011 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1012 used_class_members: vec![],
1013 ignore_decorators: vec![],
1014 duplicates: DuplicatesConfig::default(),
1015 health: HealthConfig::default(),
1016 rules: RulesConfig::default(),
1017 boundaries: BoundaryConfig::default(),
1018 production: production.into(),
1019 plugins: vec![],
1020 dynamically_loaded: vec![],
1021 overrides: vec![],
1022 regression: None,
1023 audit: crate::config::AuditConfig::default(),
1024 codeowners: None,
1025 public_packages: vec![],
1026 flags: FlagsConfig::default(),
1027 fix: crate::config::FixConfig::default(),
1028 resolve: ResolveConfig::default(),
1029 sealed: false,
1030 include_entry_exports: false,
1031 cache: CacheConfig::default(),
1032 }
1033 }
1034
1035 #[test]
1038 fn resolve_production_forces_dev_deps_off() {
1039 let resolved = make_config(true).resolve(
1040 PathBuf::from("/project"),
1041 OutputFormat::Human,
1042 1,
1043 true,
1044 true,
1045 None,
1046 );
1047 assert_eq!(
1048 resolved.rules.unused_dev_dependencies,
1049 Severity::Off,
1050 "production mode should force unused_dev_dependencies to off"
1051 );
1052 }
1053
1054 #[test]
1055 fn resolve_production_forces_optional_deps_off() {
1056 let resolved = make_config(true).resolve(
1057 PathBuf::from("/project"),
1058 OutputFormat::Human,
1059 1,
1060 true,
1061 true,
1062 None,
1063 );
1064 assert_eq!(
1065 resolved.rules.unused_optional_dependencies,
1066 Severity::Off,
1067 "production mode should force unused_optional_dependencies to off"
1068 );
1069 }
1070
1071 #[test]
1072 fn resolve_production_preserves_other_rules() {
1073 let resolved = make_config(true).resolve(
1074 PathBuf::from("/project"),
1075 OutputFormat::Human,
1076 1,
1077 true,
1078 true,
1079 None,
1080 );
1081 assert_eq!(resolved.rules.unused_files, Severity::Error);
1083 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1084 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1085 }
1086
1087 #[test]
1088 fn resolve_non_production_keeps_dev_deps_default() {
1089 let resolved = make_config(false).resolve(
1090 PathBuf::from("/project"),
1091 OutputFormat::Human,
1092 1,
1093 true,
1094 true,
1095 None,
1096 );
1097 assert_eq!(
1098 resolved.rules.unused_dev_dependencies,
1099 Severity::Warn,
1100 "non-production should keep default severity"
1101 );
1102 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1103 }
1104
1105 #[test]
1106 fn resolve_production_flag_stored() {
1107 let resolved = make_config(true).resolve(
1108 PathBuf::from("/project"),
1109 OutputFormat::Human,
1110 1,
1111 true,
1112 true,
1113 None,
1114 );
1115 assert!(resolved.production);
1116
1117 let resolved2 = make_config(false).resolve(
1118 PathBuf::from("/project"),
1119 OutputFormat::Human,
1120 1,
1121 true,
1122 true,
1123 None,
1124 );
1125 assert!(!resolved2.production);
1126 }
1127
1128 #[test]
1131 fn resolve_default_ignores_node_modules() {
1132 let resolved = make_config(false).resolve(
1133 PathBuf::from("/project"),
1134 OutputFormat::Human,
1135 1,
1136 true,
1137 true,
1138 None,
1139 );
1140 assert!(
1141 resolved
1142 .ignore_patterns
1143 .is_match("node_modules/lodash/index.js")
1144 );
1145 assert!(
1146 resolved
1147 .ignore_patterns
1148 .is_match("packages/a/node_modules/react/index.js")
1149 );
1150 }
1151
1152 #[test]
1153 fn resolve_default_ignores_dist() {
1154 let resolved = make_config(false).resolve(
1155 PathBuf::from("/project"),
1156 OutputFormat::Human,
1157 1,
1158 true,
1159 true,
1160 None,
1161 );
1162 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1163 assert!(
1164 resolved
1165 .ignore_patterns
1166 .is_match("packages/ui/dist/index.js")
1167 );
1168 }
1169
1170 #[test]
1171 fn resolve_default_ignores_root_build_only() {
1172 let resolved = make_config(false).resolve(
1173 PathBuf::from("/project"),
1174 OutputFormat::Human,
1175 1,
1176 true,
1177 true,
1178 None,
1179 );
1180 assert!(
1181 resolved.ignore_patterns.is_match("build/output.js"),
1182 "root build/ should be ignored"
1183 );
1184 assert!(
1186 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1187 "nested build/ should NOT be ignored by default"
1188 );
1189 }
1190
1191 #[test]
1192 fn resolve_default_ignores_minified_files() {
1193 let resolved = make_config(false).resolve(
1194 PathBuf::from("/project"),
1195 OutputFormat::Human,
1196 1,
1197 true,
1198 true,
1199 None,
1200 );
1201 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1202 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1203 }
1204
1205 #[test]
1206 fn resolve_default_ignores_git() {
1207 let resolved = make_config(false).resolve(
1208 PathBuf::from("/project"),
1209 OutputFormat::Human,
1210 1,
1211 true,
1212 true,
1213 None,
1214 );
1215 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1216 }
1217
1218 #[test]
1219 fn resolve_default_ignores_coverage() {
1220 let resolved = make_config(false).resolve(
1221 PathBuf::from("/project"),
1222 OutputFormat::Human,
1223 1,
1224 true,
1225 true,
1226 None,
1227 );
1228 assert!(
1229 resolved
1230 .ignore_patterns
1231 .is_match("coverage/lcov-report/index.js")
1232 );
1233 }
1234
1235 #[test]
1236 fn resolve_source_files_not_ignored_by_default() {
1237 let resolved = make_config(false).resolve(
1238 PathBuf::from("/project"),
1239 OutputFormat::Human,
1240 1,
1241 true,
1242 true,
1243 None,
1244 );
1245 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1246 assert!(
1247 !resolved
1248 .ignore_patterns
1249 .is_match("src/components/Button.tsx")
1250 );
1251 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1252 }
1253
1254 #[test]
1257 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1258 let mut config = make_config(false);
1259 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1260 let resolved = config.resolve(
1261 PathBuf::from("/project"),
1262 OutputFormat::Human,
1263 1,
1264 true,
1265 true,
1266 None,
1267 );
1268 assert!(
1270 resolved
1271 .ignore_patterns
1272 .is_match("src/__generated__/types.ts")
1273 );
1274 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1276 }
1277
1278 #[test]
1281 fn resolve_passes_through_entry_patterns() {
1282 let mut config = make_config(false);
1283 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1284 let resolved = config.resolve(
1285 PathBuf::from("/project"),
1286 OutputFormat::Human,
1287 1,
1288 true,
1289 true,
1290 None,
1291 );
1292 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1293 }
1294
1295 #[test]
1296 fn resolve_passes_through_ignore_dependencies() {
1297 let mut config = make_config(false);
1298 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1299 let resolved = config.resolve(
1300 PathBuf::from("/project"),
1301 OutputFormat::Human,
1302 1,
1303 true,
1304 true,
1305 None,
1306 );
1307 assert_eq!(
1308 resolved.ignore_dependencies,
1309 vec!["postcss", "autoprefixer"]
1310 );
1311 }
1312
1313 #[test]
1314 fn resolve_sets_cache_dir() {
1315 let resolved = make_config(false).resolve(
1316 PathBuf::from("/my/project"),
1317 OutputFormat::Human,
1318 1,
1319 true,
1320 true,
1321 None,
1322 );
1323 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1324 }
1325
1326 #[test]
1327 fn resolve_passes_through_thread_count() {
1328 let resolved = make_config(false).resolve(
1329 PathBuf::from("/project"),
1330 OutputFormat::Human,
1331 8,
1332 true,
1333 true,
1334 None,
1335 );
1336 assert_eq!(resolved.threads, 8);
1337 }
1338
1339 #[test]
1340 fn resolve_passes_through_quiet_flag() {
1341 let resolved = make_config(false).resolve(
1342 PathBuf::from("/project"),
1343 OutputFormat::Human,
1344 1,
1345 true,
1346 false,
1347 None,
1348 );
1349 assert!(!resolved.quiet);
1350
1351 let resolved2 = make_config(false).resolve(
1352 PathBuf::from("/project"),
1353 OutputFormat::Human,
1354 1,
1355 true,
1356 true,
1357 None,
1358 );
1359 assert!(resolved2.quiet);
1360 }
1361
1362 #[test]
1363 fn resolve_passes_through_no_cache_flag() {
1364 let resolved_no_cache = make_config(false).resolve(
1365 PathBuf::from("/project"),
1366 OutputFormat::Human,
1367 1,
1368 true,
1369 true,
1370 None,
1371 );
1372 assert!(resolved_no_cache.no_cache);
1373
1374 let resolved_with_cache = make_config(false).resolve(
1375 PathBuf::from("/project"),
1376 OutputFormat::Human,
1377 1,
1378 false,
1379 true,
1380 None,
1381 );
1382 assert!(!resolved_with_cache.no_cache);
1383 }
1384
1385 #[test]
1388 #[should_panic(expected = "validated at config load time")]
1389 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1390 let mut config = make_config(false);
1395 config.overrides = vec![ConfigOverride {
1396 files: vec!["[invalid".to_string()],
1397 rules: PartialRulesConfig {
1398 unused_files: Some(Severity::Off),
1399 ..Default::default()
1400 },
1401 }];
1402 let _ = config.resolve(
1403 PathBuf::from("/project"),
1404 OutputFormat::Human,
1405 1,
1406 true,
1407 true,
1408 None,
1409 );
1410 }
1411
1412 #[test]
1413 fn resolve_override_with_empty_files_skipped() {
1414 let mut config = make_config(false);
1415 config.overrides = vec![ConfigOverride {
1416 files: vec![],
1417 rules: PartialRulesConfig {
1418 unused_files: Some(Severity::Off),
1419 ..Default::default()
1420 },
1421 }];
1422 let resolved = config.resolve(
1423 PathBuf::from("/project"),
1424 OutputFormat::Human,
1425 1,
1426 true,
1427 true,
1428 None,
1429 );
1430 assert!(
1431 resolved.overrides.is_empty(),
1432 "override with no file patterns should be skipped"
1433 );
1434 }
1435
1436 #[test]
1437 fn resolve_multiple_valid_overrides() {
1438 let mut config = make_config(false);
1439 config.overrides = vec![
1440 ConfigOverride {
1441 files: vec!["*.test.ts".to_string()],
1442 rules: PartialRulesConfig {
1443 unused_exports: Some(Severity::Off),
1444 ..Default::default()
1445 },
1446 },
1447 ConfigOverride {
1448 files: vec!["*.stories.tsx".to_string()],
1449 rules: PartialRulesConfig {
1450 unused_files: Some(Severity::Off),
1451 ..Default::default()
1452 },
1453 },
1454 ];
1455 let resolved = config.resolve(
1456 PathBuf::from("/project"),
1457 OutputFormat::Human,
1458 1,
1459 true,
1460 true,
1461 None,
1462 );
1463 assert_eq!(resolved.overrides.len(), 2);
1464 }
1465
1466 #[test]
1469 fn ignore_export_rule_deserialize() {
1470 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1471 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1472 assert_eq!(rule.file, "src/types/*.ts");
1473 assert_eq!(rule.exports, vec!["*"]);
1474 }
1475
1476 #[test]
1477 fn ignore_export_rule_specific_exports() {
1478 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1479 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1480 assert_eq!(rule.exports.len(), 3);
1481 assert!(rule.exports.contains(&"FOO".to_string()));
1482 }
1483
1484 mod proptests {
1485 use super::*;
1486 use proptest::prelude::*;
1487
1488 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1489 make_config(production).resolve(
1490 PathBuf::from("/project"),
1491 OutputFormat::Human,
1492 1,
1493 true,
1494 true,
1495 None,
1496 )
1497 }
1498
1499 proptest! {
1500 #[test]
1502 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1503 let resolved = arb_resolved_config(production);
1504 prop_assert!(
1506 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1507 "Default ignore should match node_modules"
1508 );
1509 prop_assert!(
1510 resolved.ignore_patterns.is_match("dist/bundle.js"),
1511 "Default ignore should match dist"
1512 );
1513 }
1514
1515 #[test]
1517 fn production_forces_dev_deps_off(_unused in Just(())) {
1518 let resolved = arb_resolved_config(true);
1519 prop_assert_eq!(
1520 resolved.rules.unused_dev_dependencies,
1521 Severity::Off,
1522 "Production should force unused_dev_dependencies off"
1523 );
1524 prop_assert_eq!(
1525 resolved.rules.unused_optional_dependencies,
1526 Severity::Off,
1527 "Production should force unused_optional_dependencies off"
1528 );
1529 }
1530
1531 #[test]
1533 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1534 let resolved = arb_resolved_config(false);
1535 prop_assert_eq!(
1536 resolved.rules.unused_dev_dependencies,
1537 Severity::Warn,
1538 "Non-production should keep default dev dep severity"
1539 );
1540 }
1541
1542 #[test]
1544 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1545 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1546 let expected_cache = root.join(".fallow");
1547 let resolved = make_config(false).resolve(
1548 root,
1549 OutputFormat::Human,
1550 1,
1551 true,
1552 true,
1553 None,
1554 );
1555 prop_assert_eq!(
1556 resolved.cache_dir, expected_cache,
1557 "Cache dir should be root/.fallow"
1558 );
1559 }
1560
1561 #[test]
1563 fn threads_passed_through(threads in 1..64usize) {
1564 let resolved = make_config(false).resolve(
1565 PathBuf::from("/project"),
1566 OutputFormat::Human,
1567 threads,
1568 true,
1569 true, None,
1570 );
1571 prop_assert_eq!(
1572 resolved.threads, threads,
1573 "Thread count should be passed through"
1574 );
1575 }
1576
1577 #[test]
1581 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1582 let mut config = make_config(false);
1583 config.ignore_patterns = vec![pattern];
1584 let resolved = config.resolve(
1585 PathBuf::from("/project"),
1586 OutputFormat::Human,
1587 1,
1588 true,
1589 true, None,
1590 );
1591 prop_assert!(
1594 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1595 "Default node_modules ignore should still be active"
1596 );
1597 }
1598 }
1599 }
1600
1601 #[test]
1604 fn resolve_expands_boundary_preset() {
1605 use crate::config::boundaries::BoundaryPreset;
1606
1607 let mut config = make_config(false);
1608 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1609 let resolved = config.resolve(
1610 PathBuf::from("/project"),
1611 OutputFormat::Human,
1612 1,
1613 true,
1614 true,
1615 None,
1616 );
1617 assert_eq!(resolved.boundaries.zones.len(), 3);
1619 assert_eq!(resolved.boundaries.rules.len(), 3);
1620 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1621 assert_eq!(
1622 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1623 Some("adapters")
1624 );
1625 }
1626
1627 #[test]
1628 fn resolve_boundary_preset_with_user_override() {
1629 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1630
1631 let mut config = make_config(false);
1632 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1633 config.boundaries.zones = vec![BoundaryZone {
1634 name: "domain".to_string(),
1635 patterns: vec!["src/core/**".to_string()],
1636 auto_discover: vec![],
1637 root: None,
1638 }];
1639 let resolved = config.resolve(
1640 PathBuf::from("/project"),
1641 OutputFormat::Human,
1642 1,
1643 true,
1644 true,
1645 None,
1646 );
1647 assert_eq!(resolved.boundaries.zones.len(), 3);
1649 assert_eq!(
1651 resolved.boundaries.classify_zone("src/core/user.ts"),
1652 Some("domain")
1653 );
1654 assert_eq!(
1656 resolved.boundaries.classify_zone("src/domain/user.ts"),
1657 None
1658 );
1659 }
1660
1661 #[test]
1662 fn resolve_no_preset_unchanged() {
1663 let config = make_config(false);
1664 let resolved = config.resolve(
1665 PathBuf::from("/project"),
1666 OutputFormat::Human,
1667 1,
1668 true,
1669 true,
1670 None,
1671 );
1672 assert!(resolved.boundaries.is_empty());
1673 }
1674}