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