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