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 ignore_dependencies: Vec<String>,
234 pub ignore_export_rules: Vec<IgnoreExportRule>,
235 pub compiled_ignore_exports: Vec<CompiledIgnoreExportRule>,
241 pub compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule>,
243 pub compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule>,
246 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
248 pub used_class_members: Vec<UsedClassMemberRule>,
252 pub duplicates: DuplicatesConfig,
253 pub health: HealthConfig,
254 pub rules: RulesConfig,
255 pub boundaries: ResolvedBoundaryConfig,
257 pub production: bool,
259 pub quiet: bool,
261 pub external_plugins: Vec<ExternalPluginDef>,
263 pub dynamically_loaded: Vec<String>,
265 pub overrides: Vec<ResolvedOverride>,
267 pub regression: Option<super::RegressionConfig>,
269 pub audit: super::AuditConfig,
271 pub codeowners: Option<String>,
273 pub public_packages: Vec<String>,
276 pub flags: FlagsConfig,
278 pub fix: super::FixConfig,
280 pub resolve: ResolveConfig,
282 pub include_entry_exports: bool,
287}
288
289impl FallowConfig {
290 pub fn resolve(
292 self,
293 root: PathBuf,
294 output: OutputFormat,
295 threads: usize,
296 no_cache: bool,
297 quiet: bool,
298 ) -> ResolvedConfig {
299 let mut ignore_builder = GlobSetBuilder::new();
300 for pattern in &self.ignore_patterns {
301 match Glob::new(pattern) {
302 Ok(glob) => {
303 ignore_builder.add(glob);
304 }
305 Err(e) => {
306 tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
307 }
308 }
309 }
310
311 let default_ignores = [
315 "**/node_modules/**",
316 "**/dist/**",
317 "build/**",
318 "**/.git/**",
319 "**/coverage/**",
320 "**/*.min.js",
321 "**/*.min.mjs",
322 ];
323 for pattern in &default_ignores {
324 if let Ok(glob) = Glob::new(pattern) {
325 ignore_builder.add(glob);
326 }
327 }
328
329 let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
330 let cache_dir = root.join(".fallow");
331
332 let mut rules = self.rules;
333
334 let production = self.production.global();
336 if production {
337 rules.unused_dev_dependencies = Severity::Off;
338 rules.unused_optional_dependencies = Severity::Off;
339 }
340
341 let mut external_plugins = discover_external_plugins(&root, &self.plugins);
342 external_plugins.extend(self.framework);
344
345 let mut boundaries = self.boundaries;
348 if boundaries.preset.is_some() {
349 let source_root = crate::workspace::parse_tsconfig_root_dir(&root)
350 .filter(|r| {
351 r != "." && !r.starts_with("..") && !std::path::Path::new(r).is_absolute()
352 })
353 .unwrap_or_else(|| "src".to_owned());
354 if source_root != "src" {
355 tracing::info!("boundary preset: using rootDir '{source_root}' from tsconfig.json");
356 }
357 boundaries.expand(&source_root);
358 }
359 let logical_groups = boundaries.expand_auto_discover(&root);
372
373 let validation_errors = boundaries.validate_zone_references();
375 for (rule_idx, zone_name) in &validation_errors {
376 tracing::error!(
377 "boundary rule {} references undefined zone '{zone_name}'",
378 rule_idx
379 );
380 }
381 for message in boundaries.validate_root_prefixes() {
382 tracing::error!("{message}");
383 }
384 let mut boundaries = boundaries.resolve();
385 boundaries.logical_groups = logical_groups;
389
390 let overrides = self
392 .overrides
393 .into_iter()
394 .filter_map(|o| {
395 if o.rules.duplicate_exports.is_some()
405 && record_inter_file_warn_seen("duplicate-exports", &o.files)
406 {
407 let files = o.files.join(", ");
408 tracing::warn!(
409 "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."
410 );
411 }
412 if o.rules.circular_dependencies.is_some()
413 && record_inter_file_warn_seen("circular-dependency", &o.files)
414 {
415 let files = o.files.join(", ");
416 tracing::warn!(
417 "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."
418 );
419 }
420 let matchers: Vec<globset::GlobMatcher> = o
421 .files
422 .iter()
423 .filter_map(|pattern| match Glob::new(pattern) {
424 Ok(glob) => Some(glob.compile_matcher()),
425 Err(e) => {
426 tracing::warn!("invalid override glob pattern '{pattern}': {e}");
427 None
428 }
429 })
430 .collect();
431 if matchers.is_empty() {
432 None
433 } else {
434 Some(ResolvedOverride {
435 matchers,
436 rules: o.rules,
437 })
438 }
439 })
440 .collect();
441
442 let compiled_ignore_exports: Vec<CompiledIgnoreExportRule> = self
447 .ignore_exports
448 .iter()
449 .filter_map(|rule| match Glob::new(&rule.file) {
450 Ok(g) => Some(CompiledIgnoreExportRule {
451 matcher: g.compile_matcher(),
452 exports: rule.exports.clone(),
453 }),
454 Err(e) => {
455 tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
456 None
457 }
458 })
459 .collect();
460
461 let compiled_ignore_catalog_references: Vec<CompiledIgnoreCatalogReferenceRule> = self
462 .ignore_catalog_references
463 .iter()
464 .filter_map(|rule| {
465 let consumer_matcher = match &rule.consumer {
466 Some(pattern) => match Glob::new(pattern) {
467 Ok(g) => Some(g.compile_matcher()),
468 Err(e) => {
469 tracing::warn!(
470 "invalid ignoreCatalogReferences consumer glob '{pattern}': {e}"
471 );
472 return None;
473 }
474 },
475 None => None,
476 };
477 Some(CompiledIgnoreCatalogReferenceRule {
478 package: rule.package.clone(),
479 catalog: rule.catalog.clone(),
480 consumer_matcher,
481 })
482 })
483 .collect();
484
485 let compiled_ignore_dependency_overrides: Vec<CompiledIgnoreDependencyOverrideRule> = self
486 .ignore_dependency_overrides
487 .iter()
488 .map(|rule| CompiledIgnoreDependencyOverrideRule {
489 package: rule.package.clone(),
490 source: rule.source.clone(),
491 })
492 .collect();
493
494 ResolvedConfig {
495 root,
496 entry_patterns: self.entry,
497 ignore_patterns: compiled_ignore_patterns,
498 output,
499 cache_dir,
500 threads,
501 no_cache,
502 ignore_dependencies: self.ignore_dependencies,
503 ignore_export_rules: self.ignore_exports,
504 compiled_ignore_exports,
505 compiled_ignore_catalog_references,
506 compiled_ignore_dependency_overrides,
507 ignore_exports_used_in_file: self.ignore_exports_used_in_file,
508 used_class_members: self.used_class_members,
509 duplicates: self.duplicates,
510 health: self.health,
511 rules,
512 boundaries,
513 production,
514 quiet,
515 external_plugins,
516 dynamically_loaded: self.dynamically_loaded,
517 overrides,
518 regression: self.regression,
519 audit: self.audit,
520 codeowners: self.codeowners,
521 public_packages: self.public_packages,
522 flags: self.flags,
523 fix: self.fix,
524 resolve: self.resolve,
525 include_entry_exports: self.include_entry_exports,
526 }
527 }
528}
529
530impl ResolvedConfig {
531 #[must_use]
534 pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
535 if self.overrides.is_empty() {
536 return self.rules.clone();
537 }
538
539 let relative = path.strip_prefix(&self.root).unwrap_or(path);
540 let relative_str = relative.to_string_lossy();
541
542 let mut rules = self.rules.clone();
543 for override_entry in &self.overrides {
544 let matches = override_entry
545 .matchers
546 .iter()
547 .any(|m| m.is_match(relative_str.as_ref()));
548 if matches {
549 rules.apply_partial(&override_entry.rules);
550 }
551 }
552 rules
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use crate::config::boundaries::BoundaryConfig;
560 use crate::config::health::HealthConfig;
561
562 #[test]
563 fn overrides_deserialize() {
564 let json_str = r#"{
565 "overrides": [{
566 "files": ["*.test.ts"],
567 "rules": {
568 "unused-exports": "off"
569 }
570 }]
571 }"#;
572 let config: FallowConfig = serde_json::from_str(json_str).unwrap();
573 assert_eq!(config.overrides.len(), 1);
574 assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
575 assert_eq!(
576 config.overrides[0].rules.unused_exports,
577 Some(Severity::Off)
578 );
579 assert_eq!(config.overrides[0].rules.unused_files, None);
580 }
581
582 #[test]
583 fn resolve_rules_for_path_no_overrides() {
584 let config = FallowConfig {
585 schema: None,
586 extends: vec![],
587 entry: vec![],
588 ignore_patterns: vec![],
589 framework: vec![],
590 workspaces: None,
591 ignore_dependencies: vec![],
592 ignore_exports: vec![],
593 ignore_catalog_references: vec![],
594 ignore_dependency_overrides: vec![],
595 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
596 used_class_members: vec![],
597 duplicates: DuplicatesConfig::default(),
598 health: HealthConfig::default(),
599 rules: RulesConfig::default(),
600 boundaries: BoundaryConfig::default(),
601 production: false.into(),
602 plugins: vec![],
603 dynamically_loaded: vec![],
604 overrides: vec![],
605 regression: None,
606 audit: crate::config::AuditConfig::default(),
607 codeowners: None,
608 public_packages: vec![],
609 flags: FlagsConfig::default(),
610 fix: crate::config::FixConfig::default(),
611 resolve: ResolveConfig::default(),
612 sealed: false,
613 include_entry_exports: false,
614 };
615 let resolved = config.resolve(
616 PathBuf::from("/project"),
617 OutputFormat::Human,
618 1,
619 true,
620 true,
621 );
622 let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
623 assert_eq!(rules.unused_files, Severity::Error);
624 }
625
626 #[test]
627 fn resolve_rules_for_path_with_matching_override() {
628 let config = FallowConfig {
629 schema: None,
630 extends: vec![],
631 entry: vec![],
632 ignore_patterns: vec![],
633 framework: vec![],
634 workspaces: None,
635 ignore_dependencies: vec![],
636 ignore_exports: vec![],
637 ignore_catalog_references: vec![],
638 ignore_dependency_overrides: vec![],
639 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
640 used_class_members: vec![],
641 duplicates: DuplicatesConfig::default(),
642 health: HealthConfig::default(),
643 rules: RulesConfig::default(),
644 boundaries: BoundaryConfig::default(),
645 production: false.into(),
646 plugins: vec![],
647 dynamically_loaded: vec![],
648 overrides: vec![ConfigOverride {
649 files: vec!["*.test.ts".to_string()],
650 rules: PartialRulesConfig {
651 unused_exports: Some(Severity::Off),
652 ..Default::default()
653 },
654 }],
655 regression: None,
656 audit: crate::config::AuditConfig::default(),
657 codeowners: None,
658 public_packages: vec![],
659 flags: FlagsConfig::default(),
660 fix: crate::config::FixConfig::default(),
661 resolve: ResolveConfig::default(),
662 sealed: false,
663 include_entry_exports: false,
664 };
665 let resolved = config.resolve(
666 PathBuf::from("/project"),
667 OutputFormat::Human,
668 1,
669 true,
670 true,
671 );
672
673 let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
675 assert_eq!(test_rules.unused_exports, Severity::Off);
676 assert_eq!(test_rules.unused_files, Severity::Error); let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
680 assert_eq!(src_rules.unused_exports, Severity::Error);
681 }
682
683 #[test]
684 fn resolve_rules_for_path_later_override_wins() {
685 let config = FallowConfig {
686 schema: None,
687 extends: vec![],
688 entry: vec![],
689 ignore_patterns: vec![],
690 framework: vec![],
691 workspaces: None,
692 ignore_dependencies: vec![],
693 ignore_exports: vec![],
694 ignore_catalog_references: vec![],
695 ignore_dependency_overrides: vec![],
696 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
697 used_class_members: vec![],
698 duplicates: DuplicatesConfig::default(),
699 health: HealthConfig::default(),
700 rules: RulesConfig::default(),
701 boundaries: BoundaryConfig::default(),
702 production: false.into(),
703 plugins: vec![],
704 dynamically_loaded: vec![],
705 overrides: vec![
706 ConfigOverride {
707 files: vec!["*.ts".to_string()],
708 rules: PartialRulesConfig {
709 unused_files: Some(Severity::Warn),
710 ..Default::default()
711 },
712 },
713 ConfigOverride {
714 files: vec!["*.test.ts".to_string()],
715 rules: PartialRulesConfig {
716 unused_files: Some(Severity::Off),
717 ..Default::default()
718 },
719 },
720 ],
721 regression: None,
722 audit: crate::config::AuditConfig::default(),
723 codeowners: None,
724 public_packages: vec![],
725 flags: FlagsConfig::default(),
726 fix: crate::config::FixConfig::default(),
727 resolve: ResolveConfig::default(),
728 sealed: false,
729 include_entry_exports: false,
730 };
731 let resolved = config.resolve(
732 PathBuf::from("/project"),
733 OutputFormat::Human,
734 1,
735 true,
736 true,
737 );
738
739 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
741 assert_eq!(rules.unused_files, Severity::Off);
742
743 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
745 assert_eq!(rules2.unused_files, Severity::Warn);
746 }
747
748 #[test]
749 fn resolve_keeps_inter_file_rule_override_after_warning() {
750 let config = FallowConfig {
756 schema: None,
757 extends: vec![],
758 entry: vec![],
759 ignore_patterns: vec![],
760 framework: vec![],
761 workspaces: None,
762 ignore_dependencies: vec![],
763 ignore_exports: vec![],
764 ignore_catalog_references: vec![],
765 ignore_dependency_overrides: vec![],
766 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
767 used_class_members: vec![],
768 duplicates: DuplicatesConfig::default(),
769 health: HealthConfig::default(),
770 rules: RulesConfig::default(),
771 boundaries: BoundaryConfig::default(),
772 production: false.into(),
773 plugins: vec![],
774 dynamically_loaded: vec![],
775 overrides: vec![ConfigOverride {
776 files: vec!["**/ui/**".to_string()],
777 rules: PartialRulesConfig {
778 duplicate_exports: Some(Severity::Off),
779 unused_files: Some(Severity::Warn),
780 ..Default::default()
781 },
782 }],
783 regression: None,
784 audit: crate::config::AuditConfig::default(),
785 codeowners: None,
786 public_packages: vec![],
787 flags: FlagsConfig::default(),
788 fix: crate::config::FixConfig::default(),
789 resolve: ResolveConfig::default(),
790 sealed: false,
791 include_entry_exports: false,
792 };
793 let resolved = config.resolve(
794 PathBuf::from("/project"),
795 OutputFormat::Human,
796 1,
797 true,
798 true,
799 );
800 assert_eq!(
801 resolved.overrides.len(),
802 1,
803 "inter-file rule warning must not drop the override; co-located non-inter-file rules still apply"
804 );
805 let rules = resolved.resolve_rules_for_path(Path::new("/project/ui/dialog.ts"));
806 assert_eq!(rules.unused_files, Severity::Warn);
807 }
808
809 #[test]
810 fn inter_file_warn_dedup_returns_true_only_on_first_key_match() {
811 reset_inter_file_warn_dedup_for_test();
815 let files_a = vec!["__test_dedup_a/*".to_string()];
816 let files_b = vec!["__test_dedup_b/*".to_string()];
817
818 assert!(record_inter_file_warn_seen("duplicate-exports", &files_a));
820 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
821 assert!(!record_inter_file_warn_seen("duplicate-exports", &files_a));
822
823 assert!(record_inter_file_warn_seen("circular-dependency", &files_a));
825 assert!(!record_inter_file_warn_seen(
826 "circular-dependency",
827 &files_a
828 ));
829
830 assert!(record_inter_file_warn_seen("duplicate-exports", &files_b));
832
833 let files_reordered = vec![
835 "__test_dedup_b/*".to_string(),
836 "__test_dedup_a/*".to_string(),
837 ];
838 let files_natural = vec![
839 "__test_dedup_a/*".to_string(),
840 "__test_dedup_b/*".to_string(),
841 ];
842 reset_inter_file_warn_dedup_for_test();
843 assert!(record_inter_file_warn_seen(
844 "duplicate-exports",
845 &files_natural
846 ));
847 assert!(!record_inter_file_warn_seen(
848 "duplicate-exports",
849 &files_reordered
850 ));
851 }
852
853 #[test]
854 fn resolve_called_n_times_dedupes_inter_file_warning_to_one() {
855 reset_inter_file_warn_dedup_for_test();
860 let files = vec!["__test_resolve_dedup/**".to_string()];
861 let build_config = || FallowConfig {
862 schema: None,
863 extends: vec![],
864 entry: vec![],
865 ignore_patterns: vec![],
866 framework: vec![],
867 workspaces: None,
868 ignore_dependencies: vec![],
869 ignore_exports: vec![],
870 ignore_catalog_references: vec![],
871 ignore_dependency_overrides: vec![],
872 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
873 used_class_members: vec![],
874 duplicates: DuplicatesConfig::default(),
875 health: HealthConfig::default(),
876 rules: RulesConfig::default(),
877 boundaries: BoundaryConfig::default(),
878 production: false.into(),
879 plugins: vec![],
880 dynamically_loaded: vec![],
881 overrides: vec![ConfigOverride {
882 files: files.clone(),
883 rules: PartialRulesConfig {
884 duplicate_exports: Some(Severity::Off),
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 };
898 for _ in 0..10 {
899 let _ = build_config().resolve(
900 PathBuf::from("/project"),
901 OutputFormat::Human,
902 1,
903 true,
904 true,
905 );
906 }
907 assert!(
911 !record_inter_file_warn_seen("duplicate-exports", &files),
912 "warn key for duplicate-exports + __test_resolve_dedup/** should be marked after the first resolve"
913 );
914 }
915
916 fn make_config(production: bool) -> FallowConfig {
918 FallowConfig {
919 schema: None,
920 extends: vec![],
921 entry: vec![],
922 ignore_patterns: vec![],
923 framework: vec![],
924 workspaces: None,
925 ignore_dependencies: vec![],
926 ignore_exports: vec![],
927 ignore_catalog_references: vec![],
928 ignore_dependency_overrides: vec![],
929 ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig::default(),
930 used_class_members: vec![],
931 duplicates: DuplicatesConfig::default(),
932 health: HealthConfig::default(),
933 rules: RulesConfig::default(),
934 boundaries: BoundaryConfig::default(),
935 production: production.into(),
936 plugins: vec![],
937 dynamically_loaded: vec![],
938 overrides: vec![],
939 regression: None,
940 audit: crate::config::AuditConfig::default(),
941 codeowners: None,
942 public_packages: vec![],
943 flags: FlagsConfig::default(),
944 fix: crate::config::FixConfig::default(),
945 resolve: ResolveConfig::default(),
946 sealed: false,
947 include_entry_exports: false,
948 }
949 }
950
951 #[test]
954 fn resolve_production_forces_dev_deps_off() {
955 let resolved = make_config(true).resolve(
956 PathBuf::from("/project"),
957 OutputFormat::Human,
958 1,
959 true,
960 true,
961 );
962 assert_eq!(
963 resolved.rules.unused_dev_dependencies,
964 Severity::Off,
965 "production mode should force unused_dev_dependencies to off"
966 );
967 }
968
969 #[test]
970 fn resolve_production_forces_optional_deps_off() {
971 let resolved = make_config(true).resolve(
972 PathBuf::from("/project"),
973 OutputFormat::Human,
974 1,
975 true,
976 true,
977 );
978 assert_eq!(
979 resolved.rules.unused_optional_dependencies,
980 Severity::Off,
981 "production mode should force unused_optional_dependencies to off"
982 );
983 }
984
985 #[test]
986 fn resolve_production_preserves_other_rules() {
987 let resolved = make_config(true).resolve(
988 PathBuf::from("/project"),
989 OutputFormat::Human,
990 1,
991 true,
992 true,
993 );
994 assert_eq!(resolved.rules.unused_files, Severity::Error);
996 assert_eq!(resolved.rules.unused_exports, Severity::Error);
997 assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
998 }
999
1000 #[test]
1001 fn resolve_non_production_keeps_dev_deps_default() {
1002 let resolved = make_config(false).resolve(
1003 PathBuf::from("/project"),
1004 OutputFormat::Human,
1005 1,
1006 true,
1007 true,
1008 );
1009 assert_eq!(
1010 resolved.rules.unused_dev_dependencies,
1011 Severity::Warn,
1012 "non-production should keep default severity"
1013 );
1014 assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Warn);
1015 }
1016
1017 #[test]
1018 fn resolve_production_flag_stored() {
1019 let resolved = make_config(true).resolve(
1020 PathBuf::from("/project"),
1021 OutputFormat::Human,
1022 1,
1023 true,
1024 true,
1025 );
1026 assert!(resolved.production);
1027
1028 let resolved2 = make_config(false).resolve(
1029 PathBuf::from("/project"),
1030 OutputFormat::Human,
1031 1,
1032 true,
1033 true,
1034 );
1035 assert!(!resolved2.production);
1036 }
1037
1038 #[test]
1041 fn resolve_default_ignores_node_modules() {
1042 let resolved = make_config(false).resolve(
1043 PathBuf::from("/project"),
1044 OutputFormat::Human,
1045 1,
1046 true,
1047 true,
1048 );
1049 assert!(
1050 resolved
1051 .ignore_patterns
1052 .is_match("node_modules/lodash/index.js")
1053 );
1054 assert!(
1055 resolved
1056 .ignore_patterns
1057 .is_match("packages/a/node_modules/react/index.js")
1058 );
1059 }
1060
1061 #[test]
1062 fn resolve_default_ignores_dist() {
1063 let resolved = make_config(false).resolve(
1064 PathBuf::from("/project"),
1065 OutputFormat::Human,
1066 1,
1067 true,
1068 true,
1069 );
1070 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
1071 assert!(
1072 resolved
1073 .ignore_patterns
1074 .is_match("packages/ui/dist/index.js")
1075 );
1076 }
1077
1078 #[test]
1079 fn resolve_default_ignores_root_build_only() {
1080 let resolved = make_config(false).resolve(
1081 PathBuf::from("/project"),
1082 OutputFormat::Human,
1083 1,
1084 true,
1085 true,
1086 );
1087 assert!(
1088 resolved.ignore_patterns.is_match("build/output.js"),
1089 "root build/ should be ignored"
1090 );
1091 assert!(
1093 !resolved.ignore_patterns.is_match("src/build/helper.ts"),
1094 "nested build/ should NOT be ignored by default"
1095 );
1096 }
1097
1098 #[test]
1099 fn resolve_default_ignores_minified_files() {
1100 let resolved = make_config(false).resolve(
1101 PathBuf::from("/project"),
1102 OutputFormat::Human,
1103 1,
1104 true,
1105 true,
1106 );
1107 assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
1108 assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
1109 }
1110
1111 #[test]
1112 fn resolve_default_ignores_git() {
1113 let resolved = make_config(false).resolve(
1114 PathBuf::from("/project"),
1115 OutputFormat::Human,
1116 1,
1117 true,
1118 true,
1119 );
1120 assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
1121 }
1122
1123 #[test]
1124 fn resolve_default_ignores_coverage() {
1125 let resolved = make_config(false).resolve(
1126 PathBuf::from("/project"),
1127 OutputFormat::Human,
1128 1,
1129 true,
1130 true,
1131 );
1132 assert!(
1133 resolved
1134 .ignore_patterns
1135 .is_match("coverage/lcov-report/index.js")
1136 );
1137 }
1138
1139 #[test]
1140 fn resolve_source_files_not_ignored_by_default() {
1141 let resolved = make_config(false).resolve(
1142 PathBuf::from("/project"),
1143 OutputFormat::Human,
1144 1,
1145 true,
1146 true,
1147 );
1148 assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
1149 assert!(
1150 !resolved
1151 .ignore_patterns
1152 .is_match("src/components/Button.tsx")
1153 );
1154 assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
1155 }
1156
1157 #[test]
1160 fn resolve_custom_ignore_patterns_merged_with_defaults() {
1161 let mut config = make_config(false);
1162 config.ignore_patterns = vec!["**/__generated__/**".to_string()];
1163 let resolved = config.resolve(
1164 PathBuf::from("/project"),
1165 OutputFormat::Human,
1166 1,
1167 true,
1168 true,
1169 );
1170 assert!(
1172 resolved
1173 .ignore_patterns
1174 .is_match("src/__generated__/types.ts")
1175 );
1176 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
1178 }
1179
1180 #[test]
1183 fn resolve_passes_through_entry_patterns() {
1184 let mut config = make_config(false);
1185 config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
1186 let resolved = config.resolve(
1187 PathBuf::from("/project"),
1188 OutputFormat::Human,
1189 1,
1190 true,
1191 true,
1192 );
1193 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
1194 }
1195
1196 #[test]
1197 fn resolve_passes_through_ignore_dependencies() {
1198 let mut config = make_config(false);
1199 config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
1200 let resolved = config.resolve(
1201 PathBuf::from("/project"),
1202 OutputFormat::Human,
1203 1,
1204 true,
1205 true,
1206 );
1207 assert_eq!(
1208 resolved.ignore_dependencies,
1209 vec!["postcss", "autoprefixer"]
1210 );
1211 }
1212
1213 #[test]
1214 fn resolve_sets_cache_dir() {
1215 let resolved = make_config(false).resolve(
1216 PathBuf::from("/my/project"),
1217 OutputFormat::Human,
1218 1,
1219 true,
1220 true,
1221 );
1222 assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
1223 }
1224
1225 #[test]
1226 fn resolve_passes_through_thread_count() {
1227 let resolved = make_config(false).resolve(
1228 PathBuf::from("/project"),
1229 OutputFormat::Human,
1230 8,
1231 true,
1232 true,
1233 );
1234 assert_eq!(resolved.threads, 8);
1235 }
1236
1237 #[test]
1238 fn resolve_passes_through_quiet_flag() {
1239 let resolved = make_config(false).resolve(
1240 PathBuf::from("/project"),
1241 OutputFormat::Human,
1242 1,
1243 true,
1244 false,
1245 );
1246 assert!(!resolved.quiet);
1247
1248 let resolved2 = make_config(false).resolve(
1249 PathBuf::from("/project"),
1250 OutputFormat::Human,
1251 1,
1252 true,
1253 true,
1254 );
1255 assert!(resolved2.quiet);
1256 }
1257
1258 #[test]
1259 fn resolve_passes_through_no_cache_flag() {
1260 let resolved_no_cache = make_config(false).resolve(
1261 PathBuf::from("/project"),
1262 OutputFormat::Human,
1263 1,
1264 true,
1265 true,
1266 );
1267 assert!(resolved_no_cache.no_cache);
1268
1269 let resolved_with_cache = make_config(false).resolve(
1270 PathBuf::from("/project"),
1271 OutputFormat::Human,
1272 1,
1273 false,
1274 true,
1275 );
1276 assert!(!resolved_with_cache.no_cache);
1277 }
1278
1279 #[test]
1282 fn resolve_override_with_invalid_glob_skipped() {
1283 let mut config = make_config(false);
1284 config.overrides = vec![ConfigOverride {
1285 files: vec!["[invalid".to_string()],
1286 rules: PartialRulesConfig {
1287 unused_files: Some(Severity::Off),
1288 ..Default::default()
1289 },
1290 }];
1291 let resolved = config.resolve(
1292 PathBuf::from("/project"),
1293 OutputFormat::Human,
1294 1,
1295 true,
1296 true,
1297 );
1298 assert!(
1300 resolved.overrides.is_empty(),
1301 "override with invalid glob should be skipped"
1302 );
1303 }
1304
1305 #[test]
1306 fn resolve_override_with_empty_files_skipped() {
1307 let mut config = make_config(false);
1308 config.overrides = vec![ConfigOverride {
1309 files: vec![],
1310 rules: PartialRulesConfig {
1311 unused_files: Some(Severity::Off),
1312 ..Default::default()
1313 },
1314 }];
1315 let resolved = config.resolve(
1316 PathBuf::from("/project"),
1317 OutputFormat::Human,
1318 1,
1319 true,
1320 true,
1321 );
1322 assert!(
1323 resolved.overrides.is_empty(),
1324 "override with no file patterns should be skipped"
1325 );
1326 }
1327
1328 #[test]
1329 fn resolve_multiple_valid_overrides() {
1330 let mut config = make_config(false);
1331 config.overrides = vec![
1332 ConfigOverride {
1333 files: vec!["*.test.ts".to_string()],
1334 rules: PartialRulesConfig {
1335 unused_exports: Some(Severity::Off),
1336 ..Default::default()
1337 },
1338 },
1339 ConfigOverride {
1340 files: vec!["*.stories.tsx".to_string()],
1341 rules: PartialRulesConfig {
1342 unused_files: Some(Severity::Off),
1343 ..Default::default()
1344 },
1345 },
1346 ];
1347 let resolved = config.resolve(
1348 PathBuf::from("/project"),
1349 OutputFormat::Human,
1350 1,
1351 true,
1352 true,
1353 );
1354 assert_eq!(resolved.overrides.len(), 2);
1355 }
1356
1357 #[test]
1360 fn ignore_export_rule_deserialize() {
1361 let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
1362 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1363 assert_eq!(rule.file, "src/types/*.ts");
1364 assert_eq!(rule.exports, vec!["*"]);
1365 }
1366
1367 #[test]
1368 fn ignore_export_rule_specific_exports() {
1369 let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
1370 let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
1371 assert_eq!(rule.exports.len(), 3);
1372 assert!(rule.exports.contains(&"FOO".to_string()));
1373 }
1374
1375 mod proptests {
1376 use super::*;
1377 use proptest::prelude::*;
1378
1379 fn arb_resolved_config(production: bool) -> ResolvedConfig {
1380 make_config(production).resolve(
1381 PathBuf::from("/project"),
1382 OutputFormat::Human,
1383 1,
1384 true,
1385 true,
1386 )
1387 }
1388
1389 proptest! {
1390 #[test]
1392 fn resolved_config_has_default_ignores(production in any::<bool>()) {
1393 let resolved = arb_resolved_config(production);
1394 prop_assert!(
1396 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1397 "Default ignore should match node_modules"
1398 );
1399 prop_assert!(
1400 resolved.ignore_patterns.is_match("dist/bundle.js"),
1401 "Default ignore should match dist"
1402 );
1403 }
1404
1405 #[test]
1407 fn production_forces_dev_deps_off(_unused in Just(())) {
1408 let resolved = arb_resolved_config(true);
1409 prop_assert_eq!(
1410 resolved.rules.unused_dev_dependencies,
1411 Severity::Off,
1412 "Production should force unused_dev_dependencies off"
1413 );
1414 prop_assert_eq!(
1415 resolved.rules.unused_optional_dependencies,
1416 Severity::Off,
1417 "Production should force unused_optional_dependencies off"
1418 );
1419 }
1420
1421 #[test]
1423 fn non_production_preserves_dev_deps_default(_unused in Just(())) {
1424 let resolved = arb_resolved_config(false);
1425 prop_assert_eq!(
1426 resolved.rules.unused_dev_dependencies,
1427 Severity::Warn,
1428 "Non-production should keep default dev dep severity"
1429 );
1430 }
1431
1432 #[test]
1434 fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
1435 let root = PathBuf::from(format!("/project/{dir_suffix}"));
1436 let expected_cache = root.join(".fallow");
1437 let resolved = make_config(false).resolve(
1438 root,
1439 OutputFormat::Human,
1440 1,
1441 true,
1442 true,
1443 );
1444 prop_assert_eq!(
1445 resolved.cache_dir, expected_cache,
1446 "Cache dir should be root/.fallow"
1447 );
1448 }
1449
1450 #[test]
1452 fn threads_passed_through(threads in 1..64usize) {
1453 let resolved = make_config(false).resolve(
1454 PathBuf::from("/project"),
1455 OutputFormat::Human,
1456 threads,
1457 true,
1458 true,
1459 );
1460 prop_assert_eq!(
1461 resolved.threads, threads,
1462 "Thread count should be passed through"
1463 );
1464 }
1465
1466 #[test]
1470 fn custom_ignores_dont_replace_defaults(pattern in "[a-z_]{1,10}/[a-z_]{1,10}") {
1471 let mut config = make_config(false);
1472 config.ignore_patterns = vec![pattern];
1473 let resolved = config.resolve(
1474 PathBuf::from("/project"),
1475 OutputFormat::Human,
1476 1,
1477 true,
1478 true,
1479 );
1480 prop_assert!(
1483 resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
1484 "Default node_modules ignore should still be active"
1485 );
1486 }
1487 }
1488 }
1489
1490 #[test]
1493 fn resolve_expands_boundary_preset() {
1494 use crate::config::boundaries::BoundaryPreset;
1495
1496 let mut config = make_config(false);
1497 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1498 let resolved = config.resolve(
1499 PathBuf::from("/project"),
1500 OutputFormat::Human,
1501 1,
1502 true,
1503 true,
1504 );
1505 assert_eq!(resolved.boundaries.zones.len(), 3);
1507 assert_eq!(resolved.boundaries.rules.len(), 3);
1508 assert_eq!(resolved.boundaries.zones[0].name, "adapters");
1509 assert_eq!(
1510 resolved.boundaries.classify_zone("src/adapters/http.ts"),
1511 Some("adapters")
1512 );
1513 }
1514
1515 #[test]
1516 fn resolve_boundary_preset_with_user_override() {
1517 use crate::config::boundaries::{BoundaryPreset, BoundaryZone};
1518
1519 let mut config = make_config(false);
1520 config.boundaries.preset = Some(BoundaryPreset::Hexagonal);
1521 config.boundaries.zones = vec![BoundaryZone {
1522 name: "domain".to_string(),
1523 patterns: vec!["src/core/**".to_string()],
1524 auto_discover: vec![],
1525 root: None,
1526 }];
1527 let resolved = config.resolve(
1528 PathBuf::from("/project"),
1529 OutputFormat::Human,
1530 1,
1531 true,
1532 true,
1533 );
1534 assert_eq!(resolved.boundaries.zones.len(), 3);
1536 assert_eq!(
1538 resolved.boundaries.classify_zone("src/core/user.ts"),
1539 Some("domain")
1540 );
1541 assert_eq!(
1543 resolved.boundaries.classify_zone("src/domain/user.ts"),
1544 None
1545 );
1546 }
1547
1548 #[test]
1549 fn resolve_no_preset_unchanged() {
1550 let config = make_config(false);
1551 let resolved = config.resolve(
1552 PathBuf::from("/project"),
1553 OutputFormat::Human,
1554 1,
1555 true,
1556 true,
1557 );
1558 assert!(resolved.boundaries.is_empty());
1559 }
1560}