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