1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use super::boundaries::ResolvedBoundaryConfig;
12use super::duplicates_config::DuplicatesConfig;
13use super::flags::FlagsConfig;
14use super::format::OutputFormat;
15use super::health::HealthConfig;
16use super::resolve::ResolveConfig;
17use super::rules::{PartialRulesConfig, RulesConfig, Severity};
18use super::used_class_members::UsedClassMemberRule;
19use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
20
21use super::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_unresolved_imports: Vec<GlobMatcher>,
254 pub ignore_export_rules: Vec<IgnoreExportRule>,
255 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
261 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
263 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
266 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
268 pub used_class_members: Vec<UsedClassMemberRule>,
272 pub ignore_decorators: Vec<String>,
277 pub duplicates: DuplicatesConfig,
278 pub health: HealthConfig,
279 pub rules: RulesConfig,
280 pub boundaries: ResolvedBoundaryConfig,
282 pub production: bool,
284 pub quiet: bool,
286 pub external_plugins: Vec<ExternalPluginDef>,
288 pub dynamically_loaded: Vec<String>,
290 pub overrides: Vec<ResolvedOverride>,
292 pub regression: Option<super::RegressionConfig>,
294 pub audit: super::AuditConfig,
296 pub codeowners: Option<String>,
298 pub public_packages: Vec<String>,
301 pub flags: FlagsConfig,
303 pub fix: super::FixConfig,
305 pub resolve: ResolveConfig,
307 pub include_entry_exports: bool,
312 pub auto_imports: bool,
319}
320
321fn compute_cache_config_hash(external_plugins: &[ExternalPluginDef]) -> u64 {
338 let mut names: Vec<&str> = external_plugins.iter().map(|p| p.name.as_str()).collect();
339 names.sort_unstable();
340 let mut hasher = xxhash_rust::xxh3::Xxh3::new();
341 for name in names {
342 hasher.update(&(name.len() as u32).to_le_bytes());
345 hasher.update(name.as_bytes());
346 }
347 hasher.digest()
348}
349
350impl FallowConfig {
351 pub fn resolve(
359 self,
360 root: PathBuf,
361 output: OutputFormat,
362 threads: usize,
363 no_cache: bool,
364 quiet: bool,
365 cache_max_size_mb: Option<u32>,
366 ) -> ResolvedConfig {
367 let mut ignore_builder = GlobSetBuilder::new();
372 for pattern in &self.ignore_patterns {
373 ignore_builder.add(
374 Glob::new(pattern).expect("ignorePatterns entry was validated at config load time"),
375 );
376 }
377
378 let default_ignores = [
382 "**/node_modules/**",
383 "**/dist/**",
384 "build/**",
385 "**/.git/**",
386 "**/coverage/**",
387 "**/*.min.js",
388 "**/*.min.mjs",
389 ];
390 for pattern in &default_ignores {
391 ignore_builder.add(Glob::new(pattern).expect("default ignore pattern is valid"));
392 }
393
394 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
395 let ignore_unresolved_imports: Vec<GlobMatcher> = self
396 .ignore_unresolved_imports
397 .iter()
398 .map(|pattern| {
399 Glob::new(pattern)
400 .expect("ignoreUnresolvedImports entry was validated at config load time")
401 .compile_matcher()
402 })
403 .collect();
404 let cache_dir = root.join(".fallow");
405
406 let mut rules = self.rules;
407
408 let production = self.production.global();
410 if production {
411 rules.unused_dev_dependencies = Severity::Off;
412 rules.unused_optional_dependencies = Severity::Off;
413 }
414
415 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
416 external_plugins.extend(self.framework);
418
419 let mut boundaries = self.boundaries;
422 if boundaries.preset.is_some() {
423 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
424 .filter(|r| {
425 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
426 })
427 .unwrap_or_else(|| "src".to_owned());
428 if source_root != "src" {
429 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
430 }
431 boundaries.expand(&source_root);
432 }
433 let logical_groups = boundaries.expand_auto_discover(&root);
446
447 let mut boundaries = boundaries.resolve();
455 boundaries.logical_groups = logical_groups;
459
460 let overrides = self
462 .overrides
463 .into_iter()
464 .filter_map(|o| {
465 if o.rules.duplicate_exports.is_some()
475 && record_inter_file_warn_seen("duplicate-exports", &o.files)
476 {
477 let files = o.files.join(", ");
478 tracing::warn!(
479 "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."
480 );
481 }
482 if o.rules.circular_dependencies.is_some()
483 && record_inter_file_warn_seen("circular-dependency", &o.files)
484 {
485 let files = o.files.join(", ");
486 tracing::warn!(
487 "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."
488 );
489 }
490 if o.rules.re_export_cycle.is_some()
491 && record_inter_file_warn_seen("re-export-cycle", &o.files)
492 {
493 let files = o.files.join(", ");
494 tracing::warn!(
495 "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."
496 );
497 }
498 let matchers: Vec<globset::GlobMatcher> = o
499 .files
500 .iter()
501 .map(|pattern| {
502 Glob::new(pattern)
503 .expect("overrides[].files pattern was validated at config load time")
504 .compile_matcher()
505 })
506 .collect();
507 if matchers.is_empty() {
508 None
509 } else {
510 Some(ResolvedOverride {
511 matchers,
512 rules: o.rules,
513 })
514 }
515 })
516 .collect();
517
518 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
522 .ignore_exports
523 .iter()
524 .map(|rule| CompiledIgnoreExportRule {
525 matcher: Glob::new(&rule.file)
526 .expect("ignoreExports[].file was validated at config load time")
527 .compile_matcher(),
528 exports: rule.exports.clone(),
529 })
530 .collect();
531
532 let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
533 .ignore_catalog_references
534 .iter()
535 .map(|rule| CompiledIgnoreCatalogReferenceRule {
536 package: rule.package.clone(),
537 catalog: rule.catalog.clone(),
538 consumer_matcher: rule.consumer.as_ref().map(|pattern| {
539 Glob::new(pattern)
540 .expect(
541 "ignoreCatalogReferences[].consumer was validated at config load time",
542 )
543 .compile_matcher()
544 }),
545 })
546 .collect();
547
548 let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
549 .ignore_dependency_overrides
550 .iter()
551 .map(|rule| CompiledIgnoreDependencyOverrideRule {
552 package: rule.package.clone(),
553 source: rule.source.clone(),
554 })
555 .collect();
556
557 let cache_max_size_mb = cache_max_size_mb.or(self.cache.max_size_mb);
564
565 let cache_config_hash = if no_cache {
571 0
572 } else {
573 compute_cache_config_hash(&external_plugins)
574 };
575
576 ResolvedConfig {
577 root,
578 entry_patterns: self.entry,
579 ignore_patterns: compiled_ignore_patterns,
580 output,
581 cache_dir,
582 threads,
583 no_cache,
584 cache_max_size_mb,
585 cache_config_hash,
586 ignore_dependencies: self.ignore_dependencies,
587 ignore_unresolved_imports,
588 ignore_export_rules: self.ignore_exports,
589 compiled_ignore_exports,
590 compiled_ignore_catalog_references,
591 compiled_ignore_dependency_overrides,
592 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
593 used_class_members: self.used_class_members,
594 ignore_decorators: self.ignore_decorators,
595 duplicates: self.duplicates,
596 health: self.health,
597 rules,
598 boundaries,
599 production,
600 quiet,
601 external_plugins,
602 dynamically_loaded: self.dynamically_loaded,
603 overrides,
604 regression: self.regression,
605 audit: self.audit,
606 codeowners: self.codeowners,
607 public_packages: self.public_packages,
608 flags: self.flags,
609 fix: self.fix,
610 resolve: self.resolve,
611 include_entry_exports: self.include_entry_exports,
612 auto_imports: self.auto_imports,
613 }
614 }
615}
616
617impl ResolvedConfig {
618 #[must_use]
621 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
622 if self.overrides.is_empty() {
623 return self.rules.clone();
624 }
625
626 let relative = path.strip_prefix(&self.root).unwrap_or(path);
627 let relative_str = relative.to_string_lossy();
628
629 let mut rules = self.rules.clone();
630 for override_entry in &self.overrides {
631 let matches = override_entry
632 .matchers
633 .iter()
634 .any(|m| m.is_match(relative_str.as_ref()));
635 if matches {
636 rules.apply_partial(&override_entry.rules);
637 }
638 }
639 rules
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use crate::CacheConfig;
647 use crate::config::boundaries::BoundaryConfig;
648 use crate::config::health::HealthConfig;
649
650 #[test]
651 fn overrides_deserialize() {
652 let json_str = r#"{
653 "overrides": [{
654 "files": ["*.test.ts"],
655 "rules": {
656 "unused-exports": "off"
657 }
658 }]
659 }"#;
660 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
661 assert_eq!(config.overrides.len(), 1);
662 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
663 assert_eq!(
664 config.overrides[0].rules.unused_exports,
665 Some(Severity::Off)
666 );
667 assert_eq!(config.overrides[0].rules.unused_files, None);
668 }
669
670 #[test]
671 fn resolve_rules_for_path_no_overrides() {
672 let config = FallowConfig {
673 schema: None,
674 extends: vec![],
675 entry: vec![],
676 ignore_patterns: vec![],
677 framework: vec![],
678 workspaces: None,
679 ignore_dependencies: vec![],
680 ignore_unresolved_imports: vec![],
681 ignore_exports: vec![],
682 ignore_catalog_references: vec![],
683 ignore_dependency_overrides: vec![],
684 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
685 used_class_members: vec![],
686 ignore_decorators: vec![],
687 duplicates: DuplicatesConfig::default(),
688 health: HealthConfig::default(),
689 rules: RulesConfig::default(),
690 boundaries: BoundaryConfig::default(),
691 production: false.into(),
692 plugins: vec![],
693 dynamically_loaded: vec![],
694 overrides: vec![],
695 regression: None,
696 audit: crate::config::AuditConfig::default(),
697 codeowners: None,
698 public_packages: vec![],
699 flags: FlagsConfig::default(),
700 fix: crate::config::FixConfig::default(),
701 resolve: ResolveConfig::default(),
702 sealed: false,
703 include_entry_exports: false,
704 auto_imports: false,
705 cache: CacheConfig::default(),
706 };
707 let resolved = config.resolve(
708 PathBuf::from("/project"),
709 OutputFormat::Human,
710 1,
711 true,
712 true,
713 None,
714 );
715 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
716 assert_eq!(rules.unused_files, Severity::Error);
717 }
718
719 #[test]
720 fn resolve_rules_for_path_with_matching_override() {
721 let config = FallowConfig {
722 schema: None,
723 extends: vec![],
724 entry: vec![],
725 ignore_patterns: vec![],
726 framework: vec![],
727 workspaces: None,
728 ignore_dependencies: vec![],
729 ignore_unresolved_imports: vec![],
730 ignore_exports: vec![],
731 ignore_catalog_references: vec![],
732 ignore_dependency_overrides: vec![],
733 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
734 used_class_members: vec![],
735 ignore_decorators: vec![],
736 duplicates: DuplicatesConfig::default(),
737 health: HealthConfig::default(),
738 rules: RulesConfig::default(),
739 boundaries: BoundaryConfig::default(),
740 production: false.into(),
741 plugins: vec![],
742 dynamically_loaded: vec![],
743 overrides: vec![ConfigOverride {
744 files: vec!["*.test.ts".to_string()],
745 rules: PartialRulesConfig {
746 unused_exports: Some(Severity::Off),
747 ..Default::default()
748 },
749 }],
750 regression: None,
751 audit: crate::config::AuditConfig::default(),
752 codeowners: None,
753 public_packages: vec![],
754 flags: FlagsConfig::default(),
755 fix: crate::config::FixConfig::default(),
756 resolve: ResolveConfig::default(),
757 sealed: false,
758 include_entry_exports: false,
759 auto_imports: false,
760 cache: CacheConfig::default(),
761 };
762 let resolved = config.resolve(
763 PathBuf::from("/project"),
764 OutputFormat::Human,
765 1,
766 true,
767 true,
768 None,
769 );
770
771 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
773 assert_eq!(test_rules.unused_exports, Severity::Off);
774 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
778 assert_eq!(src_rules.unused_exports, Severity::Error);
779 }
780
781 #[test]
782 fn resolve_rules_for_path_later_override_wins() {
783 let config = FallowConfig {
784 schema: None,
785 extends: vec![],
786 entry: vec![],
787 ignore_patterns: vec![],
788 framework: vec![],
789 workspaces: None,
790 ignore_dependencies: vec![],
791 ignore_unresolved_imports: vec![],
792 ignore_exports: vec![],
793 ignore_catalog_references: vec![],
794 ignore_dependency_overrides: vec![],
795 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
796 used_class_members: vec![],
797 ignore_decorators: vec![],
798 duplicates: DuplicatesConfig::default(),
799 health: HealthConfig::default(),
800 rules: RulesConfig::default(),
801 boundaries: BoundaryConfig::default(),
802 production: false.into(),
803 plugins: vec![],
804 dynamically_loaded: vec![],
805 overrides: vec![
806 ConfigOverride {
807 files: vec!["*.ts".to_string()],
808 rules: PartialRulesConfig {
809 unused_files: Some(Severity::Warn),
810 ..Default::default()
811 },
812 },
813 ConfigOverride {
814 files: vec!["*.test.ts".to_string()],
815 rules: PartialRulesConfig {
816 unused_files: Some(Severity::Off),
817 ..Default::default()
818 },
819 },
820 ],
821 regression: None,
822 audit: crate::config::AuditConfig::default(),
823 codeowners: None,
824 public_packages: vec![],
825 flags: FlagsConfig::default(),
826 fix: crate::config::FixConfig::default(),
827 resolve: ResolveConfig::default(),
828 sealed: false,
829 include_entry_exports: false,
830 auto_imports: false,
831 cache: CacheConfig::default(),
832 };
833 let resolved = config.resolve(
834 PathBuf::from("/project"),
835 OutputFormat::Human,
836 1,
837 true,
838 true,
839 None,
840 );
841
842 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
844 assert_eq!(rules.unused_files, Severity::Off);
845
846 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
848 assert_eq!(rules2.unused_files, Severity::Warn);
849 }
850
851 #[test]
852 fn resolve_keeps_inter_file_rule_override_after_warning() {
853 let config = FallowConfig {
859 schema: None,
860 extends: vec![],
861 entry: vec![],
862 ignore_patterns: vec![],
863 framework: vec![],
864 workspaces: None,
865 ignore_dependencies: vec![],
866 ignore_unresolved_imports: vec![],
867 ignore_exports: vec![],
868 ignore_catalog_references: vec![],
869 ignore_dependency_overrides: vec![],
870 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
871 used_class_members: vec![],
872 ignore_decorators: vec![],
873 duplicates: DuplicatesConfig::default(),
874 health: HealthConfig::default(),
875 rules: RulesConfig::default(),
876 boundaries: BoundaryConfig::default(),
877 production: false.into(),
878 plugins: vec![],
879 dynamically_loaded: vec![],
880 overrides: vec![ConfigOverride {
881 files: vec!["**/ui/**".to_string()],
882 rules: PartialRulesConfig {
883 duplicate_exports: Some(Severity::Off),
884 unused_files: Some(Severity::Warn),
885 ..Default::default()
886 },
887 }],
888 regression: None,
889 audit: crate::config::AuditConfig::default(),
890 codeowners: None,
891 public_packages: vec![],
892 flags: FlagsConfig::default(),
893 fix: crate::config::FixConfig::default(),
894 resolve: ResolveConfig::default(),
895 sealed: false,
896 include_entry_exports: false,
897 auto_imports: false,
898 cache: CacheConfig::default(),
899 };
900 let resolved = config.resolve(
901 PathBuf::from("/project"),
902 OutputFormat::Human,
903 1,
904 true,
905 true,
906 None,
907 );
908 assert_eq!(
909 resolved.overrides.len(),
910 1,
911 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
912 );
913 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
914 assert_eq!(rules.unused_files, Severity::Warn);
915 }
916
917 #[test]
918 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
919 reset_inter_file_warn_dedup_for_test();
923 let files_a = vec!["__test_dedup_a/*".to_string()];
924 let files_b = vec!["__test_dedup_b/*".to_string()];
925
926 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
928 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
929 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
930
931 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
933 assert!(!record_inter_file_warn_seen(
934 "circular-dependency",
935 &files_a
936 ));
937
938 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
940
941 let files_reordered = vec![
943 "__test_dedup_b/*".to_string(),
944 "__test_dedup_a/*".to_string(),
945 ];
946 let files_natural = vec![
947 "__test_dedup_a/*".to_string(),
948 "__test_dedup_b/*".to_string(),
949 ];
950 reset_inter_file_warn_dedup_for_test();
951 assert!(record_inter_file_warn_seen(
952 "duplicate-exports",
953 &files_natural
954 ));
955 assert!(!record_inter_file_warn_seen(
956 "duplicate-exports",
957 &files_reordered
958 ));
959 }
960
961 #[test]
962 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
963 reset_inter_file_warn_dedup_for_test();
968 let files = vec!["__test_resolve_dedup/**".to_string()];
969 let build_config = || FallowConfig {
970 schema: None,
971 extends: vec![],
972 entry: vec![],
973 ignore_patterns: vec![],
974 framework: vec![],
975 workspaces: None,
976 ignore_dependencies: vec![],
977 ignore_unresolved_imports: vec![],
978 ignore_exports: vec![],
979 ignore_catalog_references: vec![],
980 ignore_dependency_overrides: vec![],
981 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
982 used_class_members: vec![],
983 ignore_decorators: vec![],
984 duplicates: DuplicatesConfig::default(),
985 health: HealthConfig::default(),
986 rules: RulesConfig::default(),
987 boundaries: BoundaryConfig::default(),
988 production: false.into(),
989 plugins: vec![],
990 dynamically_loaded: vec![],
991 overrides: vec![ConfigOverride {
992 files: files.clone(),
993 rules: PartialRulesConfig {
994 duplicate_exports: Some(Severity::Off),
995 ..Default::default()
996 },
997 }],
998 regression: None,
999 audit: crate::config::AuditConfig::default(),
1000 codeowners: None,
1001 public_packages: vec![],
1002 flags: FlagsConfig::default(),
1003 fix: crate::config::FixConfig::default(),
1004 resolve: ResolveConfig::default(),
1005 sealed: false,
1006 include_entry_exports: false,
1007 auto_imports: false,
1008 cache: CacheConfig::default(),
1009 };
1010 for _ in 0..10 {
1011 let _ = build_config().resolve(
1012 PathBuf::from("/project"),
1013 OutputFormat::Human,
1014 1,
1015 true,
1016 true,
1017 None,
1018 );
1019 }
1020 assert!(
1024 !record_inter_file_warn_seen("duplicate-exports", &files),
1025 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
1026 );
1027 }
1028
1029 fn make_config(production: bool) -> FallowConfig {
1031 FallowConfig {
1032 schema: None,
1033 extends: vec![],
1034 entry: vec![],
1035 ignore_patterns: vec![],
1036 framework: vec![],
1037 workspaces: None,
1038 ignore_dependencies: vec![],
1039 ignore_unresolved_imports: vec![],
1040 ignore_exports: vec![],
1041 ignore_catalog_references: vec![],
1042 ignore_dependency_overrides: vec![],
1043 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
1044 used_class_members: vec![],
1045 ignore_decorators: vec![],
1046 duplicates: DuplicatesConfig::default(),
1047 health: HealthConfig::default(),
1048 rules: RulesConfig::default(),
1049 boundaries: BoundaryConfig::default(),
1050 production: production.into(),
1051 plugins: vec![],
1052 dynamically_loaded: vec![],
1053 overrides: vec![],
1054 regression: None,
1055 audit: crate::config::AuditConfig::default(),
1056 codeowners: None,
1057 public_packages: vec![],
1058 flags: FlagsConfig::default(),
1059 fix: crate::config::FixConfig::default(),
1060 resolve: ResolveConfig::default(),
1061 sealed: false,
1062 include_entry_exports: false,
1063 auto_imports: false,
1064 cache: CacheConfig::default(),
1065 }
1066 }
1067
1068 #[test]
1071 fn resolve_production_forces_dev_deps_off() {
1072 let resolved = make_config(true).resolve(
1073 PathBuf::from("/project"),
1074 OutputFormat::Human,
1075 1,
1076 true,
1077 true,
1078 None,
1079 );
1080 assert_eq!(
1081 resolved.rules.unused_dev_dependencies,
1082 Severity::Off,
1083 "production mode should force unused_dev_dependencies to off"
1084 );
1085 }
1086
1087 #[test]
1088 fn resolve_production_forces_optional_deps_off() {
1089 let resolved = make_config(true).resolve(
1090 PathBuf::from("/project"),
1091 OutputFormat::Human,
1092 1,
1093 true,
1094 true,
1095 None,
1096 );
1097 assert_eq!(
1098 resolved.rules.unused_optional_dependencies,
1099 Severity::Off,
1100 "production mode should force unused_optional_dependencies to off"
1101 );
1102 }
1103
1104 #[test]
1105 fn resolve_production_preserves_other_rules() {
1106 let resolved = make_config(true).resolve(
1107 PathBuf::from("/project"),
1108 OutputFormat::Human,
1109 1,
1110 true,
1111 true,
1112 None,
1113 );
1114 assert_eq!(resolved.rules.unused_files, Severity::Error);
1116 assert_eq!(resolved.rules.unused_exports, Severity::Error);
1117 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
1118 }
1119
1120 #[test]
1121 fn resolve_non_production_keeps_dev_deps_default() {
1122 let resolved = make_config(false).resolve(
1123 PathBuf::from("/project"),
1124 OutputFormat::Human,
1125 1,
1126 true,
1127 true,
1128 None,
1129 );
1130 assert_eq!(
1131 resolved.rules.unused_dev_dependencies,
1132 Severity::Warn,
1133 "non-production should keep default severity"
1134 );
1135 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1136 }
1137
1138 #[test]
1139 fn resolve_production_flag_stored() {
1140 let resolved = make_config(true).resolve(
1141 PathBuf::from("/project"),
1142 OutputFormat::Human,
1143 1,
1144 true,
1145 true,
1146 None,
1147 );
1148 assert!(resolved.production);
1149
1150 let resolved2 = make_config(false).resolve(
1151 PathBuf::from("/project"),
1152 OutputFormat::Human,
1153 1,
1154 true,
1155 true,
1156 None,
1157 );
1158 assert!(!resolved2.production);
1159 }
1160
1161 #[test]
1164 fn resolve_default_ignores_node_modules() {
1165 let resolved = make_config(false).resolve(
1166 PathBuf::from("/project"),
1167 OutputFormat::Human,
1168 1,
1169 true,
1170 true,
1171 None,
1172 );
1173 assert!(
1174 resolved
1175 .ignore_patterns
1176 .is_match("node_modules/lodash/index.js")
1177 );
1178 assert!(
1179 resolved
1180 .ignore_patterns
1181 .is_match("packages/a/node_modules/react/index.js")
1182 );
1183 }
1184
1185 #[test]
1186 fn resolve_default_ignores_dist() {
1187 let resolved = make_config(false).resolve(
1188 PathBuf::from("/project"),
1189 OutputFormat::Human,
1190 1,
1191 true,
1192 true,
1193 None,
1194 );
1195 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1196 assert!(
1197 resolved
1198 .ignore_patterns
1199 .is_match("packages/ui/dist/index.js")
1200 );
1201 }
1202
1203 #[test]
1204 fn resolve_default_ignores_root_build_only() {
1205 let resolved = make_config(false).resolve(
1206 PathBuf::from("/project"),
1207 OutputFormat::Human,
1208 1,
1209 true,
1210 true,
1211 None,
1212 );
1213 assert!(
1214 resolved.ignore_patterns.is_match("build/output.js"),
1215 "root build/ should be ignored"
1216 );
1217 assert!(
1219 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1220 "nested build/ should NOT be ignored by default"
1221 );
1222 }
1223
1224 #[test]
1225 fn resolve_default_ignores_minified_files() {
1226 let resolved = make_config(false).resolve(
1227 PathBuf::from("/project"),
1228 OutputFormat::Human,
1229 1,
1230 true,
1231 true,
1232 None,
1233 );
1234 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1235 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1236 }
1237
1238 #[test]
1239 fn resolve_default_ignores_git() {
1240 let resolved = make_config(false).resolve(
1241 PathBuf::from("/project"),
1242 OutputFormat::Human,
1243 1,
1244 true,
1245 true,
1246 None,
1247 );
1248 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1249 }
1250
1251 #[test]
1252 fn resolve_default_ignores_coverage() {
1253 let resolved = make_config(false).resolve(
1254 PathBuf::from("/project"),
1255 OutputFormat::Human,
1256 1,
1257 true,
1258 true,
1259 None,
1260 );
1261 assert!(
1262 resolved
1263 .ignore_patterns
1264 .is_match("coverage/lcov-report/index.js")
1265 );
1266 }
1267
1268 #[test]
1269 fn resolve_source_files_not_ignored_by_default() {
1270 let resolved = make_config(false).resolve(
1271 PathBuf::from("/project"),
1272 OutputFormat::Human,
1273 1,
1274 true,
1275 true,
1276 None,
1277 );
1278 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1279 assert!(
1280 !resolved
1281 .ignore_patterns
1282 .is_match("src/components/Button.tsx")
1283 );
1284 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1285 }
1286
1287 #[test]
1290 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1291 let mut config = make_config(false);
1292 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1293 let resolved = config.resolve(
1294 PathBuf::from("/project"),
1295 OutputFormat::Human,
1296 1,
1297 true,
1298 true,
1299 None,
1300 );
1301 assert!(
1303 resolved
1304 .ignore_patterns
1305 .is_match("src/__generated__/types.ts")
1306 );
1307 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1309 }
1310
1311 #[test]
1314 fn resolve_passes_through_entry_patterns() {
1315 let mut config = make_config(false);
1316 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1317 let resolved = config.resolve(
1318 PathBuf::from("/project"),
1319 OutputFormat::Human,
1320 1,
1321 true,
1322 true,
1323 None,
1324 );
1325 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1326 }
1327
1328 #[test]
1329 fn resolve_passes_through_ignore_dependencies() {
1330 let mut config = make_config(false);
1331 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1332 let resolved = config.resolve(
1333 PathBuf::from("/project"),
1334 OutputFormat::Human,
1335 1,
1336 true,
1337 true,
1338 None,
1339 );
1340 assert_eq!(
1341 resolved.ignore_dependencies,
1342 vec!["postcss", "autoprefixer"]
1343 );
1344 }
1345
1346 #[test]
1347 fn resolve_compiles_ignore_unresolved_imports_as_raw_specifier_globs() {
1348 let mut config = make_config(false);
1349 config.ignore_unresolved_imports = vec![
1350 "@example/icons".to_string(),
1351 "@example/icons/**".to_string(),
1352 "../generated/**".to_string(),
1353 ];
1354 let resolved = config.resolve(
1355 PathBuf::from("/project"),
1356 OutputFormat::Human,
1357 1,
1358 true,
1359 true,
1360 None,
1361 );
1362
1363 assert!(
1364 resolved
1365 .ignore_unresolved_imports
1366 .iter()
1367 .any(|matcher| matcher.is_match("@example/icons"))
1368 );
1369 assert!(
1370 resolved
1371 .ignore_unresolved_imports
1372 .iter()
1373 .any(|matcher| matcher.is_match("@example/icons/metadata"))
1374 );
1375 assert!(
1376 resolved
1377 .ignore_unresolved_imports
1378 .iter()
1379 .any(|matcher| matcher.is_match("../generated/client"))
1380 );
1381 }
1382
1383 #[test]
1384 fn ignore_unresolved_imports_subpath_glob_does_not_match_bare_specifier() {
1385 let mut config = make_config(false);
1386 config.ignore_unresolved_imports = vec!["@example/icons/**".to_string()];
1387 let resolved = config.resolve(
1388 PathBuf::from("/project"),
1389 OutputFormat::Human,
1390 1,
1391 true,
1392 true,
1393 None,
1394 );
1395
1396 assert!(
1397 !resolved.ignore_unresolved_imports[0].is_match("@example/icons"),
1398 "globset treats @example/icons/** as subpaths only; list the bare specifier separately"
1399 );
1400 assert!(resolved.ignore_unresolved_imports[0].is_match("@example/icons/metadata"));
1401 }
1402
1403 #[test]
1404 fn resolve_sets_cache_dir() {
1405 let resolved = make_config(false).resolve(
1406 PathBuf::from("/my/project"),
1407 OutputFormat::Human,
1408 1,
1409 true,
1410 true,
1411 None,
1412 );
1413 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1414 }
1415
1416 #[test]
1417 fn resolve_passes_through_thread_count() {
1418 let resolved = make_config(false).resolve(
1419 PathBuf::from("/project"),
1420 OutputFormat::Human,
1421 8,
1422 true,
1423 true,
1424 None,
1425 );
1426 assert_eq!(resolved.threads, 8);
1427 }
1428
1429 #[test]
1430 fn resolve_passes_through_quiet_flag() {
1431 let resolved = make_config(false).resolve(
1432 PathBuf::from("/project"),
1433 OutputFormat::Human,
1434 1,
1435 true,
1436 false,
1437 None,
1438 );
1439 assert!(!resolved.quiet);
1440
1441 let resolved2 = make_config(false).resolve(
1442 PathBuf::from("/project"),
1443 OutputFormat::Human,
1444 1,
1445 true,
1446 true,
1447 None,
1448 );
1449 assert!(resolved2.quiet);
1450 }
1451
1452 #[test]
1453 fn resolve_passes_through_no_cache_flag() {
1454 let resolved_no_cache = make_config(false).resolve(
1455 PathBuf::from("/project"),
1456 OutputFormat::Human,
1457 1,
1458 true,
1459 true,
1460 None,
1461 );
1462 assert!(resolved_no_cache.no_cache);
1463
1464 let resolved_with_cache = make_config(false).resolve(
1465 PathBuf::from("/project"),
1466 OutputFormat::Human,
1467 1,
1468 false,
1469 true,
1470 None,
1471 );
1472 assert!(!resolved_with_cache.no_cache);
1473 }
1474
1475 #[test]
1478 #[should_panic(expected = "validated at config load time")]
1479 fn resolve_panics_on_unvalidated_invalid_override_glob() {
1480 let mut config = make_config(false);
1485 config.overrides = vec![ConfigOverride {
1486 files: vec!["[invalid".to_string()],
1487 rules: PartialRulesConfig {
1488 unused_files: Some(Severity::Off),
1489 ..Default::default()
1490 },
1491 }];
1492 let _ = config.resolve(
1493 PathBuf::from("/project"),
1494 OutputFormat::Human,
1495 1,
1496 true,
1497 true,
1498 None,
1499 );
1500 }
1501
1502 #[test]
1503 fn resolve_override_with_empty_files_skipped() {
1504 let mut config = make_config(false);
1505 config.overrides = vec![ConfigOverride {
1506 files: vec![],
1507 rules: PartialRulesConfig {
1508 unused_files: Some(Severity::Off),
1509 ..Default::default()
1510 },
1511 }];
1512 let resolved = config.resolve(
1513 PathBuf::from("/project"),
1514 OutputFormat::Human,
1515 1,
1516 true,
1517 true,
1518 None,
1519 );
1520 assert!(
1521 resolved.overrides.is_empty(),
1522 "override with no file patterns should be skipped"
1523 );
1524 }
1525
1526 #[test]
1527 fn resolve_multiple_valid_overrides() {
1528 let mut config = make_config(false);
1529 config.overrides = vec![
1530 ConfigOverride {
1531 files: vec!["*.test.ts".to_string()],
1532 rules: PartialRulesConfig {
1533 unused_exports: Some(Severity::Off),
1534 ..Default::default()
1535 },
1536 },
1537 ConfigOverride {
1538 files: vec!["*.stories.tsx".to_string()],
1539 rules: PartialRulesConfig {
1540 unused_files: Some(Severity::Off),
1541 ..Default::default()
1542 },
1543 },
1544 ];
1545 let resolved = config.resolve(
1546 PathBuf::from("/project"),
1547 OutputFormat::Human,
1548 1,
1549 true,
1550 true,
1551 None,
1552 );
1553 assert_eq!(resolved.overrides.len(), 2);
1554 }
1555
1556 #[test]
1559 fn ignore_export_rule_deserialize() {
1560 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1561 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1562 assert_eq!(rule.file, "src/types/*.ts");
1563 assert_eq!(rule.exports, vec!["*"]);
1564 }
1565
1566 #[test]
1567 fn ignore_export_rule_specific_exports() {
1568 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1569 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1570 assert_eq!(rule.exports.len(), 3);
1571 assert!(rule.exports.contains(&"FOO".to_string()));
1572 }
1573
1574 mod proptests {
1575 use super::*;
1576 use proptest::prelude::*;
1577
1578 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1579 make_config(production).resolve(
1580 PathBuf::from("/project"),
1581 OutputFormat::Human,
1582 1,
1583 true,
1584 true,
1585 None,
1586 )
1587 }
1588
1589 proptest! {
1590 #[test]
1592 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1593 let resolved = arb_resolved_config(production);
1594 prop_assert!(
1596 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1597 "Default ignore should match node_modules"
1598 );
1599 prop_assert!(
1600 resolved.ignore_patterns.is_match("dist/bundle.js"),
1601 "Default ignore should match dist"
1602 );
1603 }
1604
1605 #[test]
1607 fn production_forces_dev_deps_off(_unused in Just(())) {
1608 let resolved = arb_resolved_config(true);
1609 prop_assert_eq!(
1610 resolved.rules.unused_dev_dependencies,
1611 Severity::Off,
1612 "Production should force unused_dev_dependencies off"
1613 );
1614 prop_assert_eq!(
1615 resolved.rules.unused_optional_dependencies,
1616 Severity::Off,
1617 "Production should force unused_optional_dependencies off"
1618 );
1619 }
1620
1621 #[test]
1623 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1624 let resolved = arb_resolved_config(false);
1625 prop_assert_eq!(
1626 resolved.rules.unused_dev_dependencies,
1627 Severity::Warn,
1628 "Non-production should keep default dev dep severity"
1629 );
1630 }
1631
1632 #[test]
1634 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1635 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1636 let expected_cache = root.join(".fallow");
1637 let resolved = make_config(false).resolve(
1638 root,
1639 OutputFormat::Human,
1640 1,
1641 true,
1642 true,
1643 None,
1644 );
1645 prop_assert_eq!(
1646 resolved.cache_dir, expected_cache,
1647 "Cache dir should be root/.fallow"
1648 );
1649 }
1650
1651 #[test]
1653 fn threads_passed_through(threads in 1..64usize) {
1654 let resolved = make_config(false).resolve(
1655 PathBuf::from("/project"),
1656 OutputFormat::Human,
1657 threads,
1658 true,
1659 true, None,
1660 );
1661 prop_assert_eq!(
1662 resolved.threads, threads,
1663 "Thread count should be passed through"
1664 );
1665 }
1666
1667 #[test]
1671 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1672 let mut config = make_config(false);
1673 config.ignore_patterns = vec![pattern];
1674 let resolved = config.resolve(
1675 PathBuf::from("/project"),
1676 OutputFormat::Human,
1677 1,
1678 true,
1679 true, None,
1680 );
1681 prop_assert!(
1684 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1685 "Default node_modules ignore should still be active"
1686 );
1687 }
1688 }
1689 }
1690
1691 #[test]
1694 fn resolve_expands_boundary_preset() {
1695 use crate::config::boundaries::BoundaryPreset;
1696
1697 let mut config = make_config(false);
1698 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1699 let resolved = config.resolve(
1700 PathBuf::from("/project"),
1701 OutputFormat::Human,
1702 1,
1703 true,
1704 true,
1705 None,
1706 );
1707 assert_eq!(resolved.boundaries.zones.len(), 3);
1709 assert_eq!(resolved.boundaries.rules.len(), 3);
1710 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1711 assert_eq!(
1712 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1713 Some("adapters")
1714 );
1715 }
1716
1717 #[test]
1718 fn resolve_boundary_preset_with_user_override() {
1719 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1720
1721 let mut config = make_config(false);
1722 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1723 config.boundaries.zones = vec![BoundaryZone {
1724 name: "domain".to_string(),
1725 patterns: vec!["src/core/**".to_string()],
1726 auto_discover: vec![],
1727 root: None,
1728 }];
1729 let resolved = config.resolve(
1730 PathBuf::from("/project"),
1731 OutputFormat::Human,
1732 1,
1733 true,
1734 true,
1735 None,
1736 );
1737 assert_eq!(resolved.boundaries.zones.len(), 3);
1739 assert_eq!(
1741 resolved.boundaries.classify_zone("src/core/user.ts"),
1742 Some("domain")
1743 );
1744 assert_eq!(
1746 resolved.boundaries.classify_zone("src/domain/user.ts"),
1747 None
1748 );
1749 }
1750
1751 #[test]
1752 fn resolve_no_preset_unchanged() {
1753 let config = make_config(false);
1754 let resolved = config.resolve(
1755 PathBuf::from("/project"),
1756 OutputFormat::Human,
1757 1,
1758 true,
1759 true,
1760 None,
1761 );
1762 assert!(resolved.boundaries.is_empty());
1763 }
1764}